선생님 호출 시스템 추가 (Firebase 버전을 앱 계정 체계로 이전)
별도로 돌아가던 Firebase RTDB 기반 호출 페이지(index.html/teacher.html)에서 발견한 XSS, 익명 접근(아무나 아무 선생님 위치 변경 가능), 이름 위조 문제를 우리 앱 로그인 체계로 재구현하면서 구조적으로 없앴다. 학생은 본인 계정으로만 호출하고 본인 호출 기록만 보이며, 교사는 본인 위치만 수정하고 본인이 받은 호출만 본다. TTS 대신 FCM 푸시로 대체. 시간표 데이터의 "김지"→"조신현" 오타도 "조신"으로 수정해서 옮겼다. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,132 @@
|
||||
// 📅 선생님 호출 화면에서 "지금 수업 중" 배지를 보여주기 위한 시간표 데이터.
|
||||
// 원래 별도 Firebase 페이지(index.html)에 있던 데이터를 그대로 옮기면서,
|
||||
// "김지" 약어가 실제로는 조신현 선생님을 가리키던 오타를 "조신"으로 바로잡았다.
|
||||
// (다른 모든 약어는 이름 앞 두 글자 규칙을 따르는데 이것만 깨져 있었음)
|
||||
class PeriodTime {
|
||||
final int period;
|
||||
final String start;
|
||||
final String end;
|
||||
const PeriodTime(this.period, this.start, this.end);
|
||||
}
|
||||
|
||||
const List<PeriodTime> kPeriodSettings = [
|
||||
PeriodTime(1, "08:40", "09:30"),
|
||||
PeriodTime(2, "09:40", "10:30"),
|
||||
PeriodTime(3, "10:40", "11:30"),
|
||||
PeriodTime(4, "11:40", "12:30"),
|
||||
PeriodTime(5, "13:30", "14:20"),
|
||||
PeriodTime(6, "14:30", "15:20"),
|
||||
PeriodTime(7, "15:30", "16:20"),
|
||||
];
|
||||
|
||||
// 이름 앞 두 글자 약어 → 전체 이름. ("조신":"조신현" 으로 수정됨 - 원래 "김지"였던 오타)
|
||||
const Map<String, String> kTeacherAbbrevMap = {
|
||||
"손계": "손계강",
|
||||
"조신": "조신현",
|
||||
"진경": "진경아",
|
||||
"김만": "김만중",
|
||||
"이길": "이길석",
|
||||
"김효": "김효정",
|
||||
"이부": "이부원",
|
||||
"고경": "고경철",
|
||||
"김경": "김경원",
|
||||
"양성": "양성욱",
|
||||
"김명": "김명선",
|
||||
"김현": "김현진",
|
||||
"박은": "박은호",
|
||||
"심윤": "심윤주",
|
||||
"조영": "조영희",
|
||||
"이정": "이정상",
|
||||
"조용": "조용우",
|
||||
"이민": "이민혁",
|
||||
};
|
||||
|
||||
// day: 1=월 ~ 5=금, period: 1~7. 값은 그 시간에 수업 중인 선생님 약어 목록.
|
||||
const Map<int, Map<int, List<String>>> kFullTimetable = {
|
||||
1: {
|
||||
1: ["손계", "조신", "진경", "김만", "이길", "김효", "이부"],
|
||||
2: ["김경", "손계", "이길", "조영", "김효", "양성", "김명", "김현"],
|
||||
3: ["박은", "심윤", "조영", "고경", "이정", "이부", "김경"],
|
||||
4: ["심윤", "진경", "김효", "이길", "이정", "김만", "김명", "김현", "양성"],
|
||||
5: ["박은", "손계", "이정", "고경", "조영", "김현", "양성", "김명"],
|
||||
6: ["이민", "심윤", "고경", "김만", "이길", "이부", "김경"],
|
||||
7: ["이민", "김경", "박은", "김만", "이길", "조영", "양성", "조신", "손계"],
|
||||
},
|
||||
2: {
|
||||
1: ["손계", "이정", "박은", "김만", "심윤", "이길", "조영", "김현", "조신", "양성"],
|
||||
2: ["김효", "김경", "이정", "고경", "조영", "김만", "양성", "김현", "김명"],
|
||||
3: ["진경", "심윤", "손계", "이민", "이부", "김명", "조용", "조신"],
|
||||
4: ["이정", "진경", "김효", "이민", "이부", "조용", "양성", "김현"],
|
||||
5: ["박은", "김효", "심윤", "이민", "이부", "김명", "김현", "손계"],
|
||||
6: ["김경", "박은", "진경", "이민", "이부", "양성", "김명", "조용"],
|
||||
7: ["진경", "손계", "이부", "김만", "이길", "김효", "박은", "김경", "김현"],
|
||||
},
|
||||
3: {
|
||||
1: ["김효", "김경", "이민", "이부", "김명", "김현", "이정"],
|
||||
2: ["조용", "진경", "김효", "조영", "김만", "이길", "이부", "고경"],
|
||||
3: ["심윤", "손계", "조용", "이민", "박은", "조신", "양성", "이정", "김명"],
|
||||
4: ["진경", "김효", "심윤", "이민", "박은", "조신", "이정", "양성", "김현"],
|
||||
},
|
||||
4: {
|
||||
1: ["심윤", "김경", "조신", "조영", "이길", "김만", "이정", "양성", "김명"],
|
||||
2: ["박은", "진경", "김만", "심윤", "이길", "조영", "이부", "고경"],
|
||||
3: ["진경", "김효", "이민", "이부", "김현", "김명", "이정"],
|
||||
4: ["손계", "김효", "김경", "이민", "박은", "조신", "양성", "이정", "김현"],
|
||||
5: ["조신", "진경", "심윤", "김만", "고경", "조영", "이부", "이민"],
|
||||
6: ["김경", "심윤", "손계", "이길", "조영", "김효", "박은", "진경", "조신"],
|
||||
7: ["김효", "손계", "박은", "고경", "김만", "이길", "김현", "김명", "양성"],
|
||||
},
|
||||
5: {
|
||||
1: ["손계", "박은", "이정", "김만", "조영", "이길", "김현", "김명", "양성"],
|
||||
2: ["이정", "손계", "김경", "이길", "고경", "조영", "김명", "조신", "김현"],
|
||||
3: ["진경", "심윤", "조용", "고경", "김만", "김효", "이부", "김경"],
|
||||
4: ["조용", "이정", "진경", "김만", "심윤", "이길", "조영", "양성", "김현", "김명"],
|
||||
5: ["진경", "손계", "이부", "조용", "조신", "고경", "박은", "김경", "김현"],
|
||||
7: ["김효", "조용", "손계", "조영", "이길", "김만", "이부", "고경"],
|
||||
},
|
||||
};
|
||||
|
||||
/// 지금 이 순간(day: 1~5, HH:mm) 수업 중인 선생님 전체 이름 목록. 주말/쉬는시간이면 빈 목록.
|
||||
List<String> teachersInClassNow(int weekday, String hhmm) {
|
||||
if (weekday < 1 || weekday > 5) return [];
|
||||
PeriodTime? current;
|
||||
for (final p in kPeriodSettings) {
|
||||
if (hhmm.compareTo(p.start) >= 0 && hhmm.compareTo(p.end) <= 0) {
|
||||
current = p;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (current == null) return [];
|
||||
final abbrevs = kFullTimetable[weekday]?[current.period] ?? [];
|
||||
return abbrevs.map((a) => kTeacherAbbrevMap[a]).whereType<String>().toList();
|
||||
}
|
||||
|
||||
const Map<String, String> kLocationIcons = {
|
||||
"교무실": "🏢",
|
||||
"급식실": "🍴",
|
||||
"과학실": "🧪",
|
||||
"도서관": "📖",
|
||||
"출장": "🚗",
|
||||
"조퇴/퇴근": "🏠",
|
||||
"상담실": "💬",
|
||||
"기숙사": "🏨",
|
||||
};
|
||||
|
||||
const List<String> kLocationOptions = [
|
||||
"교무실",
|
||||
"급식실",
|
||||
"과학실",
|
||||
"도서관",
|
||||
"출장",
|
||||
"조퇴/퇴근",
|
||||
"상담실",
|
||||
"기숙사",
|
||||
];
|
||||
|
||||
const List<String> kCallPurposes = [
|
||||
"간단한 용무",
|
||||
"질문 있어요",
|
||||
"상담 신청",
|
||||
"과제 제출",
|
||||
"물건 전달",
|
||||
];
|
||||
@@ -0,0 +1,124 @@
|
||||
// 📍 선생님 호출(학생용) 화면의 기능(서버 통신/상태) 담당 컨트롤러.
|
||||
import 'dart:convert';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import '../config.dart';
|
||||
import '../data/teacher_call_schedule.dart';
|
||||
|
||||
class TeacherCallController extends ChangeNotifier {
|
||||
final String studentId;
|
||||
final String studentName;
|
||||
|
||||
TeacherCallController({required this.studentId, required this.studentName});
|
||||
|
||||
bool _isLoading = true;
|
||||
bool _isCalling = false;
|
||||
List<dynamic> _teachers = [];
|
||||
List<dynamic> _myCalls = [];
|
||||
List<dynamic> _ranking = [];
|
||||
bool _disposed = false;
|
||||
|
||||
bool get isLoading => _isLoading;
|
||||
bool get isCalling => _isCalling;
|
||||
List<dynamic> get teachers => _teachers;
|
||||
List<dynamic> get myCalls => _myCalls;
|
||||
List<dynamic> get ranking => _ranking;
|
||||
|
||||
void _safeNotify() {
|
||||
if (!_disposed) notifyListeners();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_disposed = true;
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
bool isInClassNow(String teacherName) {
|
||||
final now = DateTime.now();
|
||||
final hhmm =
|
||||
'${now.hour.toString().padLeft(2, '0')}:${now.minute.toString().padLeft(2, '0')}';
|
||||
return teachersInClassNow(now.weekday, hhmm).contains(teacherName);
|
||||
}
|
||||
|
||||
Future<void> init() async {
|
||||
await Future.wait([_fetchTeachers(), _fetchMyCalls(), _fetchRanking()]);
|
||||
_isLoading = false;
|
||||
_safeNotify();
|
||||
}
|
||||
|
||||
Future<void> refresh() => init();
|
||||
|
||||
Future<void> _fetchTeachers() async {
|
||||
try {
|
||||
final response = await http.get(
|
||||
Uri.parse('$baseUrl/api/teacher-call/teachers'),
|
||||
);
|
||||
if (response.statusCode == 200) {
|
||||
final data = jsonDecode(utf8.decode(response.bodyBytes));
|
||||
_teachers = data['teachers'] ?? [];
|
||||
}
|
||||
} catch (_) {
|
||||
// 조용히 무시 - 마지막으로 받아온 목록을 유지한다.
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _fetchMyCalls() async {
|
||||
try {
|
||||
final response = await http.get(
|
||||
Uri.parse('$baseUrl/api/teacher-call/my-calls?studentId=$studentId'),
|
||||
);
|
||||
if (response.statusCode == 200) {
|
||||
final data = jsonDecode(utf8.decode(response.bodyBytes));
|
||||
_myCalls = data['calls'] ?? [];
|
||||
}
|
||||
} catch (_) {
|
||||
// 조용히 무시
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _fetchRanking() async {
|
||||
try {
|
||||
final response = await http.get(
|
||||
Uri.parse('$baseUrl/api/teacher-call/ranking'),
|
||||
);
|
||||
if (response.statusCode == 200) {
|
||||
final data = jsonDecode(utf8.decode(response.bodyBytes));
|
||||
_ranking = data['ranking'] ?? [];
|
||||
}
|
||||
} catch (_) {
|
||||
// 조용히 무시
|
||||
}
|
||||
}
|
||||
|
||||
Future<(bool success, String message)> callTeacher({
|
||||
required String teacherId,
|
||||
required String purpose,
|
||||
}) async {
|
||||
_isCalling = true;
|
||||
_safeNotify();
|
||||
try {
|
||||
final response = await http.post(
|
||||
Uri.parse('$baseUrl/api/teacher-call/call'),
|
||||
headers: {"Content-Type": "application/json"},
|
||||
body: jsonEncode({
|
||||
"studentId": studentId,
|
||||
"studentName": studentName,
|
||||
"teacherId": teacherId,
|
||||
"purpose": purpose,
|
||||
}),
|
||||
);
|
||||
final result = jsonDecode(utf8.decode(response.bodyBytes));
|
||||
if (response.statusCode == 200 && result['status'] == 'success') {
|
||||
await Future.wait([_fetchMyCalls(), _fetchRanking()]);
|
||||
return (true, '${result['message'] ?? '호출했습니다.'}');
|
||||
}
|
||||
return (false, '${result['message'] ?? '호출 실패'}');
|
||||
} catch (e) {
|
||||
return (false, '네트워크 에러: $e');
|
||||
} finally {
|
||||
_isCalling = false;
|
||||
_safeNotify();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
// 📍 선생님 위치 등록(교사용) 화면의 기능(서버 통신/상태) 담당 컨트롤러.
|
||||
import 'dart:convert';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import '../config.dart';
|
||||
|
||||
class TeacherLocationController extends ChangeNotifier {
|
||||
final String teacherId;
|
||||
|
||||
TeacherLocationController({required this.teacherId});
|
||||
|
||||
bool _isLoading = true;
|
||||
bool _isSaving = false;
|
||||
String _currentLocation = "교무실";
|
||||
List<dynamic> _receivedCalls = [];
|
||||
bool _disposed = false;
|
||||
|
||||
bool get isLoading => _isLoading;
|
||||
bool get isSaving => _isSaving;
|
||||
String get currentLocation => _currentLocation;
|
||||
List<dynamic> get receivedCalls => _receivedCalls;
|
||||
|
||||
void _safeNotify() {
|
||||
if (!_disposed) notifyListeners();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_disposed = true;
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> init() async {
|
||||
await Future.wait([_fetchMyLocation(), fetchReceivedCalls()]);
|
||||
_isLoading = false;
|
||||
_safeNotify();
|
||||
}
|
||||
|
||||
Future<void> _fetchMyLocation() async {
|
||||
try {
|
||||
final response = await http.get(
|
||||
Uri.parse('$baseUrl/api/teacher-call/teachers'),
|
||||
);
|
||||
if (response.statusCode == 200) {
|
||||
final data = jsonDecode(utf8.decode(response.bodyBytes));
|
||||
final List<dynamic> list = data['teachers'] ?? [];
|
||||
final mine = list.firstWhere(
|
||||
(t) => t['teacherId'] == teacherId,
|
||||
orElse: () => null,
|
||||
);
|
||||
if (mine != null) _currentLocation = mine['location'] ?? '교무실';
|
||||
}
|
||||
} catch (_) {
|
||||
// 조용히 무시
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> fetchReceivedCalls() async {
|
||||
try {
|
||||
final response = await http.get(
|
||||
Uri.parse(
|
||||
'$baseUrl/api/teacher-call/received-calls?teacherId=$teacherId',
|
||||
),
|
||||
);
|
||||
if (response.statusCode == 200) {
|
||||
final data = jsonDecode(utf8.decode(response.bodyBytes));
|
||||
_receivedCalls = data['calls'] ?? [];
|
||||
_safeNotify();
|
||||
}
|
||||
} catch (_) {
|
||||
// 조용히 무시
|
||||
}
|
||||
}
|
||||
|
||||
Future<(bool success, String message)> updateLocation(String location) async {
|
||||
_isSaving = true;
|
||||
_safeNotify();
|
||||
try {
|
||||
final response = await http.post(
|
||||
Uri.parse('$baseUrl/api/teacher-call/location'),
|
||||
headers: {"Content-Type": "application/json"},
|
||||
body: jsonEncode({"teacherId": teacherId, "location": location}),
|
||||
);
|
||||
final result = jsonDecode(utf8.decode(response.bodyBytes));
|
||||
if (response.statusCode == 200 && result['status'] == 'success') {
|
||||
_currentLocation = location;
|
||||
return (true, '${result['message'] ?? '위치가 업데이트되었습니다.'}');
|
||||
}
|
||||
return (false, '${result['message'] ?? '업데이트 실패'}');
|
||||
} catch (e) {
|
||||
return (false, '네트워크 에러: $e');
|
||||
} finally {
|
||||
_isSaving = false;
|
||||
_safeNotify();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -23,6 +23,8 @@ import 'nfc_poccket_checkin_screen.dart';
|
||||
import 'nfc_tag_writer_screen.dart';
|
||||
import 'study_timer_screen.dart';
|
||||
import 'teacher_attendance_page.dart';
|
||||
import 'teacher_call_screen.dart';
|
||||
import 'teacher_location_screen.dart';
|
||||
import 'teacher_student_management_page.dart';
|
||||
|
||||
class MainDashboard extends StatefulWidget {
|
||||
@@ -347,6 +349,22 @@ class _MainDashboardState extends State<MainDashboard> {
|
||||
),
|
||||
),
|
||||
));
|
||||
tiles.add((
|
||||
id: 'teacher_call',
|
||||
child: _buildModernCard(
|
||||
icon: Icons.campaign_rounded,
|
||||
title: '선생님 호출',
|
||||
subtitle: '교무실 위치 확인 및 호출',
|
||||
color: AppPalette.ink,
|
||||
onTap: () => pushLaunchpad(
|
||||
context,
|
||||
(context) => TeacherCallScreen(
|
||||
studentId: _displayId,
|
||||
studentName: _displayName,
|
||||
),
|
||||
),
|
||||
),
|
||||
));
|
||||
}
|
||||
|
||||
if (_isTeacherOrAbove) {
|
||||
@@ -405,6 +423,19 @@ class _MainDashboardState extends State<MainDashboard> {
|
||||
),
|
||||
),
|
||||
));
|
||||
tiles.add((
|
||||
id: 'teacher_location',
|
||||
child: _buildModernCard(
|
||||
icon: Icons.my_location_rounded,
|
||||
title: '내 위치 알리기',
|
||||
subtitle: '학생 호출 확인 및 위치 등록',
|
||||
color: AppPalette.ink,
|
||||
onTap: () => pushLaunchpad(
|
||||
context,
|
||||
(context) => TeacherLocationScreen(teacherId: _displayId),
|
||||
),
|
||||
),
|
||||
));
|
||||
}
|
||||
|
||||
if (_isAdmin) {
|
||||
|
||||
@@ -0,0 +1,360 @@
|
||||
// 📍 선생님 호출 화면(학생용) (UI 전용). 선생님을 골라 방문 목적과 함께 호출한다.
|
||||
// 서버 통신/상태는 lib/function/teacher_call_controller.dart가 담당한다.
|
||||
import 'package:flutter/material.dart';
|
||||
import '../data/teacher_call_schedule.dart';
|
||||
import '../function/teacher_call_controller.dart';
|
||||
import '../theme/app_palette.dart';
|
||||
import 'app_notice.dart';
|
||||
import 'title_pill.dart';
|
||||
|
||||
class TeacherCallScreen extends StatefulWidget {
|
||||
final String studentId;
|
||||
final String studentName;
|
||||
|
||||
const TeacherCallScreen({
|
||||
super.key,
|
||||
required this.studentId,
|
||||
required this.studentName,
|
||||
});
|
||||
|
||||
@override
|
||||
State<TeacherCallScreen> createState() => _TeacherCallScreenState();
|
||||
}
|
||||
|
||||
class _TeacherCallScreenState extends State<TeacherCallScreen> {
|
||||
late final TeacherCallController _controller;
|
||||
String? _selectedTeacherId;
|
||||
String? _selectedTeacherName;
|
||||
String _selectedPurpose = kCallPurposes.first;
|
||||
final _customPurposeController = TextEditingController();
|
||||
bool _useCustomPurpose = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_controller = TeacherCallController(
|
||||
studentId: widget.studentId,
|
||||
studentName: widget.studentName,
|
||||
);
|
||||
_controller.init();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
_customPurposeController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _call() async {
|
||||
if (_selectedTeacherId == null) {
|
||||
AppNotice.show(context, '선생님을 선택해 주세요.');
|
||||
return;
|
||||
}
|
||||
final purpose = _useCustomPurpose
|
||||
? _customPurposeController.text.trim()
|
||||
: _selectedPurpose;
|
||||
if (purpose.isEmpty) {
|
||||
AppNotice.show(context, '방문 목적을 입력해 주세요.');
|
||||
return;
|
||||
}
|
||||
final (success, message) = await _controller.callTeacher(
|
||||
teacherId: _selectedTeacherId!,
|
||||
purpose: purpose,
|
||||
);
|
||||
if (!mounted) return;
|
||||
AppNotice.show(
|
||||
context,
|
||||
message,
|
||||
icon: success ? Icons.check_circle_rounded : Icons.error_outline_rounded,
|
||||
);
|
||||
}
|
||||
|
||||
@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,
|
||||
centerTitle: true,
|
||||
title: const TitlePill('선생님 호출'),
|
||||
),
|
||||
body: _controller.isLoading
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: RefreshIndicator(
|
||||
onRefresh: _controller.refresh,
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
if (_controller.ranking.isNotEmpty) _buildRanking(),
|
||||
const SizedBox(height: 16),
|
||||
const Text(
|
||||
'방문 목적',
|
||||
style: TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
_buildPurposeChips(),
|
||||
if (_useCustomPurpose) ...[
|
||||
const SizedBox(height: 8),
|
||||
TextField(
|
||||
controller: _customPurposeController,
|
||||
maxLength: 40,
|
||||
decoration: InputDecoration(
|
||||
hintText: '용무를 입력하세요',
|
||||
filled: true,
|
||||
fillColor: AppPalette.paper,
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: BorderSide(color: AppPalette.sage),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 16),
|
||||
const Text(
|
||||
'선생님 선택',
|
||||
style: TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
_buildTeacherGrid(),
|
||||
const SizedBox(height: 20),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: ElevatedButton(
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: _selectedTeacherId == null
|
||||
? Colors.grey
|
||||
: AppPalette.ink,
|
||||
foregroundColor: AppPalette.paper,
|
||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
),
|
||||
),
|
||||
onPressed: _controller.isCalling ? null : _call,
|
||||
child: _controller.isCalling
|
||||
? const SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
color: Colors.white,
|
||||
),
|
||||
)
|
||||
: Text(
|
||||
_selectedTeacherName == null
|
||||
? '선생님을 선택해 주세요'
|
||||
: '$_selectedTeacherName 선생님 호출하기',
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
const Text(
|
||||
'내 호출 기록',
|
||||
style: TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
_buildMyCalls(),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildRanking() {
|
||||
final medals = ['🥇', '🥈', '🥉'];
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(14),
|
||||
decoration: BoxDecoration(
|
||||
color: AppPalette.paper,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(color: AppPalette.sage),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'오늘 인기 선생님',
|
||||
style: TextStyle(fontWeight: FontWeight.bold, color: Colors.grey),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||
children: [
|
||||
for (var i = 0; i < _controller.ranking.length; i++)
|
||||
Column(
|
||||
children: [
|
||||
Text(medals[i], style: const TextStyle(fontSize: 22)),
|
||||
Text(
|
||||
_controller.ranking[i]['teacherName'] ?? '',
|
||||
style: const TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
Text(
|
||||
'${_controller.ranking[i]['count']}회',
|
||||
style: const TextStyle(color: Colors.grey, fontSize: 12),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildPurposeChips() {
|
||||
return Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: [
|
||||
for (final p in kCallPurposes)
|
||||
ChoiceChip(
|
||||
label: Text(p),
|
||||
selected: !_useCustomPurpose && _selectedPurpose == p,
|
||||
onSelected: (_) => setState(() {
|
||||
_useCustomPurpose = false;
|
||||
_selectedPurpose = p;
|
||||
}),
|
||||
selectedColor: AppPalette.ink,
|
||||
labelStyle: TextStyle(
|
||||
color: !_useCustomPurpose && _selectedPurpose == p
|
||||
? Colors.white
|
||||
: AppPalette.ink,
|
||||
),
|
||||
),
|
||||
ChoiceChip(
|
||||
label: const Text('직접 입력'),
|
||||
selected: _useCustomPurpose,
|
||||
onSelected: (_) => setState(() => _useCustomPurpose = true),
|
||||
selectedColor: AppPalette.ink,
|
||||
labelStyle: TextStyle(
|
||||
color: _useCustomPurpose ? Colors.white : AppPalette.ink,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTeacherGrid() {
|
||||
return GridView.builder(
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: 3,
|
||||
childAspectRatio: 1,
|
||||
crossAxisSpacing: 10,
|
||||
mainAxisSpacing: 10,
|
||||
),
|
||||
itemCount: _controller.teachers.length,
|
||||
itemBuilder: (context, index) {
|
||||
final t = _controller.teachers[index];
|
||||
final name = t['name'] ?? '';
|
||||
final location = t['location'] ?? '교무실';
|
||||
final isSelected = _selectedTeacherId == t['teacherId'];
|
||||
final inClass = _controller.isInClassNow(name);
|
||||
return GestureDetector(
|
||||
onTap: () => setState(() {
|
||||
_selectedTeacherId = t['teacherId'];
|
||||
_selectedTeacherName = name;
|
||||
}),
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: isSelected ? const Color(0xFFEFF6FF) : AppPalette.paper,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: isSelected ? AppPalette.ink : AppPalette.sage,
|
||||
width: isSelected ? 2 : 1,
|
||||
),
|
||||
),
|
||||
child: Stack(
|
||||
children: [
|
||||
Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
name,
|
||||
style: const TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'${kLocationIcons[location] ?? '📍'} $location',
|
||||
style: const TextStyle(
|
||||
fontSize: 11,
|
||||
color: Colors.grey,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (inClass)
|
||||
Positioned(
|
||||
top: 4,
|
||||
right: 4,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 4,
|
||||
vertical: 1,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.orange[100],
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
border: Border.all(color: Colors.orange),
|
||||
),
|
||||
child: const Text(
|
||||
'수업중',
|
||||
style: TextStyle(fontSize: 9, color: Colors.orange),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildMyCalls() {
|
||||
if (_controller.myCalls.isEmpty) {
|
||||
return const Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 12),
|
||||
child: Text('아직 호출 기록이 없어요.', style: TextStyle(color: Colors.grey)),
|
||||
);
|
||||
}
|
||||
return Column(
|
||||
children: [
|
||||
for (final c in _controller.myCalls)
|
||||
Container(
|
||||
margin: const EdgeInsets.only(bottom: 8),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
|
||||
decoration: BoxDecoration(
|
||||
color: AppPalette.paper,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text('${c['teacherName']} 선생님 · ${c['purpose']}'),
|
||||
),
|
||||
Text(
|
||||
'${c['createdAt'] ?? ''}'.split(' ').last,
|
||||
style: const TextStyle(color: Colors.grey, fontSize: 12),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
// 📍 선생님 위치 등록 화면(교사용) (UI 전용). 본인 위치를 바꾸고, 받은 호출을 확인한다.
|
||||
// 서버 통신/상태는 lib/function/teacher_location_controller.dart가 담당한다.
|
||||
import 'package:flutter/material.dart';
|
||||
import '../data/teacher_call_schedule.dart';
|
||||
import '../function/teacher_location_controller.dart';
|
||||
import '../theme/app_palette.dart';
|
||||
import 'app_notice.dart';
|
||||
import 'title_pill.dart';
|
||||
|
||||
class TeacherLocationScreen extends StatefulWidget {
|
||||
final String teacherId;
|
||||
|
||||
const TeacherLocationScreen({super.key, required this.teacherId});
|
||||
|
||||
@override
|
||||
State<TeacherLocationScreen> createState() => _TeacherLocationScreenState();
|
||||
}
|
||||
|
||||
class _TeacherLocationScreenState extends State<TeacherLocationScreen> {
|
||||
late final TeacherLocationController _controller;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_controller = TeacherLocationController(teacherId: widget.teacherId);
|
||||
_controller.init();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _selectLocation(String location) async {
|
||||
final (success, message) = await _controller.updateLocation(location);
|
||||
if (!mounted) return;
|
||||
AppNotice.show(
|
||||
context,
|
||||
message,
|
||||
icon: success ? Icons.check_circle_rounded : Icons.error_outline_rounded,
|
||||
);
|
||||
}
|
||||
|
||||
@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,
|
||||
centerTitle: true,
|
||||
title: const TitlePill('내 위치 알리기'),
|
||||
),
|
||||
body: _controller.isLoading
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: RefreshIndicator(
|
||||
onRefresh: _controller.fetchReceivedCalls,
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.all(14),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFEFF6FF),
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
const Text(
|
||||
'현재 위치',
|
||||
style: TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
const Spacer(),
|
||||
Text(
|
||||
'${kLocationIcons[_controller.currentLocation] ?? '📍'} ${_controller.currentLocation}',
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: AppPalette.ink,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
const Text(
|
||||
'위치 선택',
|
||||
style: TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
GridView.builder(
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
gridDelegate:
|
||||
const SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: 3,
|
||||
childAspectRatio: 1.3,
|
||||
crossAxisSpacing: 10,
|
||||
mainAxisSpacing: 10,
|
||||
),
|
||||
itemCount: kLocationOptions.length,
|
||||
itemBuilder: (context, index) {
|
||||
final loc = kLocationOptions[index];
|
||||
final isSelected = _controller.currentLocation == loc;
|
||||
return GestureDetector(
|
||||
onTap: _controller.isSaving
|
||||
? null
|
||||
: () => _selectLocation(loc),
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: isSelected
|
||||
? AppPalette.ink
|
||||
: AppPalette.paper,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: isSelected
|
||||
? AppPalette.ink
|
||||
: AppPalette.sage,
|
||||
),
|
||||
),
|
||||
child: Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
kLocationIcons[loc] ?? '📍',
|
||||
style: const TextStyle(fontSize: 20),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
loc,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: isSelected
|
||||
? Colors.white
|
||||
: AppPalette.ink,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
const Text(
|
||||
'나를 찾은 학생 기록',
|
||||
style: TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
_buildReceivedCalls(),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildReceivedCalls() {
|
||||
if (_controller.receivedCalls.isEmpty) {
|
||||
return const Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 12),
|
||||
child: Text('아직 호출 기록이 없어요.', style: TextStyle(color: Colors.grey)),
|
||||
);
|
||||
}
|
||||
return Column(
|
||||
children: [
|
||||
for (final c in _controller.receivedCalls)
|
||||
Container(
|
||||
margin: const EdgeInsets.only(bottom: 8),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
|
||||
decoration: BoxDecoration(
|
||||
color: AppPalette.paper,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text('${c['studentName']} 학생 · ${c['purpose']}'),
|
||||
),
|
||||
Text(
|
||||
'${c['createdAt'] ?? ''}',
|
||||
style: const TextStyle(color: Colors.grey, fontSize: 11),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user