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), + ), + ], + ), + ), + ], + ); + } +}