_runAction이 아직 예전 ScaffoldMessenger.showSnackBar(하단 스낵바)를 쓰고 있어서, 하교 처리/반출 허용/출석시간 설정/테스트 초기화 결과가 다른 화면과 다르게 아래에서 떴던 문제 수정. AppNotice.show로 교체해서 웹은 왼쪽 위, 앱은 하단(기존 위치)에 나머지 화면들과 같은 스타일로 뜨게 함. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
1083 lines
36 KiB
Dart
1083 lines
36 KiB
Dart
// 📋 실시간 출석 현황 화면 (UI 전용). 위젯 빌드/레이아웃/스타일만 담당하고,
|
|
// 서버 통신·상태·파생 로직은 lib/function/teacher_attendance_controller.dart가 담당한다.
|
|
import 'package:flutter/material.dart';
|
|
import '../function/teacher_attendance_controller.dart';
|
|
import '../theme/app_palette.dart';
|
|
import 'app_notice.dart';
|
|
import 'launchpad_transition.dart';
|
|
|
|
// -----------------------------------------------------------------------------
|
|
// 📅 [서브 화면 1] 실시간 출석 확인 란 (StudentDashboard 카드 스타일 리스트화)
|
|
// -----------------------------------------------------------------------------
|
|
class TeacherAttendancePage extends StatefulWidget {
|
|
const TeacherAttendancePage({super.key});
|
|
|
|
@override
|
|
State<TeacherAttendancePage> createState() => _TeacherAttendancePageState();
|
|
}
|
|
|
|
class _TeacherAttendancePageState extends State<TeacherAttendancePage> {
|
|
final TeacherAttendanceController _controller = TeacherAttendanceController();
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_controller.init();
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_controller.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
/// 컨트롤러 액션을 실행하고, 결과 메시지를 알약형 알림으로 보여준다.
|
|
Future<void> _runAction(Future<String> Function() action) async {
|
|
final message = await action();
|
|
if (!mounted) return;
|
|
AppNotice.show(context, message);
|
|
}
|
|
|
|
Future<void> _showAttendanceTimeDialog() async {
|
|
final String? current = _controller.attendanceTime;
|
|
final TimeOfDay initial = current != null
|
|
? TimeOfDay(
|
|
hour: int.parse(current.split(':')[0]),
|
|
minute: int.parse(current.split(':')[1]),
|
|
)
|
|
: const TimeOfDay(hour: 19, minute: 0);
|
|
|
|
final TimeOfDay? picked = await showTimePicker(
|
|
context: context,
|
|
initialTime: initial,
|
|
helpText: '자습실 출석시간 지정',
|
|
);
|
|
if (picked == null) return;
|
|
|
|
final String formatted =
|
|
'${picked.hour.toString().padLeft(2, '0')}:${picked.minute.toString().padLeft(2, '0')}';
|
|
await _runAction(() => _controller.setAttendanceTime(formatted));
|
|
}
|
|
|
|
void _showTestResetConfirmDialog() {
|
|
showDialog(
|
|
context: context,
|
|
builder: (context) => AlertDialog(
|
|
title: const Text('테스트용 허용시간 초기화'),
|
|
content: const Text(
|
|
'하교 처리나 반출 허용 시간 설정으로 켜져 있는 모든 허용 시간대를 지금 즉시 해제합니다.\n'
|
|
'(무단반출 감지 테스트할 때만 사용하세요)',
|
|
),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: () => Navigator.pop(context),
|
|
child: const Text('취소'),
|
|
),
|
|
ElevatedButton(
|
|
onPressed: () {
|
|
Navigator.pop(context);
|
|
_runAction(() => _controller.resetTestPermissions());
|
|
},
|
|
style: ElevatedButton.styleFrom(
|
|
backgroundColor: Colors.grey[700],
|
|
foregroundColor: Colors.white,
|
|
),
|
|
child: const Text('초기화'),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
void _showAllowDialog(String studentId, String studentName) {
|
|
final controller = TextEditingController(text: "5");
|
|
showDialog(
|
|
context: context,
|
|
builder: (context) => AlertDialog(
|
|
title: Text('$studentName 학생 반출 허용'),
|
|
content: TextField(
|
|
controller: controller,
|
|
keyboardType: TextInputType.number,
|
|
decoration: const InputDecoration(
|
|
labelText: '허용 시간 (분)',
|
|
border: OutlineInputBorder(),
|
|
),
|
|
),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: () => Navigator.pop(context),
|
|
child: const Text('취소'),
|
|
),
|
|
ElevatedButton(
|
|
onPressed: () {
|
|
final minutes = int.tryParse(controller.text.trim()) ?? 5;
|
|
Navigator.pop(context);
|
|
_runAction(() => _controller.allowRemoval(studentId, minutes));
|
|
},
|
|
child: const Text('허용하기'),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
void _showPermissionWindowDialog() {
|
|
final controller = TextEditingController(text: "10");
|
|
showDialog(
|
|
context: context,
|
|
builder: (context) => AlertDialog(
|
|
title: const Text('전체 학생 반출 허용 시간 설정'),
|
|
content: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
const Text(
|
|
'쉬는시간처럼 지금부터 일정 시간 동안 모든 학생의 반출을 자동으로 허용합니다.',
|
|
style: TextStyle(fontSize: 13, color: Colors.grey),
|
|
),
|
|
const SizedBox(height: 16),
|
|
TextField(
|
|
controller: controller,
|
|
keyboardType: TextInputType.number,
|
|
decoration: const InputDecoration(
|
|
labelText: '지금부터 허용 시간 (분)',
|
|
border: OutlineInputBorder(),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: () => Navigator.pop(context),
|
|
child: const Text('취소'),
|
|
),
|
|
ElevatedButton(
|
|
onPressed: () {
|
|
final minutes = int.tryParse(controller.text.trim()) ?? 10;
|
|
Navigator.pop(context);
|
|
_runAction(() => _controller.setPermissionWindow(minutes));
|
|
},
|
|
child: const Text('설정하기'),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
/// 🏫 하교 처리: 시간 입력 없이 바로 전체 학생의 반출을 (사실상 무기한) 허용한다.
|
|
void _showDismissalConfirmDialog() {
|
|
showDialog(
|
|
context: context,
|
|
builder: (context) => AlertDialog(
|
|
title: const Text('하교 처리'),
|
|
content: const Text(
|
|
'지금부터 모든 학생의 반출이 자동으로 허용되며, 더 이상 무단반출 경고가 뜨지 않습니다.\n하교 처리하시겠습니까?',
|
|
),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: () => Navigator.pop(context),
|
|
child: const Text('취소'),
|
|
),
|
|
ElevatedButton(
|
|
onPressed: () {
|
|
Navigator.pop(context);
|
|
_runAction(() => _controller.dismissAll());
|
|
},
|
|
child: const Text('하교 처리'),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return ListenableBuilder(
|
|
listenable: _controller,
|
|
builder: (context, _) {
|
|
final all = _controller.combinedStudentStatus;
|
|
final int totalCount = all.length;
|
|
final int checkedInCount = all
|
|
.where((s) => s['isAttendanceComplete'] == true)
|
|
.length;
|
|
final int absentCount = totalCount - checkedInCount;
|
|
|
|
return Scaffold(
|
|
backgroundColor: AppPalette.mist,
|
|
// 🪶 검정 배너 대신, 제목 알약은 "전체 학생 수" 버튼 바로 위(사이드바/모바일 요약 위)에 둔다.
|
|
// 나머지 기능은 전부 "자습실 시간 설정 메뉴"(런치패드 스타일 오버레이) 하나로 모은다.
|
|
// 폰에서는 사이드바가 없으니 여기 아이콘으로도 같은 메뉴를 열 수 있게 둔다.
|
|
appBar: AppBar(
|
|
backgroundColor: Colors.transparent,
|
|
foregroundColor: AppPalette.ink,
|
|
elevation: 0,
|
|
actions: [
|
|
IconButton(
|
|
icon: const Icon(Icons.more_horiz_rounded),
|
|
tooltip: '자습실 시간 설정 메뉴',
|
|
onPressed: _showTimeSettingsMenu,
|
|
),
|
|
const SizedBox(width: 4),
|
|
],
|
|
),
|
|
body: _controller.isLoading
|
|
? const Center(child: CircularProgressIndicator())
|
|
: LayoutBuilder(
|
|
builder: (context, constraints) {
|
|
final bool isWide = constraints.maxWidth >= 800;
|
|
return isWide
|
|
? _buildDesktopBody(
|
|
totalCount,
|
|
checkedInCount,
|
|
absentCount,
|
|
)
|
|
: _buildMobileBody(
|
|
totalCount,
|
|
checkedInCount,
|
|
absentCount,
|
|
);
|
|
},
|
|
),
|
|
);
|
|
},
|
|
);
|
|
}
|
|
|
|
// 🪶 "실시간 출석 현황" 제목 알약. 사이드바/모바일 요약 바로 위에 둔다.
|
|
Widget _buildTitlePill() {
|
|
return Container(
|
|
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 10),
|
|
decoration: BoxDecoration(
|
|
color: AppPalette.ink,
|
|
borderRadius: BorderRadius.circular(999),
|
|
),
|
|
child: const Text(
|
|
'실시간 출석 현황',
|
|
textAlign: TextAlign.center,
|
|
style: TextStyle(
|
|
color: Colors.white,
|
|
fontWeight: FontWeight.bold,
|
|
fontSize: 15,
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
// -----------------------------------------------------------------------
|
|
// 📱 모바일 레이아웃 (기존 카드 리스트)
|
|
// -----------------------------------------------------------------------
|
|
Widget _buildMobileBody(int total, int checkedIn, int absent) {
|
|
final groups = _controller.studentsByGrade;
|
|
return Column(
|
|
children: [
|
|
const SizedBox(height: 12),
|
|
_buildTitlePill(),
|
|
const SizedBox(height: 12),
|
|
_buildSummaryCards(total, checkedIn, absent),
|
|
_buildPermissionBanner(),
|
|
_buildFilterChips(),
|
|
Expanded(
|
|
child: groups.isEmpty
|
|
? const Center(
|
|
child: Text(
|
|
'해당하는 학생이 없습니다.',
|
|
style: TextStyle(color: Colors.grey),
|
|
),
|
|
)
|
|
: _buildGradeGroupedList(
|
|
groups,
|
|
padding: const EdgeInsets.fromLTRB(16, 12, 16, 16),
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
/// 🏫 학년별로 섹션 제목을 붙여 그리드를 이어붙인 목록. 모바일/데스크톱 공용.
|
|
Widget _buildGradeGroupedList(
|
|
List<MapEntry<String, List<Map<String, dynamic>>>> groups, {
|
|
required EdgeInsets padding,
|
|
}) {
|
|
return ListView.builder(
|
|
padding: padding,
|
|
itemCount: groups.length,
|
|
itemBuilder: (context, index) {
|
|
final group = groups[index];
|
|
return Padding(
|
|
padding: EdgeInsets.only(bottom: index == groups.length - 1 ? 0 : 24),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Row(
|
|
children: [
|
|
Container(
|
|
width: 4,
|
|
height: 16,
|
|
decoration: BoxDecoration(
|
|
color: AppPalette.ink,
|
|
borderRadius: BorderRadius.circular(2),
|
|
),
|
|
),
|
|
const SizedBox(width: 8),
|
|
Text(
|
|
group.key,
|
|
style: const TextStyle(
|
|
fontSize: 16,
|
|
fontWeight: FontWeight.bold,
|
|
color: AppPalette.ink,
|
|
),
|
|
),
|
|
const SizedBox(width: 6),
|
|
Text(
|
|
'${group.value.length}명',
|
|
style: TextStyle(fontSize: 13, color: Colors.grey[500]),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 10),
|
|
GridView.builder(
|
|
shrinkWrap: true,
|
|
physics: const NeverScrollableScrollPhysics(),
|
|
gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent(
|
|
maxCrossAxisExtent: 220,
|
|
mainAxisSpacing: 12,
|
|
crossAxisSpacing: 12,
|
|
mainAxisExtent: 148,
|
|
),
|
|
itemCount: group.value.length,
|
|
itemBuilder: (context, i) => _buildStudentTile(group.value[i]),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
},
|
|
);
|
|
}
|
|
|
|
/// 🀄 학생 한 명을 "한눈에 보기" 타일로 그린다. 모바일/데스크톱 그리드에서 공용으로 쓴다.
|
|
Widget _buildStudentTile(Map<String, dynamic> student) {
|
|
final bool isCheckedIn = student['isCheckedIn'];
|
|
final bool hasViolation = student['hasActiveViolation'] == true;
|
|
final bool isPending = student['attendanceStatus'] == 'PENDING';
|
|
final bool isComplete = student['attendanceStatus'] == 'COMPLETE';
|
|
final Color statusColor = hasViolation
|
|
? Colors.red
|
|
: (isPending ? Colors.orange : (isComplete ? Colors.blue : Colors.red));
|
|
final String statusLine = hasViolation
|
|
? '무단반출 (${student['violationTime']})'
|
|
: (isPending
|
|
? '미완료 (${student['checkInTime']})'
|
|
: (isComplete
|
|
? '${student['checkInTime']}'
|
|
: (student['hasEverCheckedIn'] == true ? '미제출' : '미등록')));
|
|
|
|
return Container(
|
|
padding: const EdgeInsets.all(14),
|
|
decoration: BoxDecoration(
|
|
color: hasViolation ? Colors.red[50] : AppPalette.paper,
|
|
borderRadius: BorderRadius.circular(20),
|
|
border: Border.all(
|
|
color: hasViolation ? Colors.red[300]! : AppPalette.sage,
|
|
width: hasViolation ? 1.5 : 1,
|
|
),
|
|
boxShadow: [
|
|
BoxShadow(
|
|
color: Colors.black.withValues(alpha: 0.03),
|
|
blurRadius: 12,
|
|
offset: const Offset(0, 4),
|
|
),
|
|
],
|
|
),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Row(
|
|
children: [
|
|
Container(
|
|
padding: const EdgeInsets.all(8),
|
|
decoration: BoxDecoration(
|
|
color: statusColor.withValues(alpha: 0.1),
|
|
shape: BoxShape.circle,
|
|
),
|
|
child: Icon(
|
|
hasViolation
|
|
? Icons.warning_amber_rounded
|
|
: (isPending
|
|
? Icons.hourglass_bottom_rounded
|
|
: (isComplete
|
|
? Icons.check_circle_rounded
|
|
: Icons.error_rounded)),
|
|
color: statusColor,
|
|
size: 18,
|
|
),
|
|
),
|
|
const Spacer(),
|
|
if (isCheckedIn && student['pocketNumber'] != null)
|
|
Container(
|
|
padding: const EdgeInsets.symmetric(
|
|
horizontal: 8,
|
|
vertical: 3,
|
|
),
|
|
decoration: BoxDecoration(
|
|
color: AppPalette.linen,
|
|
borderRadius: BorderRadius.circular(20),
|
|
border: Border.all(color: AppPalette.sage),
|
|
),
|
|
child: Text(
|
|
student['pocketNumber'],
|
|
style: const TextStyle(
|
|
fontWeight: FontWeight.bold,
|
|
color: AppPalette.ink,
|
|
fontSize: 11,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 8),
|
|
Text(
|
|
'${student['studentName']}',
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 15),
|
|
),
|
|
Text(
|
|
'학번 ${student['studentId']}',
|
|
style: TextStyle(color: Colors.grey[500], fontSize: 11),
|
|
),
|
|
const SizedBox(height: 4),
|
|
Text(
|
|
statusLine,
|
|
maxLines: 2,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: TextStyle(
|
|
color: hasViolation
|
|
? Colors.red[700]
|
|
: (isPending
|
|
? Colors.orange[800]
|
|
: (isComplete ? Colors.grey[600] : Colors.red[400])),
|
|
fontSize: 11.5,
|
|
fontWeight: hasViolation ? FontWeight.bold : FontWeight.normal,
|
|
),
|
|
),
|
|
if (hasViolation) ...[
|
|
const Spacer(),
|
|
SizedBox(
|
|
width: double.infinity,
|
|
height: 30,
|
|
child: ElevatedButton(
|
|
onPressed: () => _showAllowDialog(
|
|
student['studentId'],
|
|
student['studentName'],
|
|
),
|
|
style: ElevatedButton.styleFrom(
|
|
backgroundColor: Colors.red[600],
|
|
foregroundColor: Colors.white,
|
|
padding: EdgeInsets.zero,
|
|
),
|
|
child: const Text('반출 허용', style: TextStyle(fontSize: 12)),
|
|
),
|
|
),
|
|
],
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
// -----------------------------------------------------------------------
|
|
// 🖥️ 데스크톱 레이아웃 (선생님이 교실 컴퓨터 브라우저로 접속했을 때)
|
|
// 왼쪽에 요약/필터 사이드바, 오른쪽에 좌석표처럼 촘촘한 학생 칸 그리드를 둬서
|
|
// 80명이 넘는 인원도 스크롤을 최소화하고 한눈에 볼 수 있게 한다.
|
|
// -----------------------------------------------------------------------
|
|
Widget _buildDesktopBody(int total, int checkedIn, int absent) {
|
|
final students = _controller.filteredStudents;
|
|
return Padding(
|
|
padding: const EdgeInsets.all(24.0),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
_buildPermissionBanner(),
|
|
const SizedBox(height: 12),
|
|
Expanded(
|
|
child: Row(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
_buildDesktopSidebar(total, checkedIn, absent),
|
|
const SizedBox(width: 20),
|
|
Expanded(
|
|
child: Container(
|
|
width: double.infinity,
|
|
decoration: BoxDecoration(
|
|
color: Colors.white,
|
|
borderRadius: BorderRadius.circular(16),
|
|
boxShadow: [
|
|
BoxShadow(
|
|
color: Colors.black.withValues(alpha: 0.04),
|
|
blurRadius: 16,
|
|
offset: const Offset(0, 4),
|
|
),
|
|
],
|
|
),
|
|
child: _buildDenseSeatGrid(students),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
// 🧾 왼쪽 요약/필터 사이드바. 인원 수 캡슐을 누르면 그 필터가 바로 적용된다.
|
|
Widget _buildDesktopSidebar(int total, int checkedIn, int absent) {
|
|
return SizedBox(
|
|
width: 200,
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
children: [
|
|
Center(child: _buildTitlePill()),
|
|
const SizedBox(height: 16),
|
|
_sidebarStatPill('전체 학생 수', total, 'ALL'),
|
|
const SizedBox(height: 10),
|
|
_sidebarStatPill('출석 학생 수', checkedIn, 'CHECKED_IN'),
|
|
const SizedBox(height: 10),
|
|
_sidebarStatPill('미출석 학생수', absent, 'ABSENT'),
|
|
const SizedBox(height: 20),
|
|
_sidebarFilterButton(),
|
|
const SizedBox(height: 10),
|
|
_sidebarTimeSettingsButton(),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _sidebarTimeSettingsButton() {
|
|
return InkWell(
|
|
onTap: _showTimeSettingsMenu,
|
|
borderRadius: BorderRadius.circular(28),
|
|
child: Container(
|
|
padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 12),
|
|
decoration: BoxDecoration(
|
|
color: AppPalette.paper,
|
|
borderRadius: BorderRadius.circular(28),
|
|
border: Border.all(color: AppPalette.sage),
|
|
),
|
|
child: Row(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
children: [
|
|
const Icon(
|
|
Icons.access_time_rounded,
|
|
size: 18,
|
|
color: AppPalette.ink,
|
|
),
|
|
const SizedBox(width: 8),
|
|
const Flexible(
|
|
child: Text(
|
|
'자습실 시간 설정 메뉴',
|
|
overflow: TextOverflow.ellipsis,
|
|
style: TextStyle(
|
|
fontWeight: FontWeight.bold,
|
|
color: AppPalette.ink,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _sidebarStatPill(String label, int count, String filterValue) {
|
|
final bool selected = _controller.filterType == filterValue;
|
|
return InkWell(
|
|
onTap: () => _controller.setFilter(filterValue),
|
|
borderRadius: BorderRadius.circular(28),
|
|
child: Container(
|
|
padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 16),
|
|
decoration: BoxDecoration(
|
|
color: selected ? AppPalette.ink : AppPalette.paper,
|
|
borderRadius: BorderRadius.circular(28),
|
|
border: Border.all(
|
|
color: selected ? AppPalette.ink : AppPalette.sage,
|
|
),
|
|
),
|
|
child: Column(
|
|
children: [
|
|
Text(
|
|
label,
|
|
style: TextStyle(
|
|
fontSize: 12,
|
|
color: selected ? Colors.white70 : Colors.grey[600],
|
|
),
|
|
),
|
|
const SizedBox(height: 4),
|
|
Text(
|
|
'$count명',
|
|
style: TextStyle(
|
|
fontSize: 18,
|
|
fontWeight: FontWeight.bold,
|
|
color: selected ? Colors.white : AppPalette.ink,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _sidebarFilterButton() {
|
|
return InkWell(
|
|
onTap: _showGradeFilterMenu,
|
|
borderRadius: BorderRadius.circular(28),
|
|
child: Container(
|
|
padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 12),
|
|
decoration: BoxDecoration(
|
|
color: AppPalette.paper,
|
|
borderRadius: BorderRadius.circular(28),
|
|
border: Border.all(color: AppPalette.sage),
|
|
),
|
|
child: Row(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
children: [
|
|
const Icon(
|
|
Icons.filter_list_rounded,
|
|
size: 18,
|
|
color: AppPalette.ink,
|
|
),
|
|
const SizedBox(width: 8),
|
|
Flexible(
|
|
child: Text(
|
|
_gradeFilterLabel(),
|
|
overflow: TextOverflow.ellipsis,
|
|
style: const TextStyle(
|
|
fontWeight: FontWeight.bold,
|
|
color: AppPalette.ink,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
String _gradeFilterLabel() {
|
|
switch (_controller.gradeFilter) {
|
|
case '1':
|
|
return '1학년만';
|
|
case '2':
|
|
return '2학년만';
|
|
case '3':
|
|
return '3학년만';
|
|
default:
|
|
return '학생 나눠보기';
|
|
}
|
|
}
|
|
|
|
// 🚀 "학생 나눠보기" 버튼 — 학년 필터를 런치패드 스타일 메뉴로 고른다.
|
|
Future<void> _showGradeFilterMenu() async {
|
|
final grade = await showLaunchpadMenu<String>(
|
|
context: context,
|
|
builder: (context) => Center(
|
|
child: Material(
|
|
color: AppPalette.paper,
|
|
borderRadius: BorderRadius.circular(20),
|
|
elevation: 8,
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(20),
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
const Text(
|
|
'학년 나눠보기',
|
|
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16),
|
|
),
|
|
const SizedBox(height: 14),
|
|
Wrap(
|
|
spacing: 8,
|
|
runSpacing: 8,
|
|
children: [
|
|
_gradeChoice(context, 'ALL', '전체'),
|
|
_gradeChoice(context, '1', '1학년'),
|
|
_gradeChoice(context, '2', '2학년'),
|
|
_gradeChoice(context, '3', '3학년'),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
if (grade != null) _controller.setGradeFilter(grade);
|
|
}
|
|
|
|
Widget _gradeChoice(BuildContext dialogContext, String value, String label) {
|
|
return ChoiceChip(
|
|
label: Text(label),
|
|
selected: _controller.gradeFilter == value,
|
|
onSelected: (_) => Navigator.pop(dialogContext, value),
|
|
);
|
|
}
|
|
|
|
// 🚀 "자습실 시간 설정 메뉴" — 새로고침/출석시간/반출허용/하교/테스트리셋을
|
|
// 전부 여기 하나로 모아서, 메인 대시보드의 로그아웃/설정 메뉴와 똑같은
|
|
// 런치패드 스타일(배경 블러 유지) 오버레이로 띄운다.
|
|
Future<void> _showTimeSettingsMenu() async {
|
|
final action = await showLaunchpadMenu<String>(
|
|
context: context,
|
|
builder: (context) => Center(
|
|
child: _TimeSettingsMenuCard(
|
|
isRefreshing: _controller.isRefreshing,
|
|
attendanceTimeLabel: _controller.attendanceTime != null
|
|
? '출석시간 ${_controller.attendanceTime}'
|
|
: '자습실 출석시간 설정',
|
|
onPick: (value) => Navigator.pop(context, value),
|
|
),
|
|
),
|
|
);
|
|
if (!mounted || action == null) return;
|
|
switch (action) {
|
|
case 'refresh':
|
|
_controller.manualRefresh();
|
|
break;
|
|
case 'attendance_time':
|
|
_showAttendanceTimeDialog();
|
|
break;
|
|
case 'permission_window':
|
|
_showPermissionWindowDialog();
|
|
break;
|
|
case 'dismissal':
|
|
_showDismissalConfirmDialog();
|
|
break;
|
|
case 'test_reset':
|
|
_showTestResetConfirmDialog();
|
|
break;
|
|
}
|
|
}
|
|
|
|
// 🪑 좌석표처럼 촘촘하게 학생 한 명씩을 작은 칸에 담는 그리드.
|
|
Widget _buildDenseSeatGrid(List<Map<String, dynamic>> students) {
|
|
if (students.isEmpty) {
|
|
return const Center(
|
|
child: Text('해당하는 학생이 없습니다.', style: TextStyle(color: Colors.grey)),
|
|
);
|
|
}
|
|
return GridView.builder(
|
|
padding: const EdgeInsets.all(16),
|
|
gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent(
|
|
maxCrossAxisExtent: 92,
|
|
mainAxisExtent: 62,
|
|
crossAxisSpacing: 6,
|
|
mainAxisSpacing: 6,
|
|
),
|
|
itemCount: students.length,
|
|
itemBuilder: (context, index) => _buildSeatCell(students[index], index),
|
|
);
|
|
}
|
|
|
|
Widget _buildSeatCell(Map<String, dynamic> student, int index) {
|
|
final bool hasViolation = student['hasActiveViolation'] == true;
|
|
final bool isPending = student['attendanceStatus'] == 'PENDING';
|
|
final bool isComplete = student['attendanceStatus'] == 'COMPLETE';
|
|
final Color background = hasViolation
|
|
? Colors.red[50]!
|
|
: isComplete
|
|
? Colors.blue[50]!
|
|
: isPending
|
|
? Colors.orange[50]!
|
|
: AppPalette.paper;
|
|
final Color border = hasViolation
|
|
? Colors.red[300]!
|
|
: isComplete
|
|
? Colors.blue[200]!
|
|
: isPending
|
|
? Colors.orange[200]!
|
|
: AppPalette.sage;
|
|
|
|
return InkWell(
|
|
onTap: hasViolation
|
|
? () => _showAllowDialog(student['studentId'], student['studentName'])
|
|
: null,
|
|
borderRadius: BorderRadius.circular(8),
|
|
child: Container(
|
|
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 4),
|
|
decoration: BoxDecoration(
|
|
color: background,
|
|
borderRadius: BorderRadius.circular(8),
|
|
border: Border.all(color: border),
|
|
),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
children: [
|
|
Text(
|
|
'[${index + 1}] ${student['studentId']}',
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: TextStyle(fontSize: 8, color: Colors.grey[500]),
|
|
),
|
|
Text(
|
|
'${student['studentName']}',
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: const TextStyle(fontSize: 11, fontWeight: FontWeight.bold),
|
|
),
|
|
if (hasViolation)
|
|
Text(
|
|
'무단반출',
|
|
style: TextStyle(
|
|
fontSize: 7.5,
|
|
color: Colors.red[700],
|
|
fontWeight: FontWeight.bold,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
/// 📊 상단 요약 카드 뷰 (전체 / 출석 완료 / 미출석)
|
|
Widget _buildSummaryCards(int total, int checkedIn, int absent) {
|
|
return Container(
|
|
width: double.infinity,
|
|
padding: const EdgeInsets.all(16),
|
|
color: AppPalette.ink,
|
|
child: Row(
|
|
mainAxisAlignment: MainAxisAlignment.spaceAround,
|
|
children: [
|
|
_summaryCard("전체", "$total명", Colors.white70),
|
|
_summaryCard("출석 완료", "$checkedIn명", Colors.greenAccent),
|
|
_summaryCard("미출석", "$absent명", Colors.redAccent),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _summaryCard(String title, String count, Color color) {
|
|
return Column(
|
|
children: [
|
|
Text(title, style: const TextStyle(color: Colors.grey, fontSize: 12)),
|
|
const SizedBox(height: 4),
|
|
Text(
|
|
count,
|
|
style: TextStyle(
|
|
color: color,
|
|
fontSize: 20,
|
|
fontWeight: FontWeight.bold,
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
/// 🔓 하교(12시간)/반출 허용 시간 설정으로 지금 전체 반출이 허용 중이면 눈에 띄게 배너로 알려준다.
|
|
/// (허용 중일 땐 무단반출을 감지해도 대시보드에 뜨지 않기 때문에, 왜 안 뜨는지 헷갈리지 않게 하기 위함)
|
|
Widget _buildPermissionBanner() {
|
|
final String? until = _controller.globalPermissionUntil;
|
|
if (until == null) return const SizedBox.shrink();
|
|
|
|
return Container(
|
|
width: double.infinity,
|
|
margin: const EdgeInsets.fromLTRB(16, 8, 16, 0),
|
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
|
|
decoration: BoxDecoration(
|
|
color: Colors.amber[100],
|
|
borderRadius: BorderRadius.circular(12),
|
|
border: Border.all(color: Colors.amber[400]!),
|
|
),
|
|
child: Row(
|
|
children: [
|
|
Icon(Icons.lock_open_rounded, color: Colors.amber[800], size: 20),
|
|
const SizedBox(width: 8),
|
|
Expanded(
|
|
child: Text(
|
|
'지금 전체 반출 허용 중입니다 ($until 까지) — 이 시간 동안은 무단반출 경고가 뜨지 않아요.',
|
|
style: TextStyle(
|
|
color: Colors.amber[900],
|
|
fontWeight: FontWeight.bold,
|
|
fontSize: 12.5,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
/// 🔘 필터 칩버튼 (전체 / 출석자 / 미출석자)
|
|
Widget _buildFilterChips() {
|
|
return Padding(
|
|
padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 16),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Wrap(
|
|
spacing: 8,
|
|
runSpacing: 8,
|
|
children: [
|
|
FilterChip(
|
|
label: const Text("전체"),
|
|
selected: _controller.filterType == "ALL",
|
|
onSelected: (_) => _controller.setFilter("ALL"),
|
|
),
|
|
FilterChip(
|
|
label: const Text("출석자"),
|
|
selected: _controller.filterType == "CHECKED_IN",
|
|
onSelected: (_) => _controller.setFilter("CHECKED_IN"),
|
|
),
|
|
FilterChip(
|
|
label: const Text("미출석자"),
|
|
selected: _controller.filterType == "ABSENT",
|
|
onSelected: (_) => _controller.setFilter("ABSENT"),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 8),
|
|
Wrap(
|
|
spacing: 8,
|
|
runSpacing: 8,
|
|
children: [
|
|
ChoiceChip(
|
|
label: const Text("학년 전체"),
|
|
selected: _controller.gradeFilter == "ALL",
|
|
onSelected: (_) => _controller.setGradeFilter("ALL"),
|
|
),
|
|
ChoiceChip(
|
|
label: const Text("1학년"),
|
|
selected: _controller.gradeFilter == "1",
|
|
onSelected: (_) => _controller.setGradeFilter("1"),
|
|
),
|
|
ChoiceChip(
|
|
label: const Text("2학년"),
|
|
selected: _controller.gradeFilter == "2",
|
|
onSelected: (_) => _controller.setGradeFilter("2"),
|
|
),
|
|
ChoiceChip(
|
|
label: const Text("3학년"),
|
|
selected: _controller.gradeFilter == "3",
|
|
onSelected: (_) => _controller.setGradeFilter("3"),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
// 🚀 "자습실 시간 설정 메뉴" 카드 — 메인 대시보드 로그아웃/설정 메뉴와 같은 톤으로 통일.
|
|
class _TimeSettingsMenuCard extends StatelessWidget {
|
|
final bool isRefreshing;
|
|
final String attendanceTimeLabel;
|
|
final ValueChanged<String> onPick;
|
|
|
|
const _TimeSettingsMenuCard({
|
|
required this.isRefreshing,
|
|
required this.attendanceTimeLabel,
|
|
required this.onPick,
|
|
});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Material(
|
|
color: AppPalette.paper,
|
|
elevation: 8,
|
|
shadowColor: Colors.black.withValues(alpha: 0.3),
|
|
borderRadius: BorderRadius.circular(20),
|
|
child: IntrinsicWidth(
|
|
child: ConstrainedBox(
|
|
constraints: const BoxConstraints(minWidth: 220),
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
_TimeMenuRow(
|
|
icon: isRefreshing ? null : Icons.refresh_rounded,
|
|
label: '새로고침',
|
|
onTap: () => onPick('refresh'),
|
|
),
|
|
const Divider(height: 1, color: AppPalette.sage),
|
|
_TimeMenuRow(
|
|
icon: Icons.access_time_rounded,
|
|
label: attendanceTimeLabel,
|
|
onTap: () => onPick('attendance_time'),
|
|
),
|
|
const Divider(height: 1, color: AppPalette.sage),
|
|
_TimeMenuRow(
|
|
icon: Icons.timer_outlined,
|
|
label: '반출 허용 시간 설정',
|
|
onTap: () => onPick('permission_window'),
|
|
),
|
|
const Divider(height: 1, color: AppPalette.sage),
|
|
_TimeMenuRow(
|
|
icon: Icons.school_rounded,
|
|
label: '하교 처리',
|
|
onTap: () => onPick('dismissal'),
|
|
),
|
|
const Divider(height: 1, color: AppPalette.sage),
|
|
_TimeMenuRow(
|
|
icon: Icons.bug_report_outlined,
|
|
label: '테스트용: 허용시간 초기화',
|
|
dim: true,
|
|
onTap: () => onPick('test_reset'),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _TimeMenuRow extends StatelessWidget {
|
|
final IconData? icon;
|
|
final String label;
|
|
final VoidCallback onTap;
|
|
final bool dim;
|
|
|
|
const _TimeMenuRow({
|
|
required this.icon,
|
|
required this.label,
|
|
required this.onTap,
|
|
this.dim = false,
|
|
});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return InkWell(
|
|
onTap: onTap,
|
|
child: Padding(
|
|
padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 14),
|
|
child: Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
SizedBox(
|
|
width: 20,
|
|
height: 20,
|
|
child: icon == null
|
|
? const CircularProgressIndicator(strokeWidth: 2)
|
|
: Icon(
|
|
icon,
|
|
size: 20,
|
|
color: dim ? Colors.grey : AppPalette.ink,
|
|
),
|
|
),
|
|
const SizedBox(width: 12),
|
|
Flexible(
|
|
child: Text(
|
|
label,
|
|
style: TextStyle(
|
|
fontWeight: FontWeight.w600,
|
|
color: dim ? Colors.grey[600] : AppPalette.ink,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|