기존 프로젝트 최초 업로드

This commit is contained in:
moonsihoo
2026-07-28 19:00:30 +09:00
commit 7135854933
150 changed files with 9629 additions and 0 deletions
+68
View File
@@ -0,0 +1,68 @@
// 🌐 앱 전역 공통 설정 및 유틸 함수 모음
// (서버 주소, FCM 관련 상수/함수, 기기 UUID 조회 등 여러 화면에서 공유하는 것들)
import 'dart:io';
import 'package:device_info_plus/device_info_plus.dart';
import 'package:firebase_messaging/firebase_messaging.dart';
import 'package:flutter/foundation.dart' show kIsWeb;
// 🌐 기존에 사용하시던 실제 백엔드 서버 도메인 주소로 통일합니다.
const String baseUrl = 'http://backsanhi.mcv.kr';
// 🔑 웹에서 FCM 토큰을 받으려면 필요한 VAPID 키
// (Firebase Console > 프로젝트 설정 > Cloud Messaging > 웹 구성 > 웹 푸시 인증서에서 발급)
const String webVapidKey =
'BG4LnirukBdW3cXVWxhvY9pcjevBKDhpsaq1SLZdBs2cOEiwLAFyuuZLkMqpEGp9uOuHdPOe_y3FdwdZaz3qdfk';
// 권한 요청 및 FCM 토큰을 가져와서 콘솔에 출력하는 함수
Future<void> printFCMToken() async {
try {
FirebaseMessaging messaging = FirebaseMessaging.instance;
// 1. 🚨 안드로이드/iOS 알림 권한 요청 (이 코드가 핵심이야!)
print("🚨 [FCM] 알림 권한 요청 중...");
NotificationSettings settings = await messaging.requestPermission(
alert: true,
badge: true,
sound: true,
);
if (settings.authorizationStatus == AuthorizationStatus.authorized) {
print("🚨 [FCM] 알림 권한 허용됨! 🎉");
} else if (settings.authorizationStatus == AuthorizationStatus.denied) {
print("🚨 [FCM] 알림 권한 거부됨... 😢 (설정에서 직접 켜야 합니다)");
}
// 2. 토큰 불러오기 (웹은 VAPID 키가 필요함)
print("🚨 [FCM] 토큰을 불러오는 중...");
String? token = kIsWeb
? await messaging.getToken(vapidKey: webVapidKey)
: await messaging.getToken();
if (token != null) {
print("🚨 [FCM 토큰 발급 성공] 👉 $token");
} else {
print("🚨 [FCM 토큰] 토큰이 null입니다.");
}
} catch (e) {
print("🚨 [FCM 토큰 에러] 👉 $e");
}
}
// 📱 폰의 고유 식별 번호(UUID)를 가져오는 공통 함수
Future<String> getDeviceUuid() async {
final DeviceInfoPlugin deviceInfo = DeviceInfoPlugin();
String uuid = "UNKNOWN_DEVICE";
try {
if (Platform.isAndroid) {
AndroidDeviceInfo androidInfo = await deviceInfo.androidInfo;
uuid = androidInfo.id; // 안드로이드 폰의 고유 하드웨어 ID 추출
} else if (Platform.isIOS) {
IosDeviceInfo iosInfo = await deviceInfo.iosInfo;
uuid = iosInfo.identifierForVendor ?? "UNKNOWN_IOS"; // iOS 고유 ID
}
} catch (e) {
print("기기 ID 추출 실패: $e");
}
return uuid;
}
+22
View File
@@ -0,0 +1,22 @@
import 'package:flutter/material.dart';
import 'nfc_poccket_checkin_screen.dart';
// 🧪 디버그 전용 진입점: NFC 주머니 체크인 + 조도 센서 감시 화면을 바로 테스트하기 위한 파일.
void main() {
runApp(const DebugPocketApp());
}
class DebugPocketApp extends StatelessWidget {
const DebugPocketApp({super.key});
@override
Widget build(BuildContext context) {
return const MaterialApp(
debugShowCheckedModeBanner: false,
home: NfcPocketCheckInScreen(
studentId: "2061",
studentName: "디버그테스트",
),
);
}
}
+69
View File
@@ -0,0 +1,69 @@
// File generated by FlutterFire CLI.
// ignore_for_file: type=lint
import 'package:firebase_core/firebase_core.dart' show FirebaseOptions;
import 'package:flutter/foundation.dart'
show defaultTargetPlatform, kIsWeb, TargetPlatform;
/// Default [FirebaseOptions] for use with your Firebase apps.
///
/// Example:
/// ```dart
/// import 'firebase_options.dart';
/// // ...
/// await Firebase.initializeApp(
/// options: DefaultFirebaseOptions.currentPlatform,
/// );
/// ```
class DefaultFirebaseOptions {
static FirebaseOptions get currentPlatform {
if (kIsWeb) {
return web;
}
switch (defaultTargetPlatform) {
case TargetPlatform.android:
return android;
case TargetPlatform.iOS:
throw UnsupportedError(
'DefaultFirebaseOptions have not been configured for ios - '
'you can reconfigure this by running the FlutterFire CLI again.',
);
case TargetPlatform.macOS:
throw UnsupportedError(
'DefaultFirebaseOptions have not been configured for macos - '
'you can reconfigure this by running the FlutterFire CLI again.',
);
case TargetPlatform.windows:
throw UnsupportedError(
'DefaultFirebaseOptions have not been configured for windows - '
'you can reconfigure this by running the FlutterFire CLI again.',
);
case TargetPlatform.linux:
throw UnsupportedError(
'DefaultFirebaseOptions have not been configured for linux - '
'you can reconfigure this by running the FlutterFire CLI again.',
);
default:
throw UnsupportedError(
'DefaultFirebaseOptions are not supported for this platform.',
);
}
}
static const FirebaseOptions web = FirebaseOptions(
apiKey: 'AIzaSyCGE7xZm82Dp6yQnTgBeE0LYPcmvmdIRzE',
appId: '1:137322214849:web:0423e5c788d95761b16b40',
messagingSenderId: '137322214849',
projectId: 'school-display-ff28f',
authDomain: 'school-display-ff28f.firebaseapp.com',
storageBucket: 'school-display-ff28f.firebasestorage.app',
measurementId: 'G-XPMFYTSVF4',
);
static const FirebaseOptions android = FirebaseOptions(
apiKey: 'AIzaSyBR6CZrAafCMssuifI9So42uSOSuPASe84',
appId: '1:137322214849:android:52d9ca6efcb9613eb16b40',
messagingSenderId: '137322214849',
projectId: 'school-display-ff28f',
storageBucket: 'school-display-ff28f.firebasestorage.app',
);
}
+34
View File
@@ -0,0 +1,34 @@
// 🚪 앱 진입점. Firebase/백그라운드 감시 서비스 초기화 후 로그인 화면으로 시작한다.
// 실제 화면 구현은 lib/screens/ 폴더, 공통 설정은 lib/config.dart 참고.
import 'dart:io';
import 'package:flutter/foundation.dart' show kIsWeb;
import 'package:flutter/material.dart';
import 'package:firebase_core/firebase_core.dart';
import 'firebase_options.dart';
import 'config.dart';
import 'pocket_watch_service.dart';
import 'screens/login_screen.dart';
void main() async {
WidgetsFlutterBinding.ensureInitialized();
await Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform);
await printFCMToken();
if (!kIsWeb && Platform.isAndroid) {
await initializePocketWatchService();
}
runApp(const SchoolAttendanceApp());
}
class SchoolAttendanceApp extends StatelessWidget {
const SchoolAttendanceApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
debugShowCheckedModeBanner: false,
title: '백산고등학교 학생 도우미',
theme: ThemeData(primarySwatch: Colors.indigo, useMaterial3: true),
home: const LoginScreen(), // 🚪 앱을 켜면 무조건 로그인 화면이 먼저 등장합니다.
);
}
}
+380
View File
@@ -0,0 +1,380 @@
// 📲 자습실 NFC 출석체크 화면. 태그 인식→출석 전송 후, 배터리 최적화 예외를 요청하고
// pocket_watch_service의 백그라운드 감시를 시작/종료(재태깅 시 체크아웃)하는 역할을 한다.
import 'dart:async';
import 'dart:convert';
import 'dart:io' show Platform;
import 'package:flutter/foundation.dart' show kIsWeb;
import 'package:flutter/material.dart';
import 'package:flutter_background_service/flutter_background_service.dart';
import 'package:http/http.dart' as http;
import 'package:nfc_manager/nfc_manager.dart';
import 'package:nfc_manager_ndef/nfc_manager_ndef.dart';
import 'package:permission_handler/permission_handler.dart';
import 'config.dart' show baseUrl;
class NfcPocketCheckInScreen extends StatefulWidget {
final String studentId; // 로그인된 학생 학번 (예: "2061")
final String studentName; // 로그인된 학생 이름 (예: "시후")
const NfcPocketCheckInScreen({
super.key,
required this.studentId,
required this.studentName,
});
@override
State<NfcPocketCheckInScreen> createState() => _NfcPocketCheckInScreenState();
}
class _NfcPocketCheckInScreenState extends State<NfcPocketCheckInScreen> {
bool _isProcessing = false; // 중복 태깅 및 연타 방지 플래그
bool _isWatching = false; // 백그라운드 조도 센서 감시가 진행 중인지 여부
String _statusMessage = "주머니의 NFC 스티커에 폰 뒷면을 대어주세요.";
String? _activePocketNumber;
StreamSubscription<Map<String, dynamic>?>? _violationSub;
bool get _watchServiceSupported => !kIsWeb && Platform.isAndroid;
@override
void initState() {
super.initState();
_startNfcSession();
if (_watchServiceSupported) {
// 화면이 열려있는 동안은 실시간으로 위반 알림을 받아 팝업을 띄운다.
_violationSub = FlutterBackgroundService().on('violation_detected').listen((
event,
) {
if (mounted) _showViolationDialog(event?['pocketNumber']?.toString());
});
}
}
@override
void dispose() {
// 화면을 나갈 때 NFC 감지만 종료. 백그라운드 감시 서비스는 화면과 무관하게 계속 동작해야 하므로 건드리지 않는다.
NfcManager.instance.stopSession();
_violationSub?.cancel();
super.dispose();
}
/// 📡 NFC 감지 세션 시작
void _startNfcSession() async {
bool isAvailable = await NfcManager.instance.isAvailable();
if (!isAvailable) {
setState(() {
_statusMessage = "❌ 이 스마트폰은 NFC 기능이 꺼져있거나 지원되지 않습니다.";
});
return;
}
NfcManager.instance.startSession(
pollingOptions: {NfcPollingOption.iso14443, NfcPollingOption.iso15693},
onDiscovered: (NfcTag tag) async {
if (_isProcessing) return; // 이미 처리 중이면 연속 태그 무시 (쿨다운)
// 이미 감시 중일 때 다시 태깅하면 "폰 회수(체크아웃)"로 처리한다.
if (_isWatching) {
await _checkOut();
return;
}
setState(() {
_isProcessing = true;
_statusMessage = "⏳ 태그 인식 완료! 서버에 출석 전송 중...";
});
try {
// 1. NFC 태그에서 주머니 번호 데이터 읽기
Ndef? ndef = Ndef.from(tag);
String rawPocketData = "";
if (ndef != null && ndef.cachedMessage != null) {
for (var record in ndef.cachedMessage!.records) {
rawPocketData += String.fromCharCodes(record.payload);
}
}
// 텍스트 헤더 정리 (예: "\x02enPOCKET_17" -> "POCKET_17")
String cleanPocketNumber = "POCKET_UNKNOWN";
if (rawPocketData.contains("POCKET_")) {
int index = rawPocketData.indexOf("POCKET_");
cleanPocketNumber = rawPocketData.substring(index).trim();
} else if (rawPocketData.isNotEmpty) {
cleanPocketNumber = rawPocketData.trim();
}
// 2. 파이썬 서버로 출석 정보 보내기
await _sendAttendanceToBackend(cleanPocketNumber);
} catch (e) {
_showSnackBar("❌ 태그 읽기 실패: $e", Colors.red);
} finally {
// 3초 후 연타 방지 해제 (쿨다운)
await Future.delayed(const Duration(seconds: 3));
if (mounted) {
setState(() => _isProcessing = false);
}
}
},
);
}
/// 🚚 파이썬 백엔드로 출석 데이터 HTTP POST 전송
Future<void> _sendAttendanceToBackend(String pocketNumber) async {
final url = Uri.parse("$baseUrl/attendance");
try {
final response = await http.post(
url,
headers: {"Content-Type": "application/json"},
body: jsonEncode({
"studentId": widget.studentId,
"studentName": widget.studentName,
"pocketNumber": pocketNumber, // 주머니 번호 전달
}),
);
if (response.statusCode == 200) {
final result = jsonDecode(utf8.decode(response.bodyBytes));
if (result["status"] == "success") {
_showSuccessDialog(pocketNumber);
await _startBackgroundWatch(pocketNumber);
} else {
_showSnackBar("⚠️ 출석 실패: ${result['message']}", Colors.orange);
}
} else {
_showSnackBar("🚨 서버 에러 (코드: ${response.statusCode})", Colors.red);
}
} catch (e) {
_showSnackBar("❌ 서버 연결 실패: $e", Colors.red);
}
}
// -----------------------------------------------------------------------
// 🛡️ 주머니 제출 모드: 화면이 꺼져도 백그라운드 서비스가 조도 센서로 감시
// -----------------------------------------------------------------------
/// NFC 태깅 성공 직후 호출. 배터리 최적화 예외를 한 번 요청한 뒤 백그라운드 감시 서비스를 시작한다.
Future<void> _startBackgroundWatch(String pocketNumber) async {
if (!_watchServiceSupported) {
_showSnackBar("ℹ️ 이 기기(iOS)는 백그라운드 감시를 지원하지 않습니다.", Colors.blueGrey);
return;
}
// 배터리 최적화 예외 요청 (이미 허용되어 있으면 시스템이 알아서 다이얼로그를 건너뜀)
final batteryStatus = await Permission.ignoreBatteryOptimizations.status;
if (!batteryStatus.isGranted) {
await Permission.ignoreBatteryOptimizations.request();
}
final service = FlutterBackgroundService();
if (!await service.isRunning()) {
await service.startService();
}
service.invoke('start_watch', {
"studentId": widget.studentId,
"studentName": widget.studentName,
"pocketNumber": pocketNumber,
});
setState(() {
_isWatching = true;
_activePocketNumber = pocketNumber;
_statusMessage = "🛡️ 백그라운드에서 감시 중입니다. 화면을 꺼도 계속 감시돼요.";
});
}
/// 감시 중일 때 다시 태깅하면 폰을 회수한 것으로 보고 감시를 종료한다.
Future<void> _checkOut() async {
FlutterBackgroundService().invoke('stop_watch');
setState(() {
_isWatching = false;
_activePocketNumber = null;
_statusMessage = "✅ 폰을 회수했습니다. 감시가 종료되었습니다.";
});
_showSnackBar("✅ 감시가 종료되었습니다. 수고하셨습니다!", Colors.green);
}
void _showViolationDialog(String? pocketNumber) {
showDialog(
context: context,
builder: (context) => AlertDialog(
backgroundColor: Colors.red[50],
title: const Text(
"🚨 무단 반출 감지",
style: TextStyle(color: Colors.red, fontWeight: FontWeight.bold),
),
content: Text(
"[${pocketNumber ?? _activePocketNumber}] 주머니에서 휴대폰이 꺼내진 것으로 감지되었습니다.\n담당 선생님께 알림이 전송되었습니다.",
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text("확인"),
),
],
),
);
}
/// 🎊 출석 성공 알림창
void _showSuccessDialog(String pocketNumber) {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text("🎉 자습실 출석 완료!"),
content: Text(
"${widget.studentName} 학생!\n[$pocketNumber] 주머니 출석이 확인되었습니다.\n\n폰을 주머니에 쏙 넣고 자습에 집중해 주세요! ✏️",
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text("확인"),
),
],
),
);
}
void _showSnackBar(String text, Color color) {
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(text), backgroundColor: color));
}
@override
Widget build(BuildContext context) {
if (_isWatching) {
return _buildPocketWatchScreen();
}
return Scaffold(
appBar: AppBar(
title: const Text(
"자습실 NFC 출석체크",
style: TextStyle(color: Colors.white, fontWeight: FontWeight.bold),
),
backgroundColor: const Color.fromARGB(255, 48, 48, 52),
),
body: Center(
child: Padding(
padding: const EdgeInsets.all(24.0),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
// NFC 애니메이션 아이콘
Icon(
_isProcessing ? Icons.sync : Icons.nfc,
size: 100,
color: _isProcessing ? Colors.orange : Colors.blueAccent,
),
const SizedBox(height: 30),
// 상태 메시지 표시
Text(
_statusMessage,
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
color: _isProcessing ? Colors.orange : Colors.black87,
),
),
const SizedBox(height: 15),
const Text(
"자기 주머니 번호 숫자에 폰 뒷면을 '톡' 대면\n자동으로 출석 처리됩니다.",
textAlign: TextAlign.center,
style: TextStyle(color: Colors.grey, fontSize: 14),
),
const SizedBox(height: 40),
// 🧪 [디버그 전용] NFC 태그나 카드가 없을 때 흐름을 테스트하기 위한 가상 태그 버튼
if (!_isProcessing)
OutlinedButton.icon(
onPressed: () => _sendAttendanceToBackend("POCKET_TEST_01"),
icon: const Icon(Icons.science_outlined),
label: const Text("가상 태그로 테스트 (NFC 없이)"),
style: OutlinedButton.styleFrom(
foregroundColor: Colors.purple,
side: const BorderSide(color: Colors.purple),
),
),
],
),
),
),
);
}
/// 💳 삼성페이 결제창 느낌의 전체화면 "주머니 제출 모드" 안내 UI
/// (실제 감시 로직은 pocket_watch_service.dart의 백그라운드 서비스가 담당하므로,
/// 이 화면은 상태를 보여주고 화면을 꺼도 된다는 것만 안내한다.)
Widget _buildPocketWatchScreen() {
return Scaffold(
backgroundColor: const Color(0xFF0B1120),
body: SafeArea(
child: Padding(
padding: const EdgeInsets.all(32.0),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Container(
width: 160,
height: 160,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: Colors.greenAccent.withValues(alpha: 0.15),
border: Border.all(color: Colors.greenAccent, width: 3),
),
child: const Icon(
Icons.shield_rounded,
size: 72,
color: Colors.greenAccent,
),
),
const SizedBox(height: 40),
Text(
_activePocketNumber ?? "",
style: const TextStyle(
color: Colors.white54,
fontSize: 14,
letterSpacing: 2,
),
),
const SizedBox(height: 12),
Text(
_statusMessage,
textAlign: TextAlign.center,
style: const TextStyle(
color: Colors.white,
fontSize: 20,
fontWeight: FontWeight.bold,
height: 1.4,
),
),
const SizedBox(height: 8),
const Text(
"📴 화면을 꺼도 감시는 계속됩니다.\n알림바에서 상태를 확인할 수 있어요.",
textAlign: TextAlign.center,
style: TextStyle(color: Colors.white38, fontSize: 13),
),
const SizedBox(height: 40),
OutlinedButton.icon(
onPressed: _checkOut,
icon: const Icon(Icons.logout_rounded, color: Colors.white70),
label: const Text(
"폰 회수하고 감시 종료 (테스트용)",
style: TextStyle(color: Colors.white70),
),
style: OutlinedButton.styleFrom(
side: const BorderSide(color: Colors.white30),
),
),
],
),
),
),
);
}
}
+172
View File
@@ -0,0 +1,172 @@
// 🛡️ 화면이 꺼져도 동작하는 포그라운드 백그라운드 서비스.
// 조도 센서로 주머니 속 밝기를 측정해 무단 반출을 감지하고, 진동+알림+서버 신고를 처리한다.
import 'dart:async';
import 'dart:convert';
import 'dart:ui';
import 'package:flutter_background_service/flutter_background_service.dart';
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
import 'package:http/http.dart' as http;
import 'package:light/light.dart';
import 'package:vibration/vibration.dart';
import 'config.dart' show baseUrl;
// 🔦 조도 센서 판정 관련 튜닝 값 (nfc_poccket_checkin_screen.dart와 동일한 기준)
const int _calibrationSampleCount = 3;
const Duration _placePhoneDelay = Duration(seconds: 4);
const int _luxJumpThreshold = 40;
const String pocketWatchNotificationChannelId = 'pocket_watch_channel';
const int pocketWatchNotificationId = 9001;
final FlutterLocalNotificationsPlugin _localNotifications =
FlutterLocalNotificationsPlugin();
/// 앱 시작(main()) 시 한 번만 호출. 알림 채널 생성 + 백그라운드 서비스 설정.
Future<void> initializePocketWatchService() async {
const AndroidNotificationChannel channel = AndroidNotificationChannel(
pocketWatchNotificationChannelId,
'주머니 감시 서비스',
description: '휴대폰 무단 반출 감시 중 상태를 표시합니다.',
importance: Importance.low, // 조용한 알림 (소리/진동 없음)
);
await _localNotifications
.resolvePlatformSpecificImplementation<
AndroidFlutterLocalNotificationsPlugin
>()
?.createNotificationChannel(channel);
final service = FlutterBackgroundService();
await service.configure(
androidConfiguration: AndroidConfiguration(
onStart: onPocketWatchServiceStart,
autoStart: false, // NFC 태깅으로 출석 성공했을 때만 수동으로 시작
autoStartOnBoot: false,
isForegroundMode: true,
notificationChannelId: pocketWatchNotificationChannelId,
initialNotificationTitle: '🛡️ 주머니 감시 대기 중',
initialNotificationContent: '출석 태깅을 기다리는 중입니다.',
foregroundServiceNotificationId: pocketWatchNotificationId,
foregroundServiceTypes: [AndroidForegroundType.specialUse],
),
iosConfiguration: IosConfiguration(autoStart: false),
);
}
@pragma('vm:entry-point')
void onPocketWatchServiceStart(ServiceInstance service) {
DartPluginRegistrant.ensureInitialized();
StreamSubscription<int>? lightSub;
final List<int> calibrationSamples = [];
int? baselineLux;
String? studentId;
String? studentName;
String? pocketNumber;
bool violated = false;
void updateNotification(String title, String content) {
if (service is AndroidServiceInstance) {
_localNotifications.show(
id: pocketWatchNotificationId,
title: title,
body: content,
notificationDetails: const NotificationDetails(
android: AndroidNotificationDetails(
pocketWatchNotificationChannelId,
'주머니 감시 서비스',
icon: 'ic_bg_service_small',
ongoing: true,
importance: Importance.low,
priority: Priority.low,
),
),
);
}
}
Future<void> reportViolation() async {
// 진동 3연타로 강하게 경고 (백그라운드 서비스에서는 HapticFeedback이 아닌 vibration 패키지를 써야 동작함)
Vibration.vibrate(pattern: [0, 400, 150, 400, 150, 400]);
updateNotification("🚨 무단 반출 감지!", "[$pocketNumber] 주머니에서 폰이 감지되지 않습니다.");
service.invoke('violation_detected', {
"pocketNumber": pocketNumber,
"timestamp": DateTime.now().toIso8601String(),
});
try {
await http.post(
Uri.parse("$baseUrl/attendance/violation"),
headers: {"Content-Type": "application/json"},
body: jsonEncode({
"studentId": studentId,
"studentName": studentName,
"pocketNumber": pocketNumber,
"type": "PHONE_REMOVED",
"timestamp": DateTime.now().toIso8601String(),
}),
);
} catch (_) {
// 백그라운드 서비스에서는 UI로 에러를 띄울 수 없으니 조용히 무시.
// (기록 자체는 이미 로컬 알림+haptic으로 학생에게 전달됨)
}
}
void onLightReading(int luxValue) {
if (violated) return;
if (baselineLux == null) {
calibrationSamples.add(luxValue);
if (calibrationSamples.length >= _calibrationSampleCount) {
baselineLux =
calibrationSamples.reduce((a, b) => a + b) ~/
calibrationSamples.length;
updateNotification("🛡️ 감시 중", "[$pocketNumber] 폰이 주머니 안에 있는지 감시 중");
}
return;
}
if (luxValue > baselineLux! + _luxJumpThreshold) {
violated = true;
lightSub?.cancel();
reportViolation();
}
}
Future<void> startWatching() async {
lightSub?.cancel();
calibrationSamples.clear();
baselineLux = null;
violated = false;
updateNotification("📥 주머니에 넣는 중...", "잠시 후 감시가 시작됩니다.");
await Future.delayed(_placePhoneDelay);
updateNotification("🔎 밝기 측정 중...", "주머니 속 밝기를 기준값으로 설정하는 중입니다.");
try {
await Light().requestAuthorization();
lightSub = Light().lightSensorStream.listen(
onLightReading,
onError: (Object error) {
updateNotification("❌ 조도 센서 오류", "$error");
},
);
} catch (e) {
updateNotification("❌ 조도 센서 시작 실패", "$e");
}
}
service.on('start_watch').listen((event) {
studentId = event?['studentId'];
studentName = event?['studentName'];
pocketNumber = event?['pocketNumber'];
startWatching();
});
service.on('stop_watch').listen((event) {
lightSub?.cancel();
service.stopSelf();
});
}
+154
View File
@@ -0,0 +1,154 @@
// 🛠️ 시스템 관리자 대시보드. 출석 DB 전체 초기화 기능만 담당한다.
import 'package:flutter/material.dart';
import 'package:http/http.dart' as http;
import '../config.dart';
import 'login_screen.dart';
// ==========================================
// 🛠️ 5. 관리자 대시보드 (DB 초기화 암호 파라미터 보정 완료)
// ==========================================
class AdminDashboard extends StatefulWidget {
const AdminDashboard({super.key});
@override
State<AdminDashboard> createState() => _AdminDashboardState();
}
class _AdminDashboardState extends State<AdminDashboard> {
bool _isLoading = false;
Future<void> resetDatabase() async {
setState(() => _isLoading = true);
// 🆕 백엔드 보안 규칙에 맞추어 초기화 토큰 비밀번호 파라미터(?password=...)를 연동 주소에 매칭했습니다.
final url = Uri.parse('$baseUrl/reset-db?password=adminreset2010');
try {
final response = await http.get(url);
if (response.statusCode == 200) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('💥 DB가 깔끔하게 초기화되었습니다! (출석 번호 1번부터 시작)'),
),
);
} else {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('❌ 초기화 실패: 서버 권한 오류 (${response.statusCode})'),
),
);
}
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('❌ 네트워크 에러: 서버와 연결할 수 없습니다.')),
);
} finally {
if (mounted) setState(() => _isLoading = false);
}
}
void _showResetConfirmDialog() {
showDialog(
context: context,
builder: (BuildContext dialogContext) {
return AlertDialog(
title: const Text(
'⚠️ DB 초기화 경고',
style: TextStyle(color: Colors.red, fontWeight: FontWeight.bold),
),
content: const Text(
'모든 학생의 출석 및 휴대폰 제출 데이터가 영구적으로 삭제됩니다.\n\n정말 초기화하시겠습니까?',
),
actions: [
TextButton(
onPressed: () => Navigator.pop(dialogContext),
child: const Text('취소', style: TextStyle(color: Colors.grey)),
),
ElevatedButton(
style: ElevatedButton.styleFrom(backgroundColor: Colors.red),
onPressed: () {
Navigator.pop(dialogContext);
resetDatabase();
},
child: const Text(
'초기화 실행',
style: TextStyle(color: Colors.white),
),
),
],
);
},
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('🛠️ 관리자 시스템'),
backgroundColor: Colors.orange,
foregroundColor: Colors.white,
actions: [
IconButton(
icon: const Icon(Icons.logout),
onPressed: () => Navigator.pushReplacement(
context,
MaterialPageRoute(builder: (context) => const LoginScreen()),
),
),
],
),
body: Center(
child: Padding(
padding: const EdgeInsets.all(24.0),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(
Icons.admin_panel_settings,
size: 100,
color: Colors.orange,
),
const SizedBox(height: 20),
const Text(
'데이터베이스 관리',
style: TextStyle(fontSize: 22, fontWeight: FontWeight.bold),
),
const SizedBox(height: 40),
_isLoading
? const CircularProgressIndicator(color: Colors.red)
: SizedBox(
width: double.infinity,
height: 60,
child: ElevatedButton.icon(
style: ElevatedButton.styleFrom(
backgroundColor: Colors.red[50],
foregroundColor: Colors.red,
side: const BorderSide(color: Colors.red, width: 2),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
icon: const Icon(Icons.delete_forever, size: 28),
label: const Text(
'모든 출석 데이터 초기화',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
onPressed: _showResetConfirmDialog,
),
),
],
),
),
),
);
}
}
+109
View File
@@ -0,0 +1,109 @@
// 🔑 비밀번호 변경 화면. (참고: 현재 앱 어디서도 이 화면으로 이동하는 곳이 없는 미사용 화면)
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:http/http.dart' as http;
import '../config.dart';
import 'login_screen.dart';
// 💡 main.dart 파일의 최하단(다른 클래스 중괄호 밖)에 붙여넣으세요.
class ChangePasswordScreen extends StatefulWidget {
final String studentId;
const ChangePasswordScreen({super.key, required this.studentId});
@override
State<ChangePasswordScreen> createState() => _ChangePasswordScreenState();
}
class _ChangePasswordScreenState extends State<ChangePasswordScreen> {
final _pwController = TextEditingController();
bool _isLoading = false;
Future<void> _updatePassword() async {
String newPw = _pwController.text.trim();
if (newPw.isEmpty || newPw == "1234") {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('⚠️ 초기 비밀번호와 다른 안전한 비밀번호를 입력하세요.')),
);
return;
}
setState(() => _isLoading = true);
try {
final response = await http.post(
Uri.parse('$baseUrl/api/users/change-password'),
headers: {"Content-Type": "application/json"},
body: jsonEncode({"studentId": widget.studentId, "newPassword": newPw}),
);
final res = jsonDecode(response.body);
if (response.statusCode == 200 && res['status'] == 'success') {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('🔒 비밀번호 변경 완료! 다시 로그인해 주세요.')),
);
// 비밀번호를 바꿨으니 다시 로그인 화면으로 강제 이동
Navigator.pushReplacement(
context,
MaterialPageRoute(builder: (context) => const LoginScreen()),
);
}
} catch (e) {
ScaffoldMessenger.of(
context,
).showSnackBar(const SnackBar(content: Text('❌ 통신 실패')));
} finally {
setState(() => _isLoading = false);
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: Padding(
padding: const EdgeInsets.all(32.0),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'🔒 보안을 위해\n비밀번호를 변경해 주세요',
style: TextStyle(
fontSize: 24,
fontWeight: FontWeight.bold,
height: 1.4,
),
),
const SizedBox(height: 8),
const Text(
'처음 로그인 시 초기 비밀번호(1234)를 반드시 변경해야 이용이 가능합니다.',
style: TextStyle(color: Colors.grey),
),
const SizedBox(height: 32),
TextField(
controller: _pwController,
obscureText: true,
decoration: const InputDecoration(
labelText: '새로운 비밀번호 입력',
border: OutlineInputBorder(),
),
),
const SizedBox(height: 16),
SizedBox(
width: double.infinity,
height: 50,
child: ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: Colors.indigo,
foregroundColor: Colors.white,
),
onPressed: _isLoading ? null : _updatePassword,
child: _isLoading
? const CircularProgressIndicator(color: Colors.white)
: const Text('변경 및 적용하기'),
),
),
],
),
),
);
}
}
+541
View File
@@ -0,0 +1,541 @@
// 🔑 로그인 화면. 학번/비밀번호 인증, 최초 로그인 비밀번호 변경, 권한(학생/교사/관리자)별 화면 분기를 담당한다.
import 'dart:convert';
import 'package:flutter/foundation.dart' show kIsWeb;
import 'package:flutter/material.dart';
import 'package:http/http.dart' as http;
import 'package:firebase_messaging/firebase_messaging.dart';
import '../config.dart';
import 'student_dashboard.dart';
import 'teacher_dashboard.dart';
import 'admin_dashboard.dart';
import 'teacher_register_screen.dart';
// -------------------------------------------------------------
// 1. 로그인 화면 (LoginScreen)
// -------------------------------------------------------------
class LoginScreen extends StatefulWidget {
const LoginScreen({super.key});
@override
State<LoginScreen> createState() => _LoginScreenState();
}
class _LoginScreenState extends State<LoginScreen> {
final TextEditingController _idController = TextEditingController();
final TextEditingController _pwController = TextEditingController();
bool _isLoading = false;
Future<void> _login() async {
String currentInputId = _idController.text.trim();
String currentInputPw = _pwController.text.trim();
if (currentInputId.isEmpty || currentInputPw.isEmpty) {
ScaffoldMessenger.of(
context,
).showSnackBar(const SnackBar(content: Text('⚠️ 학번과 비밀번호를 모두 입력해 주세요.')));
return;
}
// 🔥 [개발자 마스터 계정]
if (currentInputId == "2061") {
if (currentInputPw == "happy9642!") {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('👑 개발자 최고 권한으로 로그인되었습니다.')),
);
Navigator.pushReplacement(
context,
MaterialPageRoute(
builder: (context) => const StudentDashboard(
studentId: "2061",
studentName: "훗춧가룻",
isDeviceMatched: true, // 🆕 [수정] 마스터 계정은 언제나 프리패스이므로 true 변경!
),
),
);
return;
} else {
_showErrorDialog('개발자 계정의 마스터 비밀번호가 올바르지 않습니다.');
return;
}
}
setState(() => _isLoading = true);
try {
String deviceUuid = await getDeviceUuid();
String fcmToken = "";
try {
fcmToken = kIsWeb
? await FirebaseMessaging.instance.getToken(
vapidKey: webVapidKey,
) ??
""
: await FirebaseMessaging.instance.getToken() ?? "";
print("발급된 FCM 토큰: $fcmToken");
} catch (e) {
print("FCM 토큰 가져오기 실패 (파이어베이스 미설정): $e");
}
final response = await http.post(
Uri.parse('$baseUrl/login'),
headers: {"Content-Type": "application/json"},
body: jsonEncode({
"studentId": currentInputId,
"password": currentInputPw,
"deviceUuid": deviceUuid,
"fcmToken": fcmToken,
}),
);
final resData = jsonDecode(utf8.decode(response.bodyBytes));
if (response.statusCode == 200) {
print("🚨 서버 응답 데이터: $resData");
String role = '';
String name = '';
String studentId = '';
int isFirstLogin = 0;
var userObj = resData['user'];
if (userObj != null) {
role = userObj['role']?.toString() ?? '';
name =
userObj['name']?.toString() ??
userObj['studentName']?.toString() ??
userObj['student_name']?.toString() ??
'';
studentId =
userObj['studentId']?.toString() ??
userObj['student_id']?.toString() ??
currentInputId;
var rawFirst = userObj['isFirstLogin'] ?? userObj['is_first_login'];
isFirstLogin = (rawFirst is bool)
? (rawFirst ? 1 : 0)
: (int.tryParse(rawFirst.toString()) ?? 0);
} else {
role = resData['role']?.toString() ?? '';
name =
resData['name']?.toString() ??
resData['studentName']?.toString() ??
'';
studentId = resData['studentId']?.toString() ?? currentInputId;
var rawFirst = resData['isFirstLogin'] ?? resData['is_first_login'];
isFirstLogin = (rawFirst is bool)
? (rawFirst ? 1 : 0)
: (int.tryParse(rawFirst.toString()) ?? 0);
}
if (name.trim().isEmpty) {
name = "알수없음";
}
// 🆕 [추가] 서버 응답 데이터에서 기기 일치 여부 추출하기
// (서버가 주는 Key 이름에 맞춰서 데이터를 가져옵니다. 아래 항목 중 맞는 게 알아서 들어감)
bool isDeviceMatched =
resData['isDeviceMatched'] ??
resData['isUuidMatched'] ??
resData['is_matched'] ??
(userObj != null
? (userObj['isDeviceMatched'] ?? userObj['is_matched'] ?? false)
: false);
if (isFirstLogin == 1) {
_showFirstLoginPasswordDialog(
studentId,
name,
role,
isDeviceMatched,
); // 🆕 매개변수 추가
return;
}
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text('✅ $name님 환영합니다!')));
_navigateBasedOnRole(
role,
studentId,
name,
isDeviceMatched,
); // 🆕 [수정] 기기 인증 결과 전달
} else {
String errorMsg = '로그인에 실패했습니다.';
if (resData is Map) {
errorMsg =
resData['detail']?.toString() ??
resData['message']?.toString() ??
errorMsg;
}
_showErrorDialog(errorMsg);
}
} catch (e) {
_showErrorDialog('서버와 연결할 수 없습니다. 서버 상태를 확인하세요!\n($e)');
} finally {
setState(() => _isLoading = false);
}
}
// 🛠️ 초기 비밀번호 변경 팝업창 (기기 매칭 데이터 파라미터 추가)
void _showFirstLoginPasswordDialog(
String studentId,
String name,
String role,
bool isDeviceMatched, // 🆕 추가
) {
final TextEditingController newPwController = TextEditingController();
showDialog(
context: context,
barrierDismissible: false,
builder: (BuildContext dialogContext) {
bool isUpdating = false;
return StatefulBuilder(
builder: (context, setDialogState) {
return AlertDialog(
title: const Text(
'🔒 초기 비밀번호 변경',
style: TextStyle(fontWeight: FontWeight.bold),
),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Text(
'보안을 위해 새로운 비밀번호를 설정해 주세요.',
style: TextStyle(color: Colors.redAccent),
),
const SizedBox(height: 16),
TextField(
controller: newPwController,
obscureText: true,
decoration: const InputDecoration(
labelText: '새 비밀번호',
border: OutlineInputBorder(),
prefixIcon: Icon(Icons.lock_reset),
),
),
],
),
actions: [
isUpdating
? const Padding(
padding: EdgeInsets.only(right: 20.0),
child: CircularProgressIndicator(),
)
: ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: Colors.indigo,
foregroundColor: Colors.white,
),
onPressed: () async {
String newPassword = newPwController.text.trim();
if (newPassword.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('⚠️ 새 비밀번호를 입력해 주세요.'),
),
);
return;
}
setDialogState(() => isUpdating = true);
try {
final response = await http.post(
Uri.parse('$baseUrl/api/users/change-password'),
headers: {"Content-Type": "application/json"},
body: jsonEncode({
"studentId": studentId,
"newPassword": newPassword,
}),
);
final resData = jsonDecode(
utf8.decode(response.bodyBytes),
);
setDialogState(() => isUpdating = false);
if (response.statusCode == 200 &&
resData['status'] == 'success') {
Navigator.pop(dialogContext);
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('✅ 비밀번호가 변경되었습니다!'),
),
);
_navigateBasedOnRole(
role,
studentId,
name,
isDeviceMatched,
); // 🆕 수정
} else {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('❌ 실패: ${resData['message']}'),
),
);
}
} catch (e) {
setDialogState(() => isUpdating = false);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('서버 에러 발생: $e')),
);
}
},
child: const Text('변경하고 시작하기'),
),
],
);
},
);
},
);
}
// 🆕 [수정] 권한별 화면 이동시 기기 일치 여부 파라미터(`isDeviceMatched`) 수신 및 대시보드 전달
void _navigateBasedOnRole(
String role,
String studentId,
String name,
bool isDeviceMatched,
) {
if (role == 'teacher') {
Navigator.pushReplacement(
context,
MaterialPageRoute(builder: (context) => const TeacherDashboard()),
);
} else if (role == 'admin') {
Navigator.pushReplacement(
context,
MaterialPageRoute(builder: (context) => const AdminDashboard()),
);
} else {
Navigator.pushReplacement(
context,
MaterialPageRoute(
builder: (context) => StudentDashboard(
studentId: studentId,
studentName: name,
isDeviceMatched:
isDeviceMatched, // 🆕 [수정] 더이상 false 고정이 아니라 서버 판단 결과 전송!
),
),
);
}
}
void _showErrorDialog(String message) {
showDialog(
context: context,
builder: (ctx) => AlertDialog(
title: const Text(
'⚠️ 인증 실패',
style: TextStyle(fontWeight: FontWeight.bold),
),
content: Text(message),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx),
child: const Text('확인'),
),
],
),
);
}
void _showLegacyPasswordDialog(
String title,
String correctPassword,
Widget nextPage,
) {
final TextEditingController passwordController = TextEditingController();
showDialog(
context: context,
barrierDismissible: false,
builder: (BuildContext dialogContext) {
return AlertDialog(
title: Text('🔒 $title 권한 인증 (기존 방식)'),
content: TextField(
controller: passwordController,
obscureText: true,
keyboardType: TextInputType.number,
textInputAction: TextInputAction.done,
onSubmitted: (value) {
if (value == correctPassword) {
Navigator.pop(dialogContext);
Navigator.push(
context,
MaterialPageRoute(builder: (context) => nextPage),
);
} else {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('❌ 비밀번호가 올바르지 않습니다.')),
);
}
},
decoration: const InputDecoration(
hintText: '비밀번호 4자리를 입력하세요',
border: OutlineInputBorder(),
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(dialogContext),
child: const Text('취소', style: TextStyle(color: Colors.grey)),
),
ElevatedButton(
onPressed: () {
if (passwordController.text == correctPassword) {
Navigator.pop(dialogContext);
Navigator.push(
context,
MaterialPageRoute(builder: (context) => nextPage),
);
} else {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('❌ 비밀번호가 올바르지 않습니다.')),
);
}
},
child: const Text('인증하기'),
),
],
);
},
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Colors.grey[50],
body: Center(
child: SingleChildScrollView(
padding: const EdgeInsets.all(24.0),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.school, size: 80, color: Colors.indigo),
const SizedBox(height: 16),
const Text(
'백산고등학교 모니터',
style: TextStyle(
fontSize: 26,
fontWeight: FontWeight.bold,
color: Colors.indigo,
),
),
const SizedBox(height: 8),
const Text(
'학생 편의 및 학교생활 도우미',
style: TextStyle(color: Colors.grey, fontSize: 15),
),
const SizedBox(height: 40),
TextField(
controller: _idController,
textInputAction: TextInputAction.next,
decoration: const InputDecoration(
labelText: '학번 또는 교직원 번호',
border: OutlineInputBorder(),
prefixIcon: Icon(Icons.person),
),
),
const SizedBox(height: 16),
TextField(
controller: _pwController,
obscureText: true,
textInputAction: TextInputAction.done,
onSubmitted: (_) => _login(),
decoration: const InputDecoration(
labelText: '비밀번호',
border: OutlineInputBorder(),
prefixIcon: Icon(Icons.lock),
),
),
const SizedBox(height: 24),
_isLoading
? const CircularProgressIndicator()
: SizedBox(
width: double.infinity,
height: 55,
child: ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: Colors.indigo,
foregroundColor: Colors.white,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
onPressed: _login,
child: const Text(
'로그인',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
),
),
const SizedBox(height: 4),
TextButton(
onPressed: () => Navigator.push(
context,
MaterialPageRoute(
builder: (context) => const TeacherRegisterScreen(),
),
),
child: const Text(
'👨‍🏫 선생님이신가요? 교사 회원가입 하기',
style: TextStyle(
color: Colors.indigo,
fontWeight: FontWeight.bold,
),
),
),
const Divider(height: 40),
Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
TextButton.icon(
icon: const Icon(Icons.gavel, size: 18, color: Colors.grey),
label: const Text(
'교사 간편인증',
style: TextStyle(color: Colors.grey),
),
onPressed: () => _showLegacyPasswordDialog(
'선생님',
'1234',
const TeacherDashboard(),
),
),
TextButton.icon(
icon: const Icon(
Icons.settings,
size: 18,
color: Colors.grey,
),
label: const Text(
'관리자 간편인증',
style: TextStyle(color: Colors.grey),
),
onPressed: () => _showLegacyPasswordDialog(
'시스템 관리자',
'4936',
const AdminDashboard(),
),
),
],
),
],
),
),
),
);
}
}
+482
View File
@@ -0,0 +1,482 @@
// 🎓 학생 대시보드. NFC 주머니 체크인 진입, 실시간 학교 상황 안내,
// 개발자 마스터 계정 전용 관리 메뉴(현황/DB제어/계정관리/삭제)를 담당한다.
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:http/http.dart' as http;
import '../config.dart';
import '../nfc_poccket_checkin_screen.dart';
import 'login_screen.dart';
import 'teacher_attendance_page.dart';
import 'admin_dashboard.dart';
import 'student_management_screen.dart';
// -------------------------------------------------------------
// 2. 학생 대시보드 (StudentDashboard)
// -------------------------------------------------------------
class StudentDashboard extends StatefulWidget {
final String studentId;
final String studentName;
final bool isDeviceMatched; // 🆕 [변경] 기기 UUID가 매칭되었는지 확인하는 변수 추가
const StudentDashboard({
super.key,
required this.studentId,
required this.studentName,
required this.isDeviceMatched, // 🆕 [변경] 필수 매개변수로 등록
});
@override
State<StudentDashboard> createState() => _StudentDashboardState();
}
class _StudentDashboardState extends State<StudentDashboard> {
bool _isLoading = false;
// 1️⃣ 가상 NFC 태깅 카드 → 실제 NFC 주머니 체크인 화면으로 이동
void _openPocketCheckIn(BuildContext context) {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => NfcPocketCheckInScreen(
studentId: widget.studentId,
studentName: widget.studentName,
),
),
);
}
// 2️⃣ [기존 동일] 마스터 계정 전용 회원 삭제 API 호출 함수
Future<void> _deleteUser(BuildContext context, String userId) async {
setState(() => _isLoading = true);
final url = Uri.parse('$baseUrl/api/users/delete/$userId');
try {
final response = await http.delete(url);
if (response.statusCode == 200) {
final responseData = jsonDecode(response.body);
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text('✅ ${responseData['message']}')));
} else {
final errorData = jsonDecode(response.body);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('❌ 삭제 실패: ${errorData['detail'] ?? '알 수 없는 오류'}'),
),
);
}
} catch (e) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('❌ 서버와 연결할 수 없습니다. (네트워크 에러)')),
);
} finally {
setState(() => _isLoading = false);
}
}
// 3️⃣ [기존 동일] 학번/교직원 번호를 입력받는 모던 팝업창(Dialog)
void _showDeleteUserDialog(BuildContext context) {
final TextEditingController idController = TextEditingController();
showDialog(
context: context,
builder: (context) {
return AlertDialog(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(24),
),
title: const Row(
children: [
Icon(Icons.warning_amber_rounded, color: Colors.redAccent),
SizedBox(width: 10),
Text(
'계정 강제 삭제',
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 18),
),
],
),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'학생의 학번 또는 교사의 교직원 번호를 입력하세요.\nDB에서 해당 계정과 토큰이 즉시 삭제됩니다.',
style: TextStyle(
color: Colors.black54,
fontSize: 13,
height: 1.4,
),
),
const SizedBox(height: 16),
TextField(
controller: idController,
decoration: InputDecoration(
labelText: '학번 또는 교직원 번호',
hintText: '예: 201101 또는 T1001',
labelStyle: TextStyle(color: Colors.red[400]),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(16),
borderSide: BorderSide(color: Colors.red[400]!, width: 2),
),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(16),
),
prefixIcon: const Icon(Icons.person_remove_alt_1_rounded),
),
),
],
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text(
'취소',
style: TextStyle(
color: Colors.grey,
fontWeight: FontWeight.bold,
),
),
),
ElevatedButton(
onPressed: () {
final inputId = idController.text.trim();
if (inputId.isNotEmpty) {
Navigator.pop(context);
_deleteUser(context, inputId);
}
},
style: ElevatedButton.styleFrom(
backgroundColor: Colors.redAccent,
foregroundColor: Colors.white,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
elevation: 0,
),
child: const Text(
'삭제 실행',
style: TextStyle(fontWeight: FontWeight.bold),
),
),
],
);
},
);
}
@override
Widget build(BuildContext context) {
final bool isDeveloper = widget.studentId == "2061";
return Scaffold(
backgroundColor: Colors.grey[100],
appBar: AppBar(
title: Text(
isDeveloper ? '👑 MASTER CONTROL' : '🎓 STUDENT PORTAL',
style: const TextStyle(
fontWeight: FontWeight.bold,
letterSpacing: 1.2,
),
),
centerTitle: true,
backgroundColor: isDeveloper
? Colors.deepPurple[700]
: Colors.indigo[700],
foregroundColor: Colors.white,
elevation: 0,
actions: [
IconButton(
icon: const Icon(Icons.logout_rounded),
onPressed: () => Navigator.pushReplacement(
context,
MaterialPageRoute(builder: (context) => const LoginScreen()),
),
),
],
),
body: SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 28),
decoration: BoxDecoration(
color: isDeveloper
? Colors.deepPurple[700]
: Colors.indigo[700],
borderRadius: const BorderRadius.only(
bottomLeft: Radius.circular(32),
bottomRight: Radius.circular(32),
),
),
child: Row(
children: [
Container(
padding: const EdgeInsets.all(4),
decoration: const BoxDecoration(
color: Colors.white,
shape: BoxShape.circle,
),
child: CircleAvatar(
radius: 30,
backgroundColor: isDeveloper
? Colors.deepPurple[50]
: Colors.indigo[50],
child: Icon(
isDeveloper
? Icons.admin_panel_settings_rounded
: Icons.school_rounded,
size: 32,
color: isDeveloper ? Colors.deepPurple : Colors.indigo,
),
),
),
const SizedBox(width: 18),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'${widget.studentName} 님',
style: const TextStyle(
fontSize: 22,
fontWeight: FontWeight.bold,
color: Colors.white,
),
),
const SizedBox(height: 4),
Text(
isDeveloper
? '최고 관리 권한 활성화됨'
: '학번: ${widget.studentId} | 인증 완료',
style: TextStyle(
color: Colors.white.withValues(alpha: 0.8),
fontSize: 14,
),
),
],
),
],
),
),
const SizedBox(height: 32),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 24.0),
child: Row(
children: [
Container(
width: 4,
height: 18,
decoration: BoxDecoration(
color: isDeveloper ? Colors.deepPurple : Colors.indigo,
borderRadius: BorderRadius.circular(2),
),
),
const SizedBox(width: 10),
const Text(
'스마트 관리 시스템 메뉴',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
color: Colors.black87,
),
),
],
),
),
const SizedBox(height: 16),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 24.0),
child: GridView.count(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
crossAxisCount: 2,
crossAxisSpacing: 16,
mainAxisSpacing: 16,
childAspectRatio: 0.95,
children: [
// 🆕 [변경 지점] 기기가 일치하면 정상 활성화, 일치하지 않으면 자물쇠Lock 처리
if (isDeveloper || widget.studentId != "2061")
_buildModernCard(
icon: widget.isDeviceMatched
? Icons.contactless_rounded
: Icons.lock_rounded,
title: '가상 NFC 태깅',
subtitle: widget.isDeviceMatched
? '출석 및 폰 수거 완료'
: '⚠️ 본인 인증 기기 전용',
color: widget.isDeviceMatched
? Colors.blue
: Colors.grey[400]!,
onTap: widget.isDeviceMatched
? () => _openPocketCheckIn(context)
: () {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text(
'🚨 대리 출석 방지를 위해 등록된 본인 스마트폰에서만 출석 가능합니다.',
),
),
);
},
isActionButton: widget.isDeviceMatched,
isLoading: _isLoading,
),
// 🆕 [추가 지점] 기기 일치 여부 상관없이 태블릿에서도 누구나 확인 가능한 학교 상황판 카드
_buildModernCard(
icon: Icons.fastfood_rounded,
title: '실시간 학교 상황',
subtitle: '급식실 줄 & 매점 재고 확인',
color: Colors.orange[700]!,
onTap: () {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('🍔 실시간 학교 상황 페이지로 이동합니다.'),
),
);
},
),
if (isDeveloper)
_buildModernCard(
icon: Icons.monitor_heart_rounded,
title: '실시간 현황',
subtitle: '교사용 수거 모니터링',
color: Colors.teal,
onTap: () => Navigator.push(
context,
MaterialPageRoute(
builder: (context) => const TeacherAttendancePage(),
),
),
),
if (isDeveloper)
_buildModernCard(
icon: Icons.terminal_rounded,
title: '서버 DB 제어',
subtitle: '시스템 원격 초기화',
color: Colors.amber[800]!,
onTap: () => Navigator.push(
context,
MaterialPageRoute(
builder: (context) => const AdminDashboard(),
),
),
),
if (isDeveloper)
_buildModernCard(
icon: Icons.add_moderator_rounded,
title: '학생 계정 관리',
subtitle: 'UUID 리셋 및 승인',
color: Colors.purple,
onTap: () => Navigator.push(
context,
MaterialPageRoute(
builder: (context) => const StudentManagementScreen(),
),
),
),
if (isDeveloper)
_buildModernCard(
icon: Icons.delete_sweep_rounded,
title: '계정 강제 삭제',
subtitle: '학생 및 교사 DB 삭제',
color: Colors.red[600]!,
onTap: () => _showDeleteUserDialog(context),
),
],
),
),
const SizedBox(height: 32),
],
),
),
);
}
// 5️⃣ [카드 디자인 위젯] - 기존 형태 완전 보존
Widget _buildModernCard({
required IconData icon,
required String title,
required String subtitle,
required Color color,
required VoidCallback onTap,
bool isActionButton = false,
bool isLoading = false,
}) {
return InkWell(
onTap: isLoading ? null : onTap,
borderRadius: BorderRadius.circular(24),
child: Ink(
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,
offset: const Offset(0, 4),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: color.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(16),
),
child: Icon(icon, color: color, size: 28),
),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
title,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: Colors.black87,
),
),
if (isLoading)
const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
)
else if (isActionButton)
Icon(
Icons.touch_app_rounded,
size: 16,
color: color.withValues(alpha: 0.5),
),
],
),
const SizedBox(height: 4),
Text(
subtitle,
style: TextStyle(
fontSize: 12,
color: Colors.grey[500],
height: 1.2,
),
),
],
),
],
),
),
);
}
}
+338
View File
@@ -0,0 +1,338 @@
// 🔐 (마스터 계정용) 학생 계정 및 기기 관리 화면. 기기 UUID 초기화와 계정 삭제를 담당한다.
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:http/http.dart' as http;
import '../config.dart';
// ==========================================
// 🔐 6. 학생 계정 및 기기 관리 화면 (기기 리셋 + 계정 삭제 완본)
// ==========================================
class StudentManagementScreen extends StatefulWidget {
const StudentManagementScreen({super.key});
@override
State<StudentManagementScreen> createState() =>
_StudentManagementScreenState();
}
class _StudentManagementScreenState extends State<StudentManagementScreen> {
List<dynamic> realStudents = [];
bool _isLoading = true;
@override
void initState() {
super.initState();
_fetchStudents();
}
// 🌐 서버에서 전체 학생 목록 불러오기
Future<void> _fetchStudents() async {
setState(() => _isLoading = true);
try {
final response = await http.get(Uri.parse('$baseUrl/api/users'));
if (response.statusCode == 200) {
final data = jsonDecode(utf8.decode(response.bodyBytes));
setState(() {
realStudents = data['users'] ?? [];
_isLoading = false;
});
}
} catch (e) {
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text('데이터 불러오기 실패: $e')));
setState(() => _isLoading = false);
}
}
// 🌐 서버로 기기 초기화(리셋) 명령 보내기
Future<void> _resetDevice(String studentId, String studentName) async {
showDialog(
context: context,
builder: (ctx) => AlertDialog(
title: const Text(
'⚠️ 기기 잠금 해제',
style: TextStyle(fontWeight: FontWeight.bold, color: Colors.purple),
),
content: Text('$studentName 학생의 스마트폰 기기 등록을 초기화하시겠습니까?'),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx),
child: const Text('취소', style: TextStyle(color: Colors.grey)),
),
ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: Colors.purple,
foregroundColor: Colors.white,
),
onPressed: () async {
Navigator.pop(ctx);
try {
final response = await http.post(
Uri.parse('$baseUrl/api/users/reset'),
headers: {"Content-Type": "application/json"},
body: jsonEncode({"studentId": studentId}),
);
if (response.statusCode == 200) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('✅ $studentName 학생 기기 초기화 완료!')),
);
_fetchStudents();
}
} catch (e) {
ScaffoldMessenger.of(
context,
).showSnackBar(const SnackBar(content: Text('❌ 초기화 통신 실패')));
}
},
child: const Text('초기화 승인'),
),
],
),
);
}
// 🌐 [새 기능] 서버로 계정 완전 삭제 명령 보내기 함수
Future<void> _deleteUser(String studentId, String studentName) async {
showDialog(
context: context,
builder: (ctx) => AlertDialog(
title: const Text(
'🚨 계정 완전 삭제 경고',
style: TextStyle(fontWeight: FontWeight.bold, color: Colors.red),
),
content: Text(
'정말로 $studentName ($studentId) 학생의 계정을 시스템에서 탈퇴(삭제)시키겠습니까?\n\n이 작업은 되돌릴 수 없으며, 해당 학생은 다시 회원가입을 진행해야 합니다.',
),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx),
child: const Text('취소', style: TextStyle(color: Colors.grey)),
),
ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: Colors.red,
foregroundColor: Colors.white,
),
onPressed: () async {
Navigator.pop(ctx);
try {
// 💡 http.delete 함수를 사용하여 서버에 계정 삭제 요청 발송!
final response = await http.delete(
Uri.parse('$baseUrl/api/users/delete'),
headers: {"Content-Type": "application/json"},
body: jsonEncode({"studentId": studentId}),
);
if (response.statusCode == 200) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('💥 $studentName 학생의 계정이 영구 삭제되었습니다.'),
),
);
_fetchStudents(); // 성공하면 목록 새로고침!
}
} catch (e) {
ScaffoldMessenger.of(
context,
).showSnackBar(const SnackBar(content: Text('❌ 계정 삭제 통신 실패')));
}
},
child: const Text('영구 삭제'),
),
],
),
);
}
// 💡 _StudentManagementScreenState 클래스 내부에 붙여넣으세요.
void _showCreateUserDialog() {
final idController = TextEditingController();
final nameController = TextEditingController();
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('👤 신규 학생 계정 추가'),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
TextField(
controller: idController,
decoration: const InputDecoration(labelText: '학번 입력 (예: 20101)'),
keyboardType: TextInputType.number,
),
const SizedBox(height: 8),
TextField(
controller: nameController,
decoration: const InputDecoration(labelText: '학생 이름 입력'),
),
],
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('취소'),
),
ElevatedButton(
onPressed: () async {
String studentId = idController.text.trim();
String name = nameController.text.trim();
if (studentId.isEmpty || name.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('⚠️ 학번과 이름을 모두 입력하세요.')),
);
return;
}
try {
// ⚠️ 여기도 훗춧가룻님의 프로젝트 전역 baseUrl 변수명으로 맞춰주세요!
final response = await http.post(
Uri.parse('$baseUrl/api/users/create'),
headers: {"Content-Type": "application/json"},
body: jsonEncode({"studentId": studentId, "name": name}),
);
final res = jsonDecode(response.body);
if (res['status'] == 'success') {
Navigator.pop(context); // 팝업 닫기
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('✅ ${res['message']}')),
);
_fetchStudents(); // 🔄 추가 완료 후 목록 새로고침 (기존 함수명 확인 필요)
} else {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('❌ ${res['message']}')),
);
}
} catch (e) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('❌ 학생 등록 실패 (통신 에러)')),
);
}
},
child: const Text('생성'),
),
],
),
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Colors.grey[50],
appBar: AppBar(
title: const Text(
'학생 계정 및 기기 관리',
style: TextStyle(fontWeight: FontWeight.bold),
),
backgroundColor: Colors.purple,
foregroundColor: Colors.white,
actions: [
IconButton(
icon: const Icon(Icons.person_add_alt_1_rounded),
onPressed: () => _showCreateUserDialog(), // 팝업 띄우는 함수 제작하여 연결
),
IconButton(
icon: const Icon(Icons.refresh),
onPressed: _fetchStudents,
),
],
),
body: _isLoading
? const Center(child: CircularProgressIndicator(color: Colors.purple))
: realStudents.isEmpty
? const Center(
child: Text(
'가입된 학생 계정이 없습니다.',
style: TextStyle(color: Colors.grey, fontSize: 16),
),
)
: ListView.builder(
padding: const EdgeInsets.all(16),
itemCount: realStudents.length,
itemBuilder: (context, index) {
final student = realStudents[index];
final bool needsReset = student['device'] == "초기화 필요";
return Card(
elevation: 2,
margin: const EdgeInsets.only(bottom: 12),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
child: ListTile(
contentPadding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 12,
),
leading: CircleAvatar(
backgroundColor: needsReset
? Colors.red[50]
: Colors.purple[50],
child: Icon(
needsReset ? Icons.lock_reset : Icons.person,
color: needsReset ? Colors.red : Colors.purple,
),
),
title: Text(
'${student['name']} (${student['id']})',
style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 16,
),
),
subtitle: Text(
needsReset ? '기기 초기화 승인 대기중' : '정상 등록 상태',
style: TextStyle(
color: needsReset ? Colors.red : Colors.grey,
fontWeight: needsReset
? FontWeight.bold
: FontWeight.normal,
),
),
// 🕹️ 오른쪽 끝에 [기기 리셋] 버튼과 [쓰레기통(삭제)] 버튼을 나란히 배치하는 Row 레이아웃 기획
trailing: Row(
mainAxisSize: MainAxisSize.min, // 콤팩트하게 뭉치기
children: [
if (!needsReset)
OutlinedButton(
style: OutlinedButton.styleFrom(
foregroundColor: Colors.purple,
side: BorderSide(
color: Colors.purple.withValues(alpha: 0.5),
),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
),
onPressed: () =>
_resetDevice(student['id'], student['name']),
child: const Text('기기 리셋'),
)
else
const Icon(Icons.check_circle, color: Colors.green),
const SizedBox(width: 8),
// 🔴 [새 버튼] 최고권한 마스터 전용 계정 영구 삭제 쓰레기통 버튼!
IconButton(
icon: const Icon(
Icons.delete_forever_rounded,
color: Colors.redAccent,
),
tooltip: '계정 영구 삭제',
onPressed: () =>
_deleteUser(student['id'], student['name']),
),
],
),
),
);
},
),
);
}
}
+322
View File
@@ -0,0 +1,322 @@
// 📋 실시간 출석 현황 화면. 전체 학생 명단(/api/users)과 출석 로그(/api/logs)를
// 합쳐서 출석/미출석 요약 카드 및 필터링된 목록을 3초마다 갱신해 보여준다.
import 'dart:async';
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:http/http.dart' as http;
import '../config.dart';
// -----------------------------------------------------------------------------
// 📅 [서브 화면 1] 실시간 출석 확인 란 (StudentDashboard 카드 스타일 리스트화)
// -----------------------------------------------------------------------------
class TeacherAttendancePage extends StatefulWidget {
const TeacherAttendancePage({super.key});
@override
State<TeacherAttendancePage> createState() => _TeacherAttendancePageState();
}
// 로그의 'name' 컬럼은 백엔드에서 "학생이름 (주머니정보)" 형태로 합쳐져 저장되어 있어서
// 이름과 주머니 번호를 분리해서 보여주려면 클라이언트에서 파싱해야 한다.
final RegExp _logNamePattern = RegExp(r'^(.*?)\s*\(([^)]*)\)$');
class _TeacherAttendancePageState extends State<TeacherAttendancePage> {
List<dynamic> _roster = []; // 전체 학생 명단 (/api/users)
List<dynamic> _logs = []; // 출석 로그 (/api/logs)
Timer? _timer;
bool _isLoading = true;
String _filterType = "ALL"; // "ALL", "CHECKED_IN", "ABSENT"
@override
void initState() {
super.initState();
_fetchAll();
_timer = Timer.periodic(const Duration(seconds: 3), (timer) => _fetchAll());
}
@override
void dispose() {
_timer?.cancel();
super.dispose();
}
Future<void> _fetchAll() async {
try {
final results = await Future.wait([
http.get(Uri.parse('$baseUrl/api/users')),
http.get(Uri.parse('$baseUrl/api/logs')),
]);
final usersRes = results[0];
final logsRes = results[1];
if (usersRes.statusCode == 200 && logsRes.statusCode == 200) {
final usersData = jsonDecode(utf8.decode(usersRes.bodyBytes));
final logsData = jsonDecode(utf8.decode(logsRes.bodyBytes));
setState(() {
_roster = usersData['users'] ?? [];
_logs = logsData['logs'] ?? [];
_isLoading = false;
});
} else {
setState(() => _isLoading = false);
}
} catch (e) {
setState(() => _isLoading = false);
}
}
/// 오늘 날짜 기준으로 학번별 "가장 최근 출석 기록"만 남긴 맵을 만든다.
Map<String, Map<String, String>> get _todaysCheckInsByStudentId {
final String todayPrefix = DateTime.now().toIso8601String().substring(
0,
10,
); // "YYYY-MM-DD"
final Map<String, Map<String, String>> result = {};
for (final log in _logs) {
final String time = log['time']?.toString() ?? '';
if (!time.startsWith(todayPrefix)) continue; // 오늘 기록이 아니면 무시
final String studentId = log['student_id']?.toString() ?? '';
if (studentId.isEmpty) continue;
// 이미 더 최신 기록을 찾았다면(로그는 최신순 정렬) 건너뛴다.
if (result.containsKey(studentId)) continue;
final String rawName = log['name']?.toString() ?? '';
final match = _logNamePattern.firstMatch(rawName);
result[studentId] = {
'name': match != null ? match.group(1)! : rawName,
'pocketNumber': match != null ? match.group(2)! : '주머니 미지정',
'time': time,
};
}
return result;
}
List<Map<String, dynamic>> get _combinedStudentStatus {
final checkIns = _todaysCheckInsByStudentId;
return _roster.map((u) {
final String id = u['id']?.toString() ?? '';
final checkIn = checkIns[id];
return {
'studentId': id,
'studentName': u['name']?.toString() ?? '',
'isCheckedIn': checkIn != null,
'pocketNumber': checkIn?['pocketNumber'],
'checkInTime': checkIn?['time'],
};
}).toList();
}
List<Map<String, dynamic>> get _filteredStudents {
final all = _combinedStudentStatus;
if (_filterType == "CHECKED_IN") {
return all.where((s) => s['isCheckedIn'] == true).toList();
} else if (_filterType == "ABSENT") {
return all.where((s) => s['isCheckedIn'] == false).toList();
}
return all;
}
@override
Widget build(BuildContext context) {
final all = _combinedStudentStatus;
final int totalCount = all.length;
final int checkedInCount = all.where((s) => s['isCheckedIn'] == true).length;
final int absentCount = totalCount - checkedInCount;
return Scaffold(
backgroundColor: Colors.grey[100],
appBar: AppBar(
title: const Text(
'📋 실시간 출석 현황',
style: TextStyle(fontWeight: FontWeight.bold),
),
backgroundColor: Colors.blue,
foregroundColor: Colors.white,
elevation: 0,
),
body: _isLoading
? const Center(child: CircularProgressIndicator())
: Column(
children: [
_buildSummaryCards(totalCount, checkedInCount, absentCount),
_buildFilterChips(),
Expanded(
child: _filteredStudents.isEmpty
? const Center(
child: Text(
'해당하는 학생이 없습니다.',
style: TextStyle(color: Colors.grey),
),
)
: ListView.builder(
padding: const EdgeInsets.symmetric(vertical: 12),
itemCount: _filteredStudents.length,
itemBuilder: (context, index) {
final student = _filteredStudents[index];
final bool isCheckedIn = student['isCheckedIn'];
return Container(
margin: const EdgeInsets.symmetric(
horizontal: 24,
vertical: 8,
),
padding: const EdgeInsets.all(18),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(20),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.03),
blurRadius: 12,
offset: const Offset(0, 4),
),
],
),
child: Row(
children: [
Container(
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: (isCheckedIn
? Colors.blue
: Colors.red)
.withValues(alpha: 0.1),
shape: BoxShape.circle,
),
child: Icon(
isCheckedIn
? Icons.check_circle_rounded
: Icons.error_rounded,
color: isCheckedIn
? Colors.blue
: Colors.red,
size: 24,
),
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment:
CrossAxisAlignment.start,
children: [
Text(
'${student['studentName']} 학생',
style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 16,
),
),
const SizedBox(height: 4),
Text(
isCheckedIn
? '학번: ${student['studentId']} | 제출시간: ${student['checkInTime']}'
: '학번: ${student['studentId']} | 미제출',
style: TextStyle(
color: isCheckedIn
? Colors.grey[600]
: Colors.red[400],
fontSize: 12,
),
),
],
),
),
if (isCheckedIn &&
student['pocketNumber'] != null)
Container(
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 6,
),
decoration: BoxDecoration(
color: Colors.blue.shade50,
borderRadius: BorderRadius.circular(20),
border: Border.all(
color: Colors.blue.shade200,
),
),
child: Text(
student['pocketNumber'],
style: const TextStyle(
fontWeight: FontWeight.bold,
color: Colors.blueAccent,
fontSize: 12,
),
),
),
],
),
);
},
),
),
],
),
);
}
/// 📊 상단 요약 카드 뷰 (전체 / 출석 완료 / 미출석)
Widget _buildSummaryCards(int total, int checkedIn, int absent) {
return Container(
width: double.infinity,
padding: const EdgeInsets.all(16),
color: const Color(0xFF1E293B),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [
_summaryCard("전체", "$total명", Colors.white70),
_summaryCard("출석 완료", "$checkedIn명", Colors.greenAccent),
_summaryCard("미출석", "$absent명", Colors.redAccent),
],
),
);
}
Widget _summaryCard(String title, String count, Color color) {
return Column(
children: [
Text(title, style: const TextStyle(color: Colors.grey, fontSize: 12)),
const SizedBox(height: 4),
Text(
count,
style: TextStyle(
color: color,
fontSize: 20,
fontWeight: FontWeight.bold,
),
),
],
);
}
/// 🔘 필터 칩버튼 (전체 / 출석자 / 미출석자)
Widget _buildFilterChips() {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 16),
child: Row(
children: [
FilterChip(
label: const Text("전체"),
selected: _filterType == "ALL",
onSelected: (_) => setState(() => _filterType = "ALL"),
),
const SizedBox(width: 8),
FilterChip(
label: const Text("🟢 출석자"),
selected: _filterType == "CHECKED_IN",
onSelected: (_) => setState(() => _filterType = "CHECKED_IN"),
),
const SizedBox(width: 8),
FilterChip(
label: const Text("🔴 미출석자"),
selected: _filterType == "ABSENT",
onSelected: (_) => setState(() => _filterType = "ABSENT"),
),
],
),
);
}
}
+267
View File
@@ -0,0 +1,267 @@
// 👨‍🏫 교사 대시보드. 실시간 출석 확인, 학생 계정 관리 화면으로 가는 메뉴만 담당한다.
import 'package:flutter/material.dart';
import 'login_screen.dart';
import 'teacher_attendance_page.dart';
import 'teacher_student_management_page.dart';
// ==========================================
// 👨‍🏫 4. 선생님 대시보드 (기존 3초 타이머 완벽 유지)
// ==========================================
class TeacherDashboard extends StatefulWidget {
final String? teacherId;
final String? teacherName;
const TeacherDashboard({super.key, this.teacherId, this.teacherName});
@override
State<TeacherDashboard> createState() => _TeacherDashboardState();
}
class _TeacherDashboardState extends State<TeacherDashboard> {
// 🧼 [수정] 사용하지 않던 _isLoading 변수를 삭제하여 경고를 완벽히 해결했습니다!
@override
Widget build(BuildContext context) {
// 다른 화면에서 null이 넘어왔을 때를 대비한 안전망 방탄 코드
final String displayName = widget.teacherName ?? "간편인증 선생";
final String displayId = widget.teacherId ?? "간편인증";
return Scaffold(
backgroundColor: Colors.grey[100],
appBar: AppBar(
title: const Text(
'👨‍🏫 TEACHER PORTAL',
style: TextStyle(fontWeight: FontWeight.bold, letterSpacing: 1.2),
),
centerTitle: true,
backgroundColor: Colors.green[700], // 교사 전용 그린 테마 컬러
foregroundColor: Colors.white,
elevation: 0,
actions: [
IconButton(
icon: const Icon(Icons.logout_rounded),
onPressed: () => Navigator.pushReplacement(
context,
MaterialPageRoute(builder: (context) => const LoginScreen()),
),
),
],
),
body: SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 💳 [상단 배너 가이드] StudentDashboard와 100% 일치하는 프로필 카드 레이아웃
Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 28),
decoration: BoxDecoration(
color: Colors.green[700],
borderRadius: const BorderRadius.only(
bottomLeft: Radius.circular(32),
bottomRight: Radius.circular(32),
),
),
child: Row(
children: [
Container(
padding: const EdgeInsets.all(4),
decoration: const BoxDecoration(
color: Colors.white,
shape: BoxShape.circle,
),
child: CircleAvatar(
radius: 30,
backgroundColor: Colors.green[50],
child: Icon(
Icons.admin_panel_settings_rounded,
size: 32,
color: Colors.green[700],
),
),
),
const SizedBox(width: 18),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'$displayName 님',
style: const TextStyle(
fontSize: 22,
fontWeight: FontWeight.bold,
color: Colors.white,
),
),
const SizedBox(height: 4),
Text(
'교직원 번호: $displayId | 교사 권한 활성화됨',
style: TextStyle(
color: Colors.white.withValues(alpha: 0.8),
fontSize: 14,
),
),
],
),
],
),
),
const SizedBox(height: 32),
// 🏷️ [세로 바 타이틀 인디케이터] 구조 일치화
Padding(
padding: const EdgeInsets.symmetric(horizontal: 24.0),
child: Row(
children: [
Container(
width: 4,
height: 18,
decoration: BoxDecoration(
color: Colors.green[700],
borderRadius: BorderRadius.circular(2),
),
),
const SizedBox(width: 10),
const Text(
'스마트 교사용 관리 메뉴',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
color: Colors.black87,
),
),
],
),
),
const SizedBox(height: 16),
// 📊 [그리드 레이아웃 메뉴] 시후의 카드 컴포넌트 스타일 적용
Padding(
padding: const EdgeInsets.symmetric(horizontal: 24.0),
child: GridView.count(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
crossAxisCount: 2,
crossAxisSpacing: 16,
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.manage_accounts_rounded,
title: '학생 계정 관리',
subtitle: '계정 추가 및 강제 리셋',
color: Colors.orange,
onTap: () => Navigator.push(
context,
MaterialPageRoute(
builder: (context) =>
const TeacherStudentManagementPage(),
),
),
),
],
),
),
const SizedBox(height: 32),
],
),
),
);
}
// 💎 [시후 대시보드 전용 카드 위젯 이식 완료]
Widget _buildModernCard({
required IconData icon,
required String title,
required String subtitle,
required Color color,
required VoidCallback onTap,
bool isActionButton = false,
bool isLoading = false,
}) {
return InkWell(
onTap: isLoading ? null : onTap,
borderRadius: BorderRadius.circular(24),
child: Ink(
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,
offset: const Offset(0, 4),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: color.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(16),
),
child: Icon(icon, color: color, size: 28),
),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Expanded(
child: Text(
title,
style: const TextStyle(
fontSize: 15,
fontWeight: FontWeight.bold,
color: Colors.black87,
),
),
),
if (isLoading)
const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
)
else if (isActionButton)
Icon(
Icons.touch_app_rounded,
size: 16,
color: color.withValues(alpha: 0.5),
),
],
),
const SizedBox(height: 4),
Text(
subtitle,
style: TextStyle(
fontSize: 12,
color: Colors.grey[500],
height: 1.2,
),
),
],
),
],
),
),
);
}
}
+152
View File
@@ -0,0 +1,152 @@
// 👨‍🏫 교사 회원가입 화면. 교사 인증 코드 확인 후 신규 교사 계정을 생성한다.
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:http/http.dart' as http;
import '../config.dart';
class TeacherRegisterScreen extends StatefulWidget {
const TeacherRegisterScreen({super.key});
@override
State<TeacherRegisterScreen> createState() => _TeacherRegisterScreenState();
}
class _TeacherRegisterScreenState extends State<TeacherRegisterScreen> {
final _idController = TextEditingController();
final _pwController = TextEditingController();
final _nameController = TextEditingController();
final _secretController = TextEditingController();
bool _isLoading = false;
Future<void> _registerTeacher() async {
String id = _idController.text.trim();
String pw = _pwController.text.trim();
String name = _nameController.text.trim();
String secret = _secretController.text.trim();
// 💡 테스트용 고유값 (실제 디바이스 UUID 연동 로직이 있다면 그걸 넣으세요)
String dummyUuid = "TEACHER_PHONE_$id";
if (id.isEmpty || pw.isEmpty || name.isEmpty || secret.isEmpty) {
ScaffoldMessenger.of(
context,
).showSnackBar(const SnackBar(content: Text('⚠️ 모든 빈칸을 입력해 주세요.')));
return;
}
setState(() => _isLoading = true);
try {
final response = await http.post(
Uri.parse('$baseUrl/api/users/register-teacher'),
headers: {"Content-Type": "application/json"},
body: jsonEncode({
"teacherId": id,
"password": pw,
"name": name,
"secretCode": secret,
"deviceUuid": dummyUuid,
}),
);
final res = jsonDecode(response.body);
if (response.statusCode == 200 && res['status'] == 'success') {
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text('✅ ${res['message']}')));
Navigator.pop(context); // 가입 성공 시 로그인 화면으로 복귀
} else {
// 백엔드에서 보낸 에러 메시지(detail 혹은 message) 출력
String errorMsg = res['detail'] ?? res['message'] ?? '회원가입에 실패했습니다.';
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text('❌ $errorMsg')));
}
} catch (e) {
ScaffoldMessenger.of(
context,
).showSnackBar(const SnackBar(content: Text('❌ 서버와 통신에 실패했습니다.')));
} finally {
setState(() => _isLoading = false);
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('교사 회원가입'),
backgroundColor: Colors.indigo,
foregroundColor: Colors.white,
),
body: SingleChildScrollView(
padding: const EdgeInsets.all(24.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'👨‍🏫 교직원 전용 인증',
style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold),
),
const SizedBox(height: 4),
const Text(
'학교에서 발급한 교사 가입 비밀코드가 필요합니다.',
style: TextStyle(color: Colors.grey),
),
const SizedBox(height: 24),
TextField(
controller: _secretController,
obscureText: true,
decoration: const InputDecoration(
labelText: '🔑 교사 인증 비밀코드 입력',
border: OutlineInputBorder(),
),
),
const SizedBox(height: 16),
const Divider(),
const SizedBox(height: 16),
TextField(
controller: _idController,
decoration: const InputDecoration(
labelText: '교직원 번호 (ID로 사용)',
border: OutlineInputBorder(),
),
),
const SizedBox(height: 12),
TextField(
controller: _nameController,
decoration: const InputDecoration(
labelText: '선생님 성함',
border: OutlineInputBorder(),
),
),
const SizedBox(height: 12),
TextField(
controller: _pwController,
obscureText: true,
decoration: const InputDecoration(
labelText: '사용할 비밀번호 입력',
border: OutlineInputBorder(),
),
),
const SizedBox(height: 24),
SizedBox(
width: double.infinity,
height: 50,
child: ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: Colors.indigo,
foregroundColor: Colors.white,
),
onPressed: _isLoading ? null : _registerTeacher,
child: _isLoading
? const CircularProgressIndicator(color: Colors.white)
: const Text('교사 계정 생성하기'),
),
),
],
),
),
);
}
}
@@ -0,0 +1,363 @@
// 👥 교사용 학생 계정 관리 화면. 신규 학생 계정 추가와 계정 강제 삭제를 담당한다.
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:http/http.dart' as http;
import '../config.dart';
// -----------------------------------------------------------------------------
// 👥 [서브 화면 2] 학생 계정 관리 란 (추가 양식 폼 + 강제 삭제 다이얼로그 완전 내장)
// -----------------------------------------------------------------------------
class TeacherStudentManagementPage extends StatefulWidget {
const TeacherStudentManagementPage({super.key});
@override
State<TeacherStudentManagementPage> createState() =>
_TeacherStudentManagementPageState();
}
class _TeacherStudentManagementPageState
extends State<TeacherStudentManagementPage> {
final TextEditingController _addIdController = TextEditingController();
final TextEditingController _addNameController = TextEditingController();
final TextEditingController _addPwController = TextEditingController();
bool _isWorking = false;
// ➕ [학생 추가 API 연동용 함수]
Future<void> _addStudentAccount() async {
final sId = _addIdController.text.trim();
final sName = _addNameController.text.trim();
final sPw = _addPwController.text.trim();
if (sId.isEmpty || sName.isEmpty || sPw.isEmpty) {
ScaffoldMessenger.of(
context,
).showSnackBar(const SnackBar(content: Text('⚠️ 모든 입력란을 채워주세요.')));
return;
}
setState(() => _isWorking = true);
try {
final url = Uri.parse('$baseUrl/api/users/register-student');
final response = await http.post(
url,
headers: {"Content-Type": "application/json"},
body: jsonEncode({"studentId": sId, "name": sName, "password": sPw}),
);
final result = jsonDecode(utf8.decode(response.bodyBytes));
if (response.statusCode == 200 || response.statusCode == 201) {
_addIdController.clear();
_addNameController.clear();
_addPwController.clear();
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('✅ 계정 생성 완료: ${result['message'] ?? '성공'}')),
);
} else {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('❌ 생성 실패: ${result['message'] ?? '오류 발생'}')),
);
}
} catch (e) {
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text('🚨 네트워크 에러: $e')));
} finally {
setState(() => _isWorking = false);
}
}
// ❌ [학생 삭제 API 연동용 함수]
Future<void> _deleteStudentAccount(String studentId) async {
setState(() => _isWorking = true);
final url = Uri.parse('$baseUrl/api/users/delete/$studentId');
try {
final response = await http.delete(url);
if (response.statusCode == 200) {
final resData = jsonDecode(utf8.decode(response.bodyBytes));
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text('✅ ${resData['message']}')));
} else {
ScaffoldMessenger.of(
context,
).showSnackBar(const SnackBar(content: Text('❌ 삭제 실패하였습니다.')));
}
} catch (e) {
ScaffoldMessenger.of(
context,
).showSnackBar(const SnackBar(content: Text('❌ 서버 에러 발생')));
} finally {
setState(() => _isWorking = false);
}
}
// 🚨 계정 삭제 확인 팝업창 모달
void _showDeleteDialog() {
final TextEditingController deleteIdController = TextEditingController();
showDialog(
context: context,
builder: (context) {
return AlertDialog(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(24),
),
title: Row(
children: [
Icon(Icons.warning_amber_rounded, color: Colors.orange[700]),
const SizedBox(width: 10),
const Text(
'학생 계정 강제 삭제',
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 18),
),
],
),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Text(
'영구 삭제할 학생의 학번을 정확하게 입력하세요.',
style: TextStyle(color: Colors.black54, fontSize: 13),
),
const SizedBox(height: 16),
TextField(
controller: deleteIdController,
keyboardType: TextInputType.number,
decoration: InputDecoration(
labelText: '학번 입력',
labelStyle: TextStyle(color: Colors.orange[700]),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(16),
borderSide: BorderSide(
color: Colors.orange[700]!,
width: 2,
),
),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(16),
),
prefixIcon: const Icon(Icons.no_accounts_rounded),
),
),
],
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('취소', style: TextStyle(color: Colors.grey)),
),
ElevatedButton(
onPressed: () {
final inputId = deleteIdController.text.trim();
if (inputId.isNotEmpty) {
Navigator.pop(context);
_deleteStudentAccount(inputId);
}
},
style: ElevatedButton.styleFrom(
backgroundColor: Colors.orange[700],
foregroundColor: Colors.white,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
child: const Text(
'삭제 확정',
style: TextStyle(fontWeight: FontWeight.bold),
),
),
],
);
},
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Colors.grey[100],
appBar: AppBar(
title: const Text(
'⚙️ 학생 통합 관리 센터',
style: TextStyle(fontWeight: FontWeight.bold),
),
backgroundColor: Colors.orange,
foregroundColor: Colors.white,
elevation: 0,
),
body: SingleChildScrollView(
padding: const EdgeInsets.all(24.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 🏷️ 인디케이터 바 1 (추가 메뉴)
Row(
children: [
Container(
width: 4,
height: 16,
decoration: BoxDecoration(
color: Colors.orange,
borderRadius: BorderRadius.circular(2),
),
),
const SizedBox(width: 8),
const Text(
'신규 학생 계정 추가',
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
),
],
),
const SizedBox(height: 16),
// 📝 학생 추가 컨테이너 폼
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: [
TextField(
controller: _addIdController,
keyboardType: TextInputType.number,
decoration: const InputDecoration(
labelText: '학번',
prefixIcon: Icon(Icons.badge),
),
),
const SizedBox(height: 12),
TextField(
controller: _addNameController,
decoration: const InputDecoration(
labelText: '이름',
prefixIcon: Icon(Icons.person),
),
),
const SizedBox(height: 12),
TextField(
controller: _addPwController,
obscureText: true,
decoration: const InputDecoration(
labelText: '초기 비밀번호',
prefixIcon: Icon(Icons.lock),
),
),
const SizedBox(height: 20),
SizedBox(
width: double.infinity,
height: 50,
child: ElevatedButton(
onPressed: _isWorking ? null : _addStudentAccount,
style: ElevatedButton.styleFrom(
backgroundColor: Colors.orange,
foregroundColor: Colors.white,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
child: _isWorking
? const CircularProgressIndicator(color: Colors.white)
: const Text(
'학생 등록 완료',
style: TextStyle(
fontWeight: FontWeight.bold,
fontSize: 15,
),
),
),
),
],
),
),
const SizedBox(height: 36),
// 🏷️ 인디케이터 바 2 (삭제 권한 제어메뉴)
Row(
children: [
Container(
width: 4,
height: 16,
decoration: BoxDecoration(
color: Colors.red,
borderRadius: BorderRadius.circular(2),
),
),
const SizedBox(width: 8),
const Text(
'위험 구역 (Account Reset)',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
color: Colors.redAccent,
),
),
],
),
const SizedBox(height: 16),
// 🚨 학생 삭제 트리거 카드
InkWell(
onTap: _showDeleteDialog,
borderRadius: BorderRadius.circular(24),
child: Container(
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: Colors.red[50],
borderRadius: BorderRadius.circular(24),
border: Border.all(color: Colors.red.shade200),
),
child: const Row(
children: [
Icon(
Icons.delete_sweep_rounded,
color: Colors.red,
size: 32,
),
SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'학생 계정 강제 원격 삭제',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: Colors.red,
),
),
SizedBox(height: 2),
Text(
'인증 초기화 및 DB 제거용',
style: TextStyle(
fontSize: 12,
color: Colors.redAccent,
),
),
],
),
),
Icon(
Icons.arrow_forward_ios_rounded,
color: Colors.red,
size: 16,
),
],
),
),
),
],
),
),
);
}
}