From 4cf759de62e80b49543804489f7575491c8a4644 Mon Sep 17 00:00:00 2001 From: sihoo Date: Thu, 17 Sep 2026 23:34:08 +0000 Subject: [PATCH] =?UTF-8?q?=EC=84=A0=EC=83=9D=EB=8B=98=20=ED=98=B8?= =?UTF-8?q?=EC=B6=9C=20=EC=8B=9C=EC=8A=A4=ED=85=9C=20=EC=B6=94=EA=B0=80=20?= =?UTF-8?q?(Firebase=20=EB=B2=84=EC=A0=84=EC=9D=84=20=EC=95=B1=20=EA=B3=84?= =?UTF-8?q?=EC=A0=95=20=EC=B2=B4=EA=B3=84=EB=A1=9C=20=EC=9D=B4=EC=A0=84)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 별도로 돌아가던 Firebase RTDB 기반 호출 페이지(index.html/teacher.html)에서 발견한 XSS, 익명 접근(아무나 아무 선생님 위치 변경 가능), 이름 위조 문제를 우리 앱 로그인 체계로 재구현하면서 구조적으로 없앴다. 학생은 본인 계정으로만 호출하고 본인 호출 기록만 보이며, 교사는 본인 위치만 수정하고 본인이 받은 호출만 본다. TTS 대신 FCM 푸시로 대체. 시간표 데이터의 "김지"→"조신현" 오타도 "조신"으로 수정해서 옮겼다. Co-Authored-By: Claude Sonnet 5 --- lib/data/teacher_call_schedule.dart | 132 +++++++ lib/function/teacher_call_controller.dart | 124 ++++++ lib/function/teacher_location_controller.dart | 97 +++++ lib/ui/main_dashboard.dart | 31 ++ lib/ui/teacher_call_screen.dart | 360 ++++++++++++++++++ lib/ui/teacher_location_screen.dart | 198 ++++++++++ 6 files changed, 942 insertions(+) create mode 100644 lib/data/teacher_call_schedule.dart create mode 100644 lib/function/teacher_call_controller.dart create mode 100644 lib/function/teacher_location_controller.dart create mode 100644 lib/ui/teacher_call_screen.dart create mode 100644 lib/ui/teacher_location_screen.dart diff --git a/lib/data/teacher_call_schedule.dart b/lib/data/teacher_call_schedule.dart new file mode 100644 index 0000000..ff490be --- /dev/null +++ b/lib/data/teacher_call_schedule.dart @@ -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 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 kTeacherAbbrevMap = { + "손계": "손계강", + "조신": "조신현", + "진경": "진경아", + "김만": "김만중", + "이길": "이길석", + "김효": "김효정", + "이부": "이부원", + "고경": "고경철", + "김경": "김경원", + "양성": "양성욱", + "김명": "김명선", + "김현": "김현진", + "박은": "박은호", + "심윤": "심윤주", + "조영": "조영희", + "이정": "이정상", + "조용": "조용우", + "이민": "이민혁", +}; + +// day: 1=월 ~ 5=금, period: 1~7. 값은 그 시간에 수업 중인 선생님 약어 목록. +const Map>> 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 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().toList(); +} + +const Map kLocationIcons = { + "교무실": "🏢", + "급식실": "🍴", + "과학실": "🧪", + "도서관": "📖", + "출장": "🚗", + "조퇴/퇴근": "🏠", + "상담실": "💬", + "기숙사": "🏨", +}; + +const List kLocationOptions = [ + "교무실", + "급식실", + "과학실", + "도서관", + "출장", + "조퇴/퇴근", + "상담실", + "기숙사", +]; + +const List kCallPurposes = [ + "간단한 용무", + "질문 있어요", + "상담 신청", + "과제 제출", + "물건 전달", +]; diff --git a/lib/function/teacher_call_controller.dart b/lib/function/teacher_call_controller.dart new file mode 100644 index 0000000..61af2f8 --- /dev/null +++ b/lib/function/teacher_call_controller.dart @@ -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 _teachers = []; + List _myCalls = []; + List _ranking = []; + bool _disposed = false; + + bool get isLoading => _isLoading; + bool get isCalling => _isCalling; + List get teachers => _teachers; + List get myCalls => _myCalls; + List 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 init() async { + await Future.wait([_fetchTeachers(), _fetchMyCalls(), _fetchRanking()]); + _isLoading = false; + _safeNotify(); + } + + Future refresh() => init(); + + Future _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 _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 _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(); + } + } +} diff --git a/lib/function/teacher_location_controller.dart b/lib/function/teacher_location_controller.dart new file mode 100644 index 0000000..4e7e915 --- /dev/null +++ b/lib/function/teacher_location_controller.dart @@ -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 _receivedCalls = []; + bool _disposed = false; + + bool get isLoading => _isLoading; + bool get isSaving => _isSaving; + String get currentLocation => _currentLocation; + List get receivedCalls => _receivedCalls; + + void _safeNotify() { + if (!_disposed) notifyListeners(); + } + + @override + void dispose() { + _disposed = true; + super.dispose(); + } + + Future init() async { + await Future.wait([_fetchMyLocation(), fetchReceivedCalls()]); + _isLoading = false; + _safeNotify(); + } + + Future _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 list = data['teachers'] ?? []; + final mine = list.firstWhere( + (t) => t['teacherId'] == teacherId, + orElse: () => null, + ); + if (mine != null) _currentLocation = mine['location'] ?? '교무실'; + } + } catch (_) { + // 조용히 무시 + } + } + + Future 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(); + } + } +} diff --git a/lib/ui/main_dashboard.dart b/lib/ui/main_dashboard.dart index 1777b94..439ba18 100644 --- a/lib/ui/main_dashboard.dart +++ b/lib/ui/main_dashboard.dart @@ -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 { ), ), )); + 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 { ), ), )); + 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) { diff --git a/lib/ui/teacher_call_screen.dart b/lib/ui/teacher_call_screen.dart new file mode 100644 index 0000000..429db5c --- /dev/null +++ b/lib/ui/teacher_call_screen.dart @@ -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 createState() => _TeacherCallScreenState(); +} + +class _TeacherCallScreenState extends State { + 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 _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), + ), + ], + ), + ), + ], + ); + } +} diff --git a/lib/ui/teacher_location_screen.dart b/lib/ui/teacher_location_screen.dart new file mode 100644 index 0000000..62489a3 --- /dev/null +++ b/lib/ui/teacher_location_screen.dart @@ -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 createState() => _TeacherLocationScreenState(); +} + +class _TeacherLocationScreenState extends State { + late final TeacherLocationController _controller; + + @override + void initState() { + super.initState(); + _controller = TeacherLocationController(teacherId: widget.teacherId); + _controller.init(); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + Future _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), + ), + ], + ), + ), + ], + ); + } +}