From fd0bc4f814c666bc1b6b6007ffe322037df7e1b2 Mon Sep 17 00:00:00 2001 From: sihoo Date: Mon, 31 Aug 2026 01:49:17 +0900 Subject: [PATCH] =?UTF-8?q?=EC=8A=A4=EB=A7=88=ED=8A=B8=EA=B8=B0=EA=B8=B0(?= =?UTF-8?q?=ED=8C=A8=EB=93=9C)=20=EB=B0=98=EC=B6=9C=20=EC=8B=A0=EC=B2=AD/?= =?UTF-8?q?=EC=8A=B9=EC=9D=B8=20=EC=8B=9C=EC=8A=A4=ED=85=9C=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 패드는 NFC를 못 쓰므로 학생이 사용 시간/목적을 신청하면 선생님이 대시보드의 반출 대장에서 승인/거절하는 흐름을 추가. 저울-아두이노 연동(무게 기반 픽업/반납 감지)은 백엔드에 구현되어 있으나 실기기 연동은 추후 진행. Co-Authored-By: Claude Sonnet 5 --- lib/function/device_checkout_controller.dart | 48 ++++ .../device_checkout_ledger_controller.dart | 85 ++++++ lib/ui/device_checkout_ledger_page.dart | 262 ++++++++++++++++++ lib/ui/device_checkout_request_screen.dart | 198 +++++++++++++ lib/ui/student_dashboard.dart | 19 ++ lib/ui/teacher_dashboard.dart | 60 ++-- pubspec.yaml | 2 +- 7 files changed, 650 insertions(+), 24 deletions(-) create mode 100644 lib/function/device_checkout_controller.dart create mode 100644 lib/function/device_checkout_ledger_controller.dart create mode 100644 lib/ui/device_checkout_ledger_page.dart create mode 100644 lib/ui/device_checkout_request_screen.dart diff --git a/lib/function/device_checkout_controller.dart b/lib/function/device_checkout_controller.dart new file mode 100644 index 0000000..19de90b --- /dev/null +++ b/lib/function/device_checkout_controller.dart @@ -0,0 +1,48 @@ +// 📱 스마트기기(패드) 반출 신청 화면의 기능(서버 통신/상태) 담당 컨트롤러. +// NFC가 안 되는 패드용 대체 절차 — 시작~종료 시각과 사용 목적을 적어 신청하면 +// 선생님 승인 후 저울(아두이노)이 실제 픽업/반납 여부를 감시한다 (하드웨어는 나중에 붙임). +import 'dart:convert'; +import 'package:flutter/foundation.dart'; +import 'package:http/http.dart' as http; +import '../config.dart'; + +class DeviceCheckoutController extends ChangeNotifier { + bool _isSubmitting = false; + bool get isSubmitting => _isSubmitting; + + /// ➕ 반출 요청 제출. (성공여부, 메시지)를 반환한다. + Future<(bool success, String message)> submitRequest({ + required String studentId, + required String studentName, + required String purpose, + required String startTime, + required String endTime, + }) async { + _isSubmitting = true; + notifyListeners(); + try { + final response = await http.post( + Uri.parse('$baseUrl/api/device-checkout/request'), + headers: {"Content-Type": "application/json"}, + body: jsonEncode({ + "studentId": studentId, + "studentName": studentName, + "purpose": purpose, + "startTime": startTime, + "endTime": endTime, + }), + ); + final result = jsonDecode(utf8.decode(response.bodyBytes)); + if (response.statusCode == 200 && result['status'] == 'success') { + return (true, '✅ ${result['message'] ?? '요청이 접수되었습니다.'}'); + } else { + return (false, '❌ ${result['message'] ?? '요청 실패'}'); + } + } catch (e) { + return (false, '🚨 네트워크 에러: $e'); + } finally { + _isSubmitting = false; + notifyListeners(); + } + } +} diff --git a/lib/function/device_checkout_ledger_controller.dart b/lib/function/device_checkout_ledger_controller.dart new file mode 100644 index 0000000..69e5af9 --- /dev/null +++ b/lib/function/device_checkout_ledger_controller.dart @@ -0,0 +1,85 @@ +// 📋 선생님용 "스마트기기 반출 대장" 화면의 기능(서버 통신/상태) 담당 컨트롤러. +import 'dart:async'; +import 'dart:convert'; +import 'package:flutter/foundation.dart'; +import 'package:http/http.dart' as http; +import '../config.dart'; + +class DeviceCheckoutLedgerController extends ChangeNotifier { + List _requests = []; + bool _isLoading = true; + bool _isWorking = false; + Timer? _timer; + bool _disposed = false; + + List get requests => _requests; + bool get isLoading => _isLoading; + bool get isWorking => _isWorking; + + void _safeNotify() { + if (!_disposed) notifyListeners(); + } + + void init() { + fetchAll(); + _timer = Timer.periodic(const Duration(seconds: 5), (_) => fetchAll()); + } + + @override + void dispose() { + _disposed = true; + _timer?.cancel(); + super.dispose(); + } + + Future fetchAll() async { + try { + final response = await http.get( + Uri.parse('$baseUrl/api/device-checkout/list'), + ); + if (response.statusCode == 200) { + final data = jsonDecode(utf8.decode(response.bodyBytes)); + _requests = data['requests'] ?? []; + } + } catch (_) { + // 새로고침 실패는 조용히 무시하고 마지막으로 받아온 목록을 유지한다. + } finally { + _isLoading = false; + _safeNotify(); + } + } + + Future<(bool success, String message)> approve(int requestId) async { + return _postAction('/api/device-checkout/approve', requestId); + } + + Future<(bool success, String message)> reject(int requestId) async { + return _postAction('/api/device-checkout/reject', requestId); + } + + Future<(bool success, String message)> _postAction( + String path, + int requestId, + ) async { + _isWorking = true; + _safeNotify(); + try { + final response = await http.post( + Uri.parse('$baseUrl$path'), + headers: {"Content-Type": "application/json"}, + body: jsonEncode({"requestId": requestId}), + ); + final result = jsonDecode(utf8.decode(response.bodyBytes)); + if (response.statusCode == 200 && result['status'] == 'success') { + await fetchAll(); + return (true, '✅ ${result['message'] ?? '처리되었습니다.'}'); + } + return (false, '❌ ${result['message'] ?? '처리 실패'}'); + } catch (e) { + return (false, '🚨 네트워크 에러: $e'); + } finally { + _isWorking = false; + _safeNotify(); + } + } +} diff --git a/lib/ui/device_checkout_ledger_page.dart b/lib/ui/device_checkout_ledger_page.dart new file mode 100644 index 0000000..b31acef --- /dev/null +++ b/lib/ui/device_checkout_ledger_page.dart @@ -0,0 +1,262 @@ +// 📋 선생님용 "스마트기기 반출 대장" 화면 (UI 전용). 오늘 신청된 반출 요청을 보여주고 승인/거절한다. +// 서버 통신/상태는 lib/function/device_checkout_ledger_controller.dart가 담당한다. +import 'package:flutter/material.dart'; +import '../function/device_checkout_ledger_controller.dart'; + +class DeviceCheckoutLedgerPage extends StatefulWidget { + const DeviceCheckoutLedgerPage({super.key}); + + @override + State createState() => + _DeviceCheckoutLedgerPageState(); +} + +class _DeviceCheckoutLedgerPageState extends State { + final DeviceCheckoutLedgerController _controller = + DeviceCheckoutLedgerController(); + + @override + void initState() { + super.initState(); + _controller.init(); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + Future _approve(int id) async { + final (_, message) = await _controller.approve(id); + if (!mounted) return; + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(message))); + } + + Future _reject(int id) async { + final (_, message) = await _controller.reject(id); + if (!mounted) return; + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(message))); + } + + ({Color color, String label}) _statusInfo(String status) { + switch (status) { + case 'PENDING': + return (color: Colors.orange, label: '⏳ 대기중'); + case 'APPROVED': + return (color: Colors.blue, label: '✅ 승인됨 (픽업 대기)'); + case 'IN_USE': + return (color: Colors.teal, label: '📱 사용중'); + case 'RETURNED': + return (color: Colors.grey, label: '↩️ 반납완료'); + case 'REJECTED': + return (color: Colors.red, label: '🚫 거절됨'); + default: + return (color: Colors.grey, label: status); + } + } + + String _timeRange(String start, String end) { + // "YYYY-MM-DD HH:MM:SS" -> "HH:MM" + String hm(String full) => full.length >= 16 ? full.substring(11, 16) : full; + return '${hm(start)} ~ ${hm(end)}'; + } + + @override + Widget build(BuildContext context) { + return ListenableBuilder( + listenable: _controller, + builder: (context, _) { + final requests = _controller.requests; + final pendingCount = requests + .where((r) => r['status'] == 'PENDING') + .length; + + return Scaffold( + backgroundColor: Colors.grey[100], + appBar: AppBar( + title: const Text( + '📱 스마트기기 반출 대장', + style: TextStyle(fontWeight: FontWeight.bold), + ), + backgroundColor: Colors.teal, + foregroundColor: Colors.white, + elevation: 0, + actions: [ + IconButton( + icon: const Icon(Icons.refresh_rounded), + onPressed: _controller.fetchAll, + tooltip: '새로고침', + ), + ], + ), + body: _controller.isLoading + ? const Center(child: CircularProgressIndicator()) + : Column( + children: [ + if (pendingCount > 0) + Container( + width: double.infinity, + margin: const EdgeInsets.all(16), + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 10, + ), + decoration: BoxDecoration( + color: Colors.orange[100], + borderRadius: BorderRadius.circular(12), + border: Border.all(color: Colors.orange[400]!), + ), + child: Text( + '⏳ 승인 대기 중인 요청이 $pendingCount건 있습니다.', + style: TextStyle( + color: Colors.orange[900], + fontWeight: FontWeight.bold, + ), + ), + ), + Expanded( + child: requests.isEmpty + ? const Center( + child: Text( + '오늘 신청된 반출 요청이 없습니다.', + style: TextStyle(color: Colors.grey), + ), + ) + : ListView.builder( + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 8, + ), + itemCount: requests.length, + itemBuilder: (context, index) { + final r = requests[index]; + final String status = r['status']; + final info = _statusInfo(status); + final bool isPending = status == 'PENDING'; + + return Card( + elevation: 1, + margin: const EdgeInsets.only(bottom: 10), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + side: isPending + ? BorderSide(color: Colors.orange[300]!) + : BorderSide.none, + ), + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: + CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Text( + '${r['studentName']} (${r['studentId']})', + style: const TextStyle( + fontWeight: FontWeight.bold, + fontSize: 15, + ), + ), + ), + Container( + padding: + const EdgeInsets.symmetric( + horizontal: 10, + vertical: 4, + ), + decoration: BoxDecoration( + color: info.color.withValues( + alpha: 0.12, + ), + borderRadius: + BorderRadius.circular(20), + ), + child: Text( + info.label, + style: TextStyle( + color: info.color, + fontWeight: FontWeight.bold, + fontSize: 12, + ), + ), + ), + ], + ), + const SizedBox(height: 6), + Text( + '⏰ ${_timeRange(r['requestedStart'], r['requestedEnd'])}', + style: TextStyle( + color: Colors.grey[700], + fontSize: 13, + ), + ), + const SizedBox(height: 2), + Text( + '📝 ${r['purpose']}', + style: TextStyle( + color: Colors.grey[700], + fontSize: 13, + ), + ), + if (isPending) ...[ + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: OutlinedButton( + onPressed: + _controller.isWorking + ? null + : () => _reject(r['id']), + style: + OutlinedButton.styleFrom( + foregroundColor: + Colors.red, + side: const BorderSide( + color: Colors.red, + ), + ), + child: const Text('거절'), + ), + ), + const SizedBox(width: 8), + Expanded( + child: ElevatedButton( + onPressed: + _controller.isWorking + ? null + : () => _approve(r['id']), + style: + ElevatedButton.styleFrom( + backgroundColor: + Colors.teal, + foregroundColor: + Colors.white, + ), + child: const Text('승인'), + ), + ), + ], + ), + ], + ], + ), + ), + ); + }, + ), + ), + ], + ), + ); + }, + ); + } +} diff --git a/lib/ui/device_checkout_request_screen.dart b/lib/ui/device_checkout_request_screen.dart new file mode 100644 index 0000000..06c1433 --- /dev/null +++ b/lib/ui/device_checkout_request_screen.dart @@ -0,0 +1,198 @@ +// 📱 스마트기기(패드) 반출 신청 화면 (UI 전용). 시작~종료 시각과 사용 목적을 입력받는다. +// 서버 통신/상태는 lib/function/device_checkout_controller.dart가 담당한다. +import 'package:flutter/material.dart'; +import '../function/device_checkout_controller.dart'; + +class DeviceCheckoutRequestScreen extends StatefulWidget { + final String studentId; + final String studentName; + + const DeviceCheckoutRequestScreen({ + super.key, + required this.studentId, + required this.studentName, + }); + + @override + State createState() => + _DeviceCheckoutRequestScreenState(); +} + +class _DeviceCheckoutRequestScreenState + extends State { + final DeviceCheckoutController _controller = DeviceCheckoutController(); + final TextEditingController _purposeController = TextEditingController(); + TimeOfDay? _startTime; + TimeOfDay? _endTime; + + @override + void dispose() { + _controller.dispose(); + _purposeController.dispose(); + super.dispose(); + } + + String _formatTime(TimeOfDay time) => + '${time.hour.toString().padLeft(2, '0')}:${time.minute.toString().padLeft(2, '0')}'; + + Future _pickStartTime() async { + final picked = await showTimePicker( + context: context, + initialTime: _startTime ?? TimeOfDay.now(), + helpText: '사용 시작 시각', + ); + if (picked != null) setState(() => _startTime = picked); + } + + Future _pickEndTime() async { + final picked = await showTimePicker( + context: context, + initialTime: _endTime ?? TimeOfDay.now(), + helpText: '사용 종료 시각', + ); + if (picked != null) setState(() => _endTime = picked); + } + + Future _submit() async { + final purpose = _purposeController.text.trim(); + if (_startTime == null || _endTime == null || purpose.isEmpty) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('⚠️ 시작/종료 시각과 사용 목적을 모두 입력해주세요.')), + ); + return; + } + + final (success, message) = await _controller.submitRequest( + studentId: widget.studentId, + studentName: widget.studentName, + purpose: purpose, + startTime: _formatTime(_startTime!), + endTime: _formatTime(_endTime!), + ); + if (!mounted) return; + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(message))); + if (success) Navigator.pop(context); + } + + @override + Widget build(BuildContext context) { + return ListenableBuilder( + listenable: _controller, + builder: (context, _) { + return Scaffold( + backgroundColor: Colors.grey[100], + appBar: AppBar( + title: const Text( + '📱 스마트기기 반출 신청', + style: TextStyle(fontWeight: FontWeight.bold), + ), + backgroundColor: Colors.teal, + foregroundColor: Colors.white, + elevation: 0, + ), + body: SingleChildScrollView( + padding: const EdgeInsets.all(24.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + '패드는 NFC 태그가 안 되니, 사용 시간과 목적을 적어 신청하면\n' + '선생님이 확인하고 승인해줍니다. 승인되면 반납 바구니에서 꺼내 쓰세요.', + style: TextStyle( + color: Colors.black54, + fontSize: 13, + height: 1.4, + ), + ), + const SizedBox(height: 24), + Container( + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(24), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.04), + blurRadius: 16, + ), + ], + ), + child: Column( + children: [ + Row( + children: [ + Expanded( + child: OutlinedButton.icon( + onPressed: _pickStartTime, + icon: const Icon(Icons.play_arrow_rounded), + label: Text( + _startTime == null + ? '시작 시각' + : _formatTime(_startTime!), + ), + ), + ), + const SizedBox(width: 12), + Expanded( + child: OutlinedButton.icon( + onPressed: _pickEndTime, + icon: const Icon(Icons.stop_rounded), + label: Text( + _endTime == null + ? '종료 시각' + : _formatTime(_endTime!), + ), + ), + ), + ], + ), + const SizedBox(height: 16), + TextField( + controller: _purposeController, + maxLines: 3, + decoration: const InputDecoration( + labelText: '사용 목적', + hintText: '예: 수행평가 자료조사', + alignLabelWithHint: true, + border: OutlineInputBorder(), + ), + ), + const SizedBox(height: 20), + SizedBox( + width: double.infinity, + height: 50, + child: ElevatedButton( + onPressed: _controller.isSubmitting ? null : _submit, + style: ElevatedButton.styleFrom( + backgroundColor: Colors.teal, + foregroundColor: Colors.white, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + child: _controller.isSubmitting + ? const CircularProgressIndicator( + color: Colors.white, + ) + : const Text( + '반출 신청하기', + style: TextStyle( + fontWeight: FontWeight.bold, + fontSize: 15, + ), + ), + ), + ), + ], + ), + ), + ], + ), + ), + ); + }, + ); + } +} diff --git a/lib/ui/student_dashboard.dart b/lib/ui/student_dashboard.dart index fa0a9e0..b2a44af 100644 --- a/lib/ui/student_dashboard.dart +++ b/lib/ui/student_dashboard.dart @@ -3,6 +3,7 @@ // 계정 삭제 서버 통신은 lib/function/student_dashboard_controller.dart가 담당한다. import 'package:flutter/material.dart'; import '../function/student_dashboard_controller.dart'; +import 'device_checkout_request_screen.dart'; import 'nfc_poccket_checkin_screen.dart'; import 'login_screen.dart'; import 'teacher_attendance_page.dart'; @@ -346,6 +347,24 @@ class _StudentDashboardState extends State { }, ), + // 📱 패드는 NFC가 안 되니, 시간+목적 적어서 신청 → 선생님 승인받는 대체 절차 + _buildModernCard( + icon: Icons.tablet_mac_rounded, + title: '스마트기기 반출', + subtitle: '패드 사용 신청 (시간/목적)', + color: Colors.teal, + onTap: () => Navigator.push( + context, + MaterialPageRoute( + builder: (context) => + DeviceCheckoutRequestScreen( + studentId: widget.studentId, + studentName: widget.studentName, + ), + ), + ), + ), + if (isDeveloper) _buildModernCard( icon: Icons.monitor_heart_rounded, diff --git a/lib/ui/teacher_dashboard.dart b/lib/ui/teacher_dashboard.dart index a1c31c0..5223903 100644 --- a/lib/ui/teacher_dashboard.dart +++ b/lib/ui/teacher_dashboard.dart @@ -1,5 +1,6 @@ // 👨‍🏫 교사 대시보드. 실시간 출석 확인, 학생 계정 관리 화면으로 가는 메뉴만 담당한다. import 'package:flutter/material.dart'; +import 'device_checkout_ledger_page.dart'; import 'login_screen.dart'; import 'teacher_attendance_page.dart'; import 'teacher_student_management_page.dart'; @@ -149,31 +150,44 @@ class _TeacherDashboardState extends State { mainAxisSpacing: 16, childAspectRatio: 0.95, children: [ - _buildModernCard( - icon: Icons.assignment_turned_in_rounded, - title: '실시간 출석 확인', - subtitle: '학생 제출 로그 모니터링', - color: Colors.blue, - onTap: () => Navigator.push( - context, - MaterialPageRoute( - builder: (context) => const TeacherAttendancePage(), + _buildModernCard( + icon: Icons.assignment_turned_in_rounded, + title: '실시간 출석 확인', + subtitle: '학생 제출 로그 모니터링', + color: Colors.blue, + onTap: () => Navigator.push( + context, + MaterialPageRoute( + builder: (context) => const TeacherAttendancePage(), + ), + ), ), - ), - ), - _buildModernCard( - icon: Icons.manage_accounts_rounded, - title: '학생 계정 관리', - subtitle: '계정 추가 및 강제 리셋', - color: Colors.orange, - onTap: () => Navigator.push( - context, - MaterialPageRoute( - builder: (context) => - const TeacherStudentManagementPage(), + _buildModernCard( + icon: Icons.manage_accounts_rounded, + title: '학생 계정 관리', + subtitle: '계정 추가 및 강제 리셋', + color: Colors.orange, + onTap: () => Navigator.push( + context, + MaterialPageRoute( + builder: (context) => + const TeacherStudentManagementPage(), + ), + ), + ), + _buildModernCard( + icon: Icons.tablet_mac_rounded, + title: '스마트기기 반출 대장', + subtitle: '패드 반출 신청 승인/거절', + color: Colors.teal, + onTap: () => Navigator.push( + context, + MaterialPageRoute( + builder: (context) => + const DeviceCheckoutLedgerPage(), + ), + ), ), - ), - ), ], ), ), diff --git a/pubspec.yaml b/pubspec.yaml index 5b6c50b..f654dfc 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html # In Windows, build-name is used as the major, minor, and patch parts # of the product and file versions while build-number is used as the build suffix. -version: 1.2.0+6 +version: 1.3.0+7 environment: sdk: ^3.12.2