24 Commits
Author SHA1 Message Date
sihooandClaude Sonnet 5 ccff219874 선생님 대시보드에 학년 필터 칩 추가
출석자/미출석자 필터처럼 학년 전체/1학년/2학년/3학년 칩을 추가해서
선택한 학년만 볼 수 있게 함. 상태 필터와 조합해서 같이 적용됨.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-31 01:07:38 +09:00
sihooandClaude Sonnet 5 94ba3efff7 선생님/마스터 계정의 학생 계정 관리 화면을 하나로 통합
- 두 화면이 서로 다른 기능만 갖고 있던 문제 해결: 선생님 화면(엑셀 일괄등록,
  학년 지정, 커스텀 초기비번)에 마스터 화면에만 있던 실시간 학생 목록 조회 +
  기기 리셋(학생이 폰 바꿨을 때 재등록용) 기능을 합침
- 학번을 직접 타이핑하던 블라인드 학년변경/삭제 다이얼로그를 목록의
  행별 액션(학년 칩, 기기 리셋 버튼, 삭제 버튼)으로 교체
- 계정 생성/삭제/학년변경 성공 시 목록 자동 새로고침
- 마스터(2061) 계정의 "학생 계정 관리" 카드도 이 통합 화면으로 연결하고,
  중복되던 student_management_screen.dart / student_management_controller.dart 삭제

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-30 01:23:46 +09:00
sihooandClaude Sonnet 5 a622249008 학생 계정 엑셀 일괄 등록 기능 추가 (신입생 등)
- 학생 계정 관리 화면에 드래그앤드롭/클릭 업로드 영역 추가
  (excel, desktop_drop, file_picker 패키지 도입)
- .xlsx 파싱 → 미리보기 팝업 → 확인 후 한 명씩 순서대로 계정 생성,
  결과(성공/실패)를 요약해서 보여줌. 초기 비밀번호는 1234 고정
- file_picker의 웹 구현체가 pickFile(s)를 아직 구현하지 않아서
  (UnimplementedError) 웹에서는 package:web으로 직접 <input type=file>을
  다뤄서 우회 (lib/function/excel_file_picker_web.dart), 다른 플랫폼은
  기존 file_picker 사용 (excel_file_picker_io.dart)

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-29 22:44:40 +09:00
sihooandClaude Sonnet 5 db903fcdef 기존 학생 계정 학년 지정/변경 기능 추가
- 학생 계정 관리 화면에 학번으로 학년을 지정/변경하는 카드+다이얼로그 추가
- 계정 생성 성공 판정을 HTTP 상태코드가 아닌 응답 status 필드로 정확히 확인하도록 수정

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-28 22:05:36 +09:00
sihooandClaude Sonnet 5 d1c59c521b 선생님 대시보드 학년별 카테고리 분류 + 학생 계정 관리 버그 수정
- 실시간 출석 현황을 학년별 섹션으로 묶어서 표시 (백엔드에 grade 필드 추가)
- 학생 계정 추가 폼에 학년 선택 드롭다운 추가
- 학생 계정 생성/삭제가 실제로는 존재하지 않는 엔드포인트를 호출하던
  버그 수정 (/api/users/register-student, /api/users/delete/{id} →
  /api/users/create, /api/users/delete)
- 학생 대시보드(개발자 모드) 카드 그리드가 넓은 화면에서 과도하게
  커지던 레이아웃 버그 수정, 웹은 5열/폰은 2열로 반응형 처리
- 버전 1.2.0+6

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-28 16:29:40 +09:00
sihooandClaude Sonnet 5 9354e2998a 아이폰 근접센서 주머니 감시, 학생 목록 타일 UI, 학교 서버 주소 반영
- iOS는 조도 센서/백그라운드 서비스가 불가능해서 근접센서(UIDevice
  proximityMonitoring) + 가이드 접근 안내로 대체 구현 (Core NFC와 마찬가지로
  네이티브 플러그인 필요해서 ProximityMonitorPlugin.swift 추가)
- 교사 대시보드 실시간 출석 현황: 모바일 리스트/데스크톱 표를 타일 그리드로
  통일해서 한눈에 보기 쉽게 변경
- 서버를 학교 자체 서버(Cloudflare Tunnel, api.backsanhi.shop)로 이전하면서
  기본 BASE_URL 갱신
- Firebase CLI 로컬 캐시(.firebase/)는 소스가 아니라 gitignore 처리

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-27 21:50:38 +09:00
sihoo f9fa05f817 학교별 배포를 위해 서버주소/학교이름을 빌드타임 설정으로 전환
- config.dart의 baseUrl/webVapidKey와 학교 이름을 String.fromEnvironment로 전환
- 기본값은 기존 백산고 값 그대로라 인자 없이 빌드하면 지금과 동일하게 동작
- 다른 학교는 --dart-define=BASE_URL=...=SCHOOL_NAME=...=WEB_VAPID_KEY=...로 빌드
2026-08-06 14:36:10 +09:00
sihoo 26d143d713 안드로이드 Gradle 빌드 폴더 git 추적 제외
- android/build/ 아래 자동생성 리포트(HTML)가 실수로 커밋되어 있던 것을 제거
- .gitignore에 android/**/build/, ios/**/build/ 추가해서 재발 방지
  (Gitea 언어 통계가 HTML 위주로 잘못 표시되던 원인)
2026-08-05 15:07:10 +09:00
sihoo 29e319110c lib/ 코드를 UI(lib/ui/)와 기능(lib/function/)으로 분리
- 화면마다 위젯/스타일만 담당하는 UI 파일과, 서버통신/상태/파생로직만 담당하는
  ChangeNotifier 컨트롤러 파일로 1:1 분리 (lib/screens/ -> lib/ui/ + lib/function/)
- UI는 ListenableBuilder로 컨트롤러를 구독해서 재렌더링, 버튼은 컨트롤러 메서드만 호출
- 다이얼로그/스낵바 등 위젯 코드는 전부 UI 파일에 남기고, 컨트롤러는 결과값(성공여부+메시지)
  또는 콜백으로만 UI와 통신 (BuildContext/Widget 의존성 없음)
- 디자인/레이아웃은 기존과 완전히 동일하게 유지 (순수 코드 재배치)
- change_password_screen.dart, debug_pocket_main.dart(미사용 파일)는 깨지지 않게 import
  경로만 갱신하고 리팩터링은 보류
2026-08-05 15:04:24 +09:00
sihoo dca7912f8d 로그인 화면 아이디/비밀번호 입력칸 폭 제한 (데스크톱 웹에서 너무 넓게 늘어지던 문제 수정) 2026-08-05 14:07:06 +09:00
sihoo 4e12b279e2 선생님 정상 로그인 시 대시보드 이름 표시 버그 수정 + 앱 버전 1.1.4
- 로그인 성공 후 TeacherDashboard로 이동할 때 실제 이름/학번을 안 넘겨줘서
  항상 "간편인증 선생"으로 표시되던 문제 수정
2026-08-05 14:01:50 +09:00
sihoo d864927957 전체 반출 허용 중일 때 대시보드에 배너 표시 추가 2026-08-04 14:55:22 +09:00
sihoo 61aedac312 한 번도 태깅 안 한 학생은 미제출 대신 미등록으로 표시 2026-08-04 14:47:15 +09:00
sihoo 5ab1c4c37c 하교 처리 후 무단반출 오탐 버그 수정 + 앱 버전 1.1.3
- 조도 센서가 폰 반출을 감지하면, 위반 경고를 울리기 전에 서버에 반출 허용 상태(하교/허용시간대)를 먼저 확인
- 허용된 상태면 위반 경고 없이 조용히 정상 회수로 처리
2026-08-04 14:36:38 +09:00
sihoo 3a62ebacd8 출석/무단반출 팝업 중복 표시 버그 수정 + 앱 버전 1.1.2
- 새 팝업(출석완료/무단반출)을 띄우기 전 이전에 열려있던 팝업을 먼저 닫도록 수정
- 재태깅이 짧은 간격으로 반복되면 팝업이 여러 개 겹쳐 쌓이던 문제 해결
2026-08-04 14:24:35 +09:00
sihoo 26fb4c32cf 테스트용 허용시간 초기화 디버그 버튼 추가 2026-08-04 14:15:20 +09:00
sihoo 4137a6815c 자습실 출석시간 지정 기능 추가 + 앱 버전 1.1.1
- 대시보드에서 자습실 출석시간(HH:MM) 지정 가능, 하교 시 자동 초기화
- 지정 시간 이전 태깅은 "출석 미완료", 이후는 시간 강조 표시로 구분
2026-08-04 14:01:50 +09:00
sihooandClaude Sonnet 5 4ff063c76d 하교 처리 시 출석 현황 표시도 함께 초기화
하교 버튼을 누르면 반출 허용뿐 아니라, 그 시각을 기준선으로 삼아 대시보드의
"출석 완료" 표시도 미제출로 초기화되도록 함. 실제 출석 기록(attendance 테이블)은
삭제하지 않고, 대시보드가 "오늘 출석"으로 인정하는 기준 시각만 옮기는 방식이라
데이터 손실 없이 표시만 리셋된다.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-04 01:25:20 +09:00
sihooandClaude Sonnet 5 77688170b7 하교 버튼 추가 + 무단반출 후 재태깅 시 재출석 처리 + 테스트용 감시종료 버튼 제거
- 선생님 대시보드에 "하교" 버튼 추가: 시간 입력 없이 바로 전체 학생 반출 허용
  (실수 클릭 방지를 위해 확인 다이얼로그 한 번 거침)
- 무단반출이 감지되면 클라이언트의 감시 상태를 해제해서, 다음 태깅이
  체크아웃이 아니라 새 출석으로 처리되도록 수정
- 주머니 감시 화면에 있던 "폰 회수하고 감시 종료 (테스트용)" 버튼 제거
  (정식 플로우는 재태깅으로만 체크아웃/재출석 처리)

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-04 01:08:48 +09:00
sihooandClaude Sonnet 5 c17a28bad9 새로고침 버튼에 로딩 표시 추가
버튼을 눌러도 아무 반응이 없어 보인다는 피드백 반영 - 클릭 시 아이콘이
잠깐 스피너로 바뀌도록 해서 실제로 새로고침이 진행 중임을 알 수 있게 함.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-04 00:52:26 +09:00
sihooandClaude Sonnet 5 9adaba10cd 교사 대시보드에 새로고침 버튼 추가 + 웹 배포 캐시 무효화 설정
- TeacherAttendancePage 앱바에 수동 새로고침 버튼 추가
- Firebase Hosting에서 index.html/flutter_service_worker.js/version.json을
  no-cache로 설정해서, 재배포 후 브라우저가 예전 버전을 계속 들고 있는 문제 방지

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-04 00:42:15 +09:00
sihoo 77ef666e16 Merge README update from remote 2026-08-04 00:33:47 +09:00
sihooandClaude Sonnet 5 c8ed8f2fd2 교사 데스크톱 대시보드 + 무단반출 허용 기능 추가
- 실시간 출석 현황 화면(TeacherAttendancePage)에 데스크톱(웹 브라우저 800px+) 전용
  레이아웃 추가: 통계 타일 3개 + 정식 DataTable. 교사 대시보드 메뉴 그리드도
  데스크톱에서 카드가 지나치게 커지지 않도록 최대 너비 제한
- Firebase Hosting에 웹 빌드 배포 설정 추가 (firebase.json hosting 섹션),
  https://school-display-ff28f.web.app 로 선생님이 브라우저에서 바로 접속 가능
- 무단 반출(/api/violations/active) 감지 시 대시보드에 빨간 표시 + "반출 허용" 버튼 추가
- 선생님이 개별 학생에게 N분간 반출 허용(/api/violations/allow), 또는 전체 학생에게
  한 번에 반출 허용 시간대 설정(/api/permissions/window, 쉬는시간 등) 가능하도록 연동

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-04 00:32:30 +09:00
sihoo 49ab432fe9 README.md 업데이트 2026-08-03 14:15:51 +00:00
48 changed files with 4926 additions and 3641 deletions
+7
View File
@@ -43,3 +43,10 @@ app.*.map.json
/android/app/debug
/android/app/profile
/android/app/release
# Gradle build output (실수로 커밋되면 리포트 HTML 등 큰 파일이 섞여 들어옴)
android/**/build/
ios/**/build/
# Firebase CLI 로컬 캐시 (배포 이력 해시 등, 소스 아님)
.firebase/
+2 -1
View File
@@ -1,6 +1,7 @@
# school_attendance
A new Flutter project.
학교생활 편의 프로젝트
## Getting Started
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -1 +1 @@
{"flutter":{"platforms":{"android":{"default":{"projectId":"school-display-ff28f","appId":"1:137322214849:android:2595999513206bdeb16b40","fileOutput":"android/app/google-services.json"}},"dart":{"lib/firebase_options.dart":{"projectId":"school-display-ff28f","configurations":{"android":"1:137322214849:android:2595999513206bdeb16b40","web":"1:137322214849:web:0423e5c788d95761b16b40"}}}}}}
{"flutter":{"platforms":{"android":{"default":{"projectId":"school-display-ff28f","appId":"1:137322214849:android:2595999513206bdeb16b40","fileOutput":"android/app/google-services.json"}},"dart":{"lib/firebase_options.dart":{"projectId":"school-display-ff28f","configurations":{"android":"1:137322214849:android:2595999513206bdeb16b40","web":"1:137322214849:web:0423e5c788d95761b16b40"}}}}},"hosting":{"public":"build/web","ignore":["firebase.json","**/.*"],"rewrites":[{"source":"**","destination":"/index.html"}],"headers":[{"source":"/index.html","headers":[{"key":"Cache-Control","value":"no-cache, no-store, must-revalidate"}]},{"source":"/flutter_service_worker.js","headers":[{"key":"Cache-Control","value":"no-cache, no-store, must-revalidate"}]},{"source":"/version.json","headers":[{"key":"Cache-Control","value":"no-cache, no-store, must-revalidate"}]}]}}
+4
View File
@@ -11,6 +11,7 @@
331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; };
3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; };
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; };
5FA389C5018E5592CC402E5C /* ProximityMonitorPlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = E653BC1DC10E8731479786F7 /* ProximityMonitorPlugin.swift */; };
7884E8682EC3CC0700C636F2 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */; };
78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */ = {isa = PBXBuildFile; productRef = 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */; };
97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; };
@@ -49,6 +50,7 @@
3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = "<group>"; };
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = "<group>"; };
74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
E653BC1DC10E8731479786F7 /* ProximityMonitorPlugin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProximityMonitorPlugin.swift; sourceTree = "<group>"; };
7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = "<group>"; };
78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = FlutterGeneratedPluginSwiftPackage; path = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; sourceTree = "<group>"; };
7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = "<group>"; };
@@ -122,6 +124,7 @@
1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */,
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */,
74858FAE1ED2DC5600515810 /* AppDelegate.swift */,
E653BC1DC10E8731479786F7 /* ProximityMonitorPlugin.swift */,
7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */,
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */,
);
@@ -282,6 +285,7 @@
buildActionMask = 2147483647;
files = (
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */,
5FA389C5018E5592CC402E5C /* ProximityMonitorPlugin.swift in Sources */,
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */,
7884E8682EC3CC0700C636F2 /* SceneDelegate.swift in Sources */,
);
+3
View File
@@ -12,5 +12,8 @@ import UIKit
func didInitializeImplicitFlutterEngine(_ engineBridge: FlutterImplicitEngineBridge) {
GeneratedPluginRegistrant.register(with: engineBridge.pluginRegistry)
ProximityMonitorPlugin.register(
with: engineBridge.pluginRegistry.registrar(forPlugin: "ProximityMonitorPlugin")
)
}
}
+63
View File
@@ -0,0 +1,63 @@
// 🛡️ iOS 근접센서로 "주머니 감시"를 흉내내기 위한 네이티브 플러그인.
// UIDevice의 근접센서를 켜면 통화 중처럼 화면이 자동으로 꺼지고 켜지는데,
// 그 상태 변화(가까움/멀어짐)를 Dart 쪽 EventChannel로 그대로 흘려보낸다.
import Flutter
import UIKit
class ProximityMonitorPlugin: NSObject, FlutterPlugin, FlutterStreamHandler {
private var eventSink: FlutterEventSink?
static func register(with registrar: FlutterPluginRegistrar) {
let instance = ProximityMonitorPlugin()
let methodChannel = FlutterMethodChannel(
name: "proximity_monitor",
binaryMessenger: registrar.messenger()
)
methodChannel.setMethodCallHandler(instance.handle)
let eventChannel = FlutterEventChannel(
name: "proximity_monitor/events",
binaryMessenger: registrar.messenger()
)
eventChannel.setStreamHandler(instance)
}
func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) {
switch call.method {
case "start":
UIDevice.current.isProximityMonitoringEnabled = true
result(nil)
case "stop":
UIDevice.current.isProximityMonitoringEnabled = false
result(nil)
default:
result(FlutterMethodNotImplemented)
}
}
func onListen(withArguments arguments: Any?, eventSink events: @escaping FlutterEventSink) -> FlutterError? {
eventSink = events
NotificationCenter.default.addObserver(
self,
selector: #selector(proximityStateChanged),
name: UIDevice.proximityStateDidChangeNotification,
object: nil
)
return nil
}
func onCancel(withArguments arguments: Any?) -> FlutterError? {
NotificationCenter.default.removeObserver(
self,
name: UIDevice.proximityStateDidChangeNotification,
object: nil
)
eventSink = nil
return nil
}
@objc private func proximityStateChanged() {
eventSink?(UIDevice.current.proximityState)
}
}
+19 -4
View File
@@ -5,13 +5,28 @@ 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 = 'https://backsanhi.mcv.kr';
// 🌐 학교마다 다른 값들. 빌드할 때 --dart-define으로 안 넘기면 지금 쓰는
// 백산고 값이 그대로 기본값이 된다 (기존 빌드 명령이 안 깨지게 하기 위함).
// 예: flutter build web --dart-define=BASE_URL=https://다른학교도메인.com
const String baseUrl = String.fromEnvironment(
'BASE_URL',
defaultValue: 'https://api.backsanhi.shop',
);
// 🔑 웹에서 FCM 토큰을 받으려면 필요한 VAPID 키
// (Firebase Console > 프로젝트 설정 > Cloud Messaging > 웹 구성 > 웹 푸시 인증서에서 발급)
const String webVapidKey =
'BG4LnirukBdW3cXVWxhvY9pcjevBKDhpsaq1SLZdBs2cOEiwLAFyuuZLkMqpEGp9uOuHdPOe_y3FdwdZaz3qdfk';
// 학교마다 Firebase 프로젝트가 다르므로 VAPID 키도 학교별로 --dart-define=WEB_VAPID_KEY=...로 지정.
const String webVapidKey = String.fromEnvironment(
'WEB_VAPID_KEY',
defaultValue:
'BG4LnirukBdW3cXVWxhvY9pcjevBKDhpsaq1SLZdBs2cOEiwLAFyuuZLkMqpEGp9uOuHdPOe_y3FdwdZaz3qdfk',
);
// 🏫 앱/로그인 화면에 표시할 학교 이름. --dart-define=SCHOOL_NAME=...으로 학교별 지정.
const String schoolName = String.fromEnvironment(
'SCHOOL_NAME',
defaultValue: '백산고등학교',
);
// 권한 요청 및 FCM 토큰을 가져와서 콘솔에 출력하는 함수
Future<void> printFCMToken() async {
+1 -1
View File
@@ -1,5 +1,5 @@
import 'package:flutter/material.dart';
import 'nfc_poccket_checkin_screen.dart';
import 'ui/nfc_poccket_checkin_screen.dart';
// 🧪 디버그 전용 진입점: NFC 주머니 체크인 + 조도 센서 감시 화면을 바로 테스트하기 위한 파일.
void main() {
+31
View File
@@ -0,0 +1,31 @@
// 🛠️ 관리자 대시보드의 기능(서버 통신/상태) 담당 컨트롤러.
import 'package:flutter/foundation.dart';
import 'package:http/http.dart' as http;
import '../config.dart';
class AdminController extends ChangeNotifier {
bool _isLoading = false;
bool get isLoading => _isLoading;
Future<String> resetDatabase() async {
_isLoading = true;
notifyListeners();
// 🆕 백엔드 보안 규칙에 맞추어 초기화 토큰 비밀번호 파라미터(?password=...)를 연동 주소에 매칭했습니다.
final url = Uri.parse('$baseUrl/reset-db?password=adminreset2010');
try {
final response = await http.get(url);
if (response.statusCode == 200) {
return '💥 DB가 깔끔하게 초기화되었습니다! (출석 번호 1번부터 시작)';
} else {
return '❌ 초기화 실패: 서버 권한 오류 (${response.statusCode})';
}
} catch (e) {
return '❌ 네트워크 에러: 서버와 연결할 수 없습니다.';
} finally {
_isLoading = false;
notifyListeners();
}
}
}
+4
View File
@@ -0,0 +1,4 @@
// 📄 엑셀 파일 하나를 골라 바이트로 돌려주는 진입점. 실제 구현은 플랫폼별로 갈라진다
// (web은 file_picker의 웹 구현체에 pickFile(s)가 아직 없어서 dart:html로 직접 처리).
export 'excel_file_picker_io.dart'
if (dart.library.html) 'excel_file_picker_web.dart';
+13
View File
@@ -0,0 +1,13 @@
// 📄 [비웹 플랫폼] file_picker로 엑셀 파일 하나를 고른다.
import 'dart:typed_data';
import 'package:file_picker/file_picker.dart';
/// 사용자가 엑셀(.xlsx) 파일을 고르면 바이트를 돌려준다. 취소하면 null.
Future<Uint8List?> pickExcelFileBytes() async {
final files = await FilePicker.pickFiles(
type: FileType.custom,
allowedExtensions: ['xlsx'],
);
if (files.isEmpty) return null;
return files.first.readAsBytes();
}
+31
View File
@@ -0,0 +1,31 @@
// 📄 [웹 전용] file_picker의 웹 구현체에 pickFile(s)가 아직 없어서(UnimplementedError),
// 숨겨진 <input type="file">를 직접 만들어 처리한다.
import 'dart:async';
import 'dart:js_interop';
import 'dart:typed_data';
import 'package:web/web.dart' as web;
/// 사용자가 엑셀(.xlsx) 파일을 고르면 바이트를 돌려준다. 취소하면 null.
Future<Uint8List?> pickExcelFileBytes() async {
final completer = Completer<web.File?>();
final input = web.HTMLInputElement()
..type = 'file'
..accept = '.xlsx'
..style.display = 'none';
// 문서에 실제로 붙어있지 않으면 일부 브라우저/자동화 환경에서 change 이벤트가
// 안정적으로 안 잡힌다 — 그래서 body에 잠깐 붙였다가 끝나면 반드시 제거한다.
web.document.body!.append(input);
input.onchange = (web.Event event) {
final file = input.files?.item(0);
input.remove();
completer.complete(file);
}.toJS;
input.click();
final file = await completer.future;
if (file == null) return null;
final buffer = await file.arrayBuffer().toDart;
return buffer.toDart.asUint8List();
}
+211
View File
@@ -0,0 +1,211 @@
// 🔑 로그인 화면의 기능(서버 통신/인증 분기 로직) 담당 컨트롤러.
// 위젯/BuildContext/다이얼로그 코드는 없다 — 전부 UI 파일(lib/screens/login_screen.dart) 몫.
import 'dart:convert';
import 'package:flutter/foundation.dart' show ChangeNotifier, kIsWeb;
import 'package:http/http.dart' as http;
import 'package:firebase_messaging/firebase_messaging.dart';
import '../config.dart';
enum LoginOutcome { masterSuccess, needsPasswordChange, success, error }
/// 로그인 시도 결과. UI는 [outcome]만 보고 어떤 화면/다이얼로그를 띄울지 분기한다.
class LoginResult {
final LoginOutcome outcome;
final String? role;
final String? studentId;
final String? name;
final bool isDeviceMatched;
final String? errorMessage;
const LoginResult.masterSuccess({
required this.studentId,
required this.name,
}) : outcome = LoginOutcome.masterSuccess,
role = null,
isDeviceMatched = true,
errorMessage = null;
const LoginResult.needsPasswordChange({
required this.studentId,
required this.name,
required this.role,
required this.isDeviceMatched,
}) : outcome = LoginOutcome.needsPasswordChange,
errorMessage = null;
const LoginResult.success({
required this.role,
required this.studentId,
required this.name,
required this.isDeviceMatched,
}) : outcome = LoginOutcome.success,
errorMessage = null;
const LoginResult.error(this.errorMessage)
: outcome = LoginOutcome.error,
role = null,
studentId = null,
name = null,
isDeviceMatched = false;
}
class LoginController extends ChangeNotifier {
bool _isLoading = false;
bool get isLoading => _isLoading;
Future<LoginResult> login(String id, String password) async {
if (id.isEmpty || password.isEmpty) {
return const LoginResult.error('⚠️ 학번과 비밀번호를 모두 입력해 주세요.');
}
// 🔥 [개발자 마스터 계정]
if (id == "2061") {
if (password == "happy9642!") {
return const LoginResult.masterSuccess(
studentId: "2061",
name: "훗춧가룻",
);
} else {
return const LoginResult.error('개발자 계정의 마스터 비밀번호가 올바르지 않습니다.');
}
}
_isLoading = true;
notifyListeners();
try {
String deviceUuid = await getDeviceUuid();
String fcmToken = "";
try {
fcmToken = kIsWeb
? await FirebaseMessaging.instance.getToken(
vapidKey: webVapidKey,
) ??
""
: await FirebaseMessaging.instance.getToken() ?? "";
} catch (e) {
// 파이어베이스 미설정 등 - 로그인 자체는 계속 진행
}
final response = await http.post(
Uri.parse('$baseUrl/login'),
headers: {"Content-Type": "application/json"},
body: jsonEncode({
"studentId": id,
"password": password,
"deviceUuid": deviceUuid,
"fcmToken": fcmToken,
}),
);
final resData = jsonDecode(utf8.decode(response.bodyBytes));
if (response.statusCode == 200) {
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() ??
id;
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() ?? id;
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) {
return LoginResult.needsPasswordChange(
studentId: studentId,
name: name,
role: role,
isDeviceMatched: isDeviceMatched,
);
}
return LoginResult.success(
role: role,
studentId: studentId,
name: name,
isDeviceMatched: isDeviceMatched,
);
} else {
String errorMsg = '로그인에 실패했습니다.';
if (resData is Map) {
errorMsg =
resData['detail']?.toString() ??
resData['message']?.toString() ??
errorMsg;
}
return LoginResult.error(errorMsg);
}
} catch (e) {
return LoginResult.error('서버와 연결할 수 없습니다. 서버 상태를 확인하세요!\n($e)');
} finally {
_isLoading = false;
notifyListeners();
}
}
/// 🛠️ 초기 비밀번호 변경. (성공여부, 메시지)를 반환한다.
Future<(bool success, String message)> changePassword({
required String studentId,
required String newPassword,
}) async {
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));
if (response.statusCode == 200 && resData['status'] == 'success') {
return (true, '✅ 비밀번호가 변경되었습니다!');
} else {
return (false, '❌ 실패: ${resData['message']}');
}
} catch (e) {
return (false, '서버 에러 발생: $e');
}
}
}
@@ -0,0 +1,387 @@
// 📲 자습실 NFC 출석체크 화면의 기능(NFC 세션/서버 통신/백그라운드 감시 연동) 담당 컨트롤러.
// 다이얼로그/스낵바 등 실제 위젯 표시는 UI 파일(lib/nfc_poccket_checkin_screen.dart)의
// 콜백을 통해서만 이루어진다 — 이 파일엔 위젯 코드가 없다.
import 'dart:async';
import 'dart:convert';
import 'dart:io' show Platform;
import 'package:flutter/foundation.dart' show ChangeNotifier, kIsWeb;
import 'package:flutter/services.dart' show EventChannel, MethodChannel;
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/nfc_manager_android.dart';
import 'package:nfc_manager_ndef/nfc_manager_ndef.dart';
import 'package:permission_handler/permission_handler.dart';
import '../config.dart' show baseUrl;
// 🛡️ iOS 근접센서 감시용 네이티브 채널 (ios/Runner/ProximityMonitorPlugin.swift 참고).
// 조도 센서 API가 없는 iOS에서, 통화 중 화면이 꺼지는 것과 같은 원리인
// 근접센서로 "주머니 안/밖" 상태 변화를 감지해 대체한다.
const MethodChannel _proximityMethodChannel = MethodChannel('proximity_monitor');
const EventChannel _proximityEventChannel = EventChannel('proximity_monitor/events');
enum WatchMessageLevel { info, success, warning, error }
class NfcPocketCheckinController extends ChangeNotifier {
final String studentId;
final String studentName;
/// 스낵바로 보여줄 일반 메시지 이벤트. UI가 레벨에 맞는 색상을 정해서 띄운다.
final void Function(String text, WatchMessageLevel level) onMessage;
/// 출석(태깅) 성공 시 축하 팝업을 띄우기 위한 콜백.
final void Function(String pocketNumber) onCheckInSuccess;
/// 무단반출 감지 시 경고 팝업을 띄우기 위한 콜백.
final void Function(String? pocketNumber) onViolationDetected;
/// iOS 출석 성공 직후, "가이드 접근"을 켜달라고 안내하는 팝업을 띄우기 위한 콜백.
/// (안드로이드는 화면이 꺼져도 백그라운드 서비스가 동작하니 이 안내가 필요 없다.)
final void Function(String pocketNumber) onNeedGuidedAccessSetup;
NfcPocketCheckinController({
required this.studentId,
required this.studentName,
required this.onMessage,
required this.onCheckInSuccess,
required this.onViolationDetected,
required this.onNeedGuidedAccessSetup,
});
bool _isProcessing = false; // 중복 태깅 및 연타 방지 플래그
bool _isWatching = false; // 백그라운드 조도 센서(또는 iOS 근접센서) 감시가 진행 중인지 여부
bool _isIosProximityWatch = false; // 지금 진행 중인 감시가 iOS 근접센서 방식인지 여부 (체크아웃 분기용)
String _statusMessage = "주머니의 NFC 스티커에 폰 뒷면을 대어주세요.";
String? _activePocketNumber;
StreamSubscription<Map<String, dynamic>?>? _violationSub;
StreamSubscription<Map<String, dynamic>?>? _checkedOutSub;
StreamSubscription<dynamic>? _proximitySub;
bool? _proximityBaselineNear; // 감시 시작 직후(주머니에 넣은 직후) 기준 상태
bool _proximityViolated = false;
bool get isProcessing => _isProcessing;
bool get isWatching => _isWatching;
String get statusMessage => _statusMessage;
String? get activePocketNumber => _activePocketNumber;
bool get watchServiceSupported => !kIsWeb && Platform.isAndroid;
/// iOS는 진짜 백그라운드 감시가 불가능해서, "가이드 접근" 모드로 화면을 못 잠그게 한 뒤
/// 근접센서로 흉내내는 방식만 지원한다 (자세한 이유는 사용자와의 대화 참고).
bool get iosProximityWatchSupported => !kIsWeb && Platform.isIOS;
void init() {
_startNfcSession();
if (watchServiceSupported) {
// 화면이 열려있는 동안은 실시간으로 위반 알림을 받아 팝업을 띄운다.
// 무단 반출이 한 번 감지되면 "신뢰된 감시 세션"은 끝난 것으로 보고,
// 다음 태깅은 체크아웃이 아니라 새 출석(재출석)으로 처리되도록 감시 상태를 해제한다.
_violationSub = FlutterBackgroundService().on('violation_detected').listen((
event,
) {
_isWatching = false;
notifyListeners();
onViolationDetected(event?['pocketNumber']?.toString());
});
// 🏫 하교 처리 등으로 반출이 허용된 상태에서 폰을 꺼내면, 위반이 아니라 정상 회수로 처리된다.
_checkedOutSub = FlutterBackgroundService().on('checked_out').listen((
event,
) {
_isWatching = false;
_activePocketNumber = null;
_statusMessage = "✅ 폰을 회수했습니다. 수고하셨습니다!";
notifyListeners();
onMessage("✅ 폰이 정상적으로 회수되었습니다.", WatchMessageLevel.success);
});
}
}
@override
void dispose() {
// 화면을 나갈 때 NFC 감지만 종료. 백그라운드 감시 서비스는 화면과 무관하게 계속 동작해야 하므로 건드리지 않는다.
NfcManager.instance.stopSession();
_violationSub?.cancel();
_checkedOutSub?.cancel();
// iOS 근접센서 감시는 (안드로이드와 달리) 이 컨트롤러 안에서 직접 도는 것이라,
// 화면을 나가면 같이 정리해야 한다 (안 그러면 dispose된 ChangeNotifier에 notifyListeners 호출로 에러남).
_proximitySub?.cancel();
if (_isIosProximityWatch) {
_proximityMethodChannel.invokeMethod('stop').catchError((_) {});
}
super.dispose();
}
/// 📡 NFC 감지 세션 시작
void _startNfcSession() async {
bool isAvailable = await NfcManager.instance.isAvailable();
if (!isAvailable) {
_statusMessage = "❌ 이 스마트폰은 NFC 기능이 꺼져있거나 지원되지 않습니다.";
notifyListeners();
return;
}
NfcManager.instance.startSession(
pollingOptions: {NfcPollingOption.iso14443, NfcPollingOption.iso15693},
onDiscovered: (NfcTag tag) async {
if (_isProcessing) return; // 이미 처리 중이면 연속 태그 무시 (쿨다운)
// 이미 감시 중일 때 다시 태깅하면 "폰 회수(체크아웃)"로 처리한다.
if (_isWatching) {
await _checkOut();
return;
}
_isProcessing = true;
_statusMessage = "⏳ 태그 인식 완료! 서버에 출석 전송 중...";
notifyListeners();
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. 파이썬 서버로 출석 정보 보내기 (복제 방지용 태그 UID도 함께 전송)
await _sendAttendanceToBackend(
cleanPocketNumber,
tagUid: _getTagUid(tag),
);
} catch (e) {
onMessage("❌ 태그 읽기 실패: $e", WatchMessageLevel.error);
} finally {
// 3초 후 연타 방지 해제 (쿨다운)
await Future.delayed(const Duration(seconds: 3));
_isProcessing = false;
notifyListeners();
}
},
);
}
/// 태그의 공장 각인 하드웨어 UID를 16진수 문자열로 반환한다 (쓰기로 바꿀 수 없어 복제 방지 기준으로 씀).
String? _getTagUid(NfcTag tag) {
final id = NfcTagAndroid.from(tag)?.id;
if (id == null || id.isEmpty) return null;
return id
.map((b) => b.toRadixString(16).padLeft(2, '0'))
.join()
.toUpperCase();
}
/// 🚚 파이썬 백엔드로 출석 데이터 HTTP POST 전송
Future<void> _sendAttendanceToBackend(
String pocketNumber, {
String? tagUid,
}) async {
final url = Uri.parse("$baseUrl/attendance");
try {
final response = await http.post(
url,
headers: {"Content-Type": "application/json"},
body: jsonEncode({
"studentId": studentId,
"studentName": studentName,
"pocketNumber": pocketNumber, // 주머니 번호 전달
"tagUid": tagUid, // 🔒 서버가 태그 진위를 대조할 하드웨어 UID
}),
);
if (response.statusCode == 200) {
final result = jsonDecode(utf8.decode(response.bodyBytes));
if (result["status"] == "success") {
onCheckInSuccess(pocketNumber);
if (watchServiceSupported) {
await _startBackgroundWatch(pocketNumber);
} else if (iosProximityWatchSupported) {
// iOS는 바로 감시를 못 켜고, 먼저 "가이드 접근"을 켜달라고 안내해야 한다.
onNeedGuidedAccessSetup(pocketNumber);
} else {
onMessage("ℹ️ 이 기기는 백그라운드 감시를 지원하지 않습니다.", WatchMessageLevel.info);
}
} else {
onMessage("⚠️ 출석 실패: ${result['message']}", WatchMessageLevel.warning);
}
} else {
onMessage(
"🚨 서버 에러 (코드: ${response.statusCode})",
WatchMessageLevel.error,
);
}
} catch (e) {
onMessage("❌ 서버 연결 실패: $e", WatchMessageLevel.error);
}
}
// -----------------------------------------------------------------------
// 🛡️ 주머니 제출 모드: 화면이 꺼져도 백그라운드 서비스가 조도 센서로 감시
// -----------------------------------------------------------------------
/// NFC 태깅 성공 직후 호출. 배터리 최적화 예외를 한 번 요청한 뒤 백그라운드 감시 서비스를 시작한다.
Future<void> _startBackgroundWatch(String pocketNumber) async {
if (!watchServiceSupported) 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": studentId,
"studentName": studentName,
"pocketNumber": pocketNumber,
});
_isWatching = true;
_activePocketNumber = pocketNumber;
_statusMessage = "🛡️ 백그라운드에서 감시 중입니다. 화면을 꺼도 계속 감시돼요.";
notifyListeners();
}
/// 감시 중일 때 다시 태깅하면 폰을 회수한 것으로 보고 감시를 종료한다.
Future<void> _checkOut() async {
if (_isIosProximityWatch) {
await _stopIosProximityWatch();
} else {
FlutterBackgroundService().invoke('stop_watch');
}
_isWatching = false;
_activePocketNumber = null;
_statusMessage = "✅ 폰을 회수했습니다. 감시가 종료되었습니다.";
notifyListeners();
onMessage("✅ 감시가 종료되었습니다. 수고하셨습니다!", WatchMessageLevel.success);
}
// -----------------------------------------------------------------------
// 🛡️ iOS 전용: 근접센서 + 가이드 접근으로 흉내내는 감시 모드
//
// iOS는 조도 센서 API가 없고, 앱이 백그라운드로 가면(=화면을 잠그면) 그 즉시
// 서스펜드되어 어떤 센서도 못 읽는다. 그래서 학생이 "가이드 접근"(iOS 키오스크 모드)을
// 직접 켜서 잠금을 막아야 하고, 그 위에서 근접센서로 화면만 통화 중처럼 껐다 켰다
// 하면서(UIDevice.isProximityMonitoringEnabled) 폰이 주머니에서 빠졌는지를 감지한다.
// -----------------------------------------------------------------------
/// UI가 "가이드 접근을 켰다"는 확인을 받은 뒤 호출. 근접센서 감시를 시작한다.
Future<void> beginIosProximityWatch(String pocketNumber) async {
_proximitySub?.cancel();
_proximityBaselineNear = null;
_proximityViolated = false;
_isIosProximityWatch = true;
_activePocketNumber = pocketNumber;
_isWatching = true;
_statusMessage = "📥 주머니에 넣는 중...";
notifyListeners();
try {
await _proximityMethodChannel.invokeMethod('start');
} catch (e) {
onMessage("❌ 근접센서 감시 시작 실패: $e", WatchMessageLevel.error);
_isWatching = false;
_isIosProximityWatch = false;
notifyListeners();
return;
}
// 학생이 폰을 주머니에 넣을 시간을 준 뒤, 그 시점 상태를 기준값으로 삼는다.
await Future.delayed(const Duration(seconds: 4));
_statusMessage = "🛡️ 감시 중입니다. 화면을 잠그지 말고 주머니에 넣어주세요.";
notifyListeners();
_proximitySub = _proximityEventChannel.receiveBroadcastStream().listen((
event,
) {
final bool isNear = event as bool;
if (_proximityBaselineNear == null) {
_proximityBaselineNear = isNear;
return;
}
if (_proximityViolated) return;
if (isNear != _proximityBaselineNear) {
_proximityViolated = true;
_handleIosPhoneRemoved(pocketNumber);
}
});
}
Future<void> _stopIosProximityWatch() async {
await _proximitySub?.cancel();
_proximitySub = null;
_isIosProximityWatch = false;
try {
await _proximityMethodChannel.invokeMethod('stop');
} catch (_) {
// 이미 감시가 꺼져 있어도 상관없으니 조용히 무시.
}
}
/// 하교 처리/반출 허용 시간대라면 조용히 감시만 종료하고, 아니면 위반으로 경고한다.
/// (안드로이드 백그라운드 서비스의 handlePhoneRemoved와 동일한 판정 로직)
Future<void> _handleIosPhoneRemoved(String pocketNumber) async {
await _stopIosProximityWatch();
bool isPermitted = false;
try {
final res = await http.get(
Uri.parse("$baseUrl/api/permissions/check?studentId=$studentId"),
);
if (res.statusCode == 200) {
final data = jsonDecode(utf8.decode(res.bodyBytes));
isPermitted = data['permitted'] == true;
}
} catch (_) {
isPermitted = false; // 네트워크 오류 시엔 안전하게 위반으로 처리
}
_isWatching = false;
if (isPermitted) {
_activePocketNumber = null;
_statusMessage = "✅ 폰을 회수했습니다. 수고하셨습니다!";
notifyListeners();
onMessage("✅ 폰이 정상적으로 회수되었습니다.", WatchMessageLevel.success);
return;
}
notifyListeners();
onViolationDetected(pocketNumber);
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 (_) {
// 서버 신고가 실패해도 학생에게는 이미 팝업으로 알렸으니 조용히 무시.
}
}
}
+141
View File
@@ -0,0 +1,141 @@
// 🏷️ (관리자용) NFC 주머니 태그 쓰기 화면의 기능(NFC 세션/서버 통신/상태) 담당 컨트롤러.
// 실제 스낵바 표시나 텍스트필드 조작은 UI 파일(lib/ui/nfc_tag_writer_screen.dart)의
// 콜백을 통해서만 이루어진다 — 이 파일엔 위젯 코드가 없다.
import 'dart:convert';
import 'dart:typed_data';
import 'package:flutter/foundation.dart';
import 'package:http/http.dart' as http;
import 'package:nfc_manager/nfc_manager.dart';
import 'package:nfc_manager/nfc_manager_android.dart';
import 'package:nfc_manager/ndef_record.dart';
import 'package:nfc_manager_ndef/nfc_manager_ndef.dart';
import '../config.dart';
class NfcTagWriterController extends ChangeNotifier {
/// (메시지, 색상) — 스낵바로 보여줄 이벤트. UI가 정의해서 넘겨준다.
final void Function(String message, bool isError) onMessage;
/// 태그 쓰기에 성공하면 다음 스티커 번호를 제안한다. UI가 텍스트필드를 갱신한다.
final void Function(String nextNumber) onNextNumberSuggested;
NfcTagWriterController({
required this.onMessage,
required this.onNextNumberSuggested,
});
bool _isWriting = false;
String _statusMessage = "주머니 번호를 입력하고 '쓰기 시작'을 눌러주세요.";
bool get isWriting => _isWriting;
String get statusMessage => _statusMessage;
@override
void dispose() {
NfcManager.instance.stopSession();
super.dispose();
}
Future<void> startWriteSession(String number) async {
if (number.isEmpty) {
onMessage("⚠️ 주머니 번호를 입력해주세요.", true);
return;
}
final String pocketLabel = "POCKET_$number";
bool isAvailable = await NfcManager.instance.isAvailable();
if (!isAvailable) {
onMessage("❌ NFC가 꺼져있거나 지원되지 않습니다.", true);
return;
}
_isWriting = true;
_statusMessage = "📡 [$pocketLabel] 쓸 준비 완료! 스티커에 폰 뒷면을 대주세요.";
notifyListeners();
NfcManager.instance.startSession(
pollingOptions: {NfcPollingOption.iso14443, NfcPollingOption.iso15693},
onDiscovered: (NfcTag tag) async {
try {
final ndef = Ndef.from(tag);
if (ndef == null) {
onMessage("❌ 이 태그는 NDEF 쓰기를 지원하지 않는 종류입니다.", true);
return;
}
if (!ndef.isWritable) {
onMessage("❌ 이 태그는 쓰기 잠금(read-only) 상태입니다.", true);
return;
}
await ndef.write(
message: NdefMessage(records: [_createTextRecord(pocketLabel)]),
);
// 🔒 태그 복제 방지: 하드웨어 UID를 서버에 등록해서 이 물리 태그만 [pocketLabel]로 인정되게 한다.
final String? tagUid = _getTagUid(tag);
if (tagUid != null) {
await _registerTagUid(tagUid, pocketLabel);
}
onMessage(
tagUid != null
? "✅ [$pocketLabel] 쓰기 + UID 등록 성공!"
: "⚠️ [$pocketLabel] 쓰기는 성공했지만 UID를 못 읽어 등록은 안 됐습니다.",
tagUid == null,
);
// 다음 스티커를 연달아 쓰기 편하도록 번호를 자동으로 1 올려준다.
final int? n = int.tryParse(number);
if (n != null) onNextNumberSuggested((n + 1).toString());
_statusMessage = "다음 번호를 확인하고 '쓰기 시작'을 다시 눌러주세요.";
notifyListeners();
} catch (e) {
onMessage("❌ 쓰기 실패: $e", true);
} finally {
await NfcManager.instance.stopSession();
_isWriting = false;
notifyListeners();
}
},
);
}
/// 태그의 공장 각인 하드웨어 UID를 16진수 문자열로 반환한다 (쓰기로 바꿀 수 없어 복제 방지 기준으로 씀).
String? _getTagUid(NfcTag tag) {
final id = NfcTagAndroid.from(tag)?.id;
if (id == null || id.isEmpty) return null;
return id
.map((b) => b.toRadixString(16).padLeft(2, '0'))
.join()
.toUpperCase();
}
/// 서버에 "이 UID는 이 주머니 번호다"를 등록한다.
Future<void> _registerTagUid(String tagUid, String pocketLabel) async {
try {
await http.post(
Uri.parse("$baseUrl/api/pockets/register"),
headers: {"Content-Type": "application/json"},
body: jsonEncode({"tagUid": tagUid, "pocketNumber": pocketLabel}),
);
} catch (e) {
onMessage("❌ 서버에 UID 등록 실패: $e", true);
}
}
/// NFC Forum Text Record Type Definition에 맞춰 "언어코드+텍스트" 페이로드를 만든다.
NdefRecord _createTextRecord(String text) {
const languageCode = 'en';
final languageBytes = utf8.encode(languageCode);
final textBytes = utf8.encode(text);
final payload = Uint8List.fromList([
languageBytes.length, // 상태 바이트: UTF-8 + 언어코드 길이
...languageBytes,
...textBytes,
]);
return NdefRecord(
typeNameFormat: TypeNameFormat.wellKnown,
type: Uint8List.fromList([0x54]), // 'T' = Text Record
identifier: Uint8List(0),
payload: payload,
);
}
}
@@ -0,0 +1,33 @@
// 🎓 학생 대시보드(마스터 계정 전용 계정 강제 삭제)의 기능(서버 통신/상태) 담당 컨트롤러.
import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:http/http.dart' as http;
import '../config.dart';
class StudentDashboardController extends ChangeNotifier {
bool _isLoading = false;
bool get isLoading => _isLoading;
/// 👑 마스터 계정 전용 회원 삭제 API 호출.
Future<(bool success, String message)> deleteUser(String userId) async {
_isLoading = true;
notifyListeners();
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);
return (true, '✅ ${responseData['message']}');
} else {
final errorData = jsonDecode(response.body);
return (false, '❌ 삭제 실패: ${errorData['detail'] ?? '알 수 없는 오류'}');
}
} catch (e) {
return (false, '❌ 서버와 연결할 수 없습니다. (네트워크 에러)');
} finally {
_isLoading = false;
notifyListeners();
}
}
}
@@ -0,0 +1,349 @@
// 📋 실시간 출석 현황 화면의 기능(상태/서버 통신/파생 로직) 담당 컨트롤러.
// UI(lib/ui/teacher_attendance_page.dart)는 이 컨트롤러를 구독해서 화면만 그린다.
// 이 파일에는 위젯/BuildContext/다이얼로그 코드가 없어야 한다 — 전부 UI 파일 몫.
import 'dart:async';
import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:http/http.dart' as http;
import '../config.dart';
// 로그의 'name' 컬럼은 백엔드에서 "학생이름 (주머니정보)" 형태로 합쳐져 저장되어 있어서
// 이름과 주머니 번호를 분리해서 보여주려면 클라이언트에서 파싱해야 한다.
final RegExp _logNamePattern = RegExp(r'^(.*?)\s*\(([^)]*)\)$');
class TeacherAttendanceController extends ChangeNotifier {
List<dynamic> _roster = []; // 전체 학생 명단 (/api/users)
List<dynamic> _logs = []; // 출석 로그 (/api/logs)
Map<String, dynamic> _activeViolationsByStudentId =
{}; // 무단반출 중인 학생 (/api/violations/active)
String?
_dismissedAt; // 오늘 가장 최근 하교 처리 시각 (/api/dismissal/latest). 이 시각 이후 기록만 "오늘 출석"으로 표시.
String?
_attendanceTime; // 선생님이 지정한 자습실 출석시간 "HH:MM" (/api/settings/attendance-time). null이면 미설정.
String?
_globalPermissionUntil; // 하교(12시간)/반출허용시간설정으로 전체 반출이 허용된 경우 그 만료 시각 (/api/permissions/status). null이면 비활성.
Timer? _timer;
bool _isLoading = true;
bool _isRefreshing = false; // 새로고침 버튼 클릭 시 잠깐 도는 표시용
String _filterType = "ALL"; // "ALL", "CHECKED_IN", "ABSENT"
String _gradeFilter = "ALL"; // "ALL", "1", "2", "3"
bool _disposed = false;
// ----------------------------------------------------------------
// UI가 읽는 공개 상태
// ----------------------------------------------------------------
bool get isLoading => _isLoading;
bool get isRefreshing => _isRefreshing;
String get filterType => _filterType;
String get gradeFilter => _gradeFilter;
String? get attendanceTime => _attendanceTime;
String? get globalPermissionUntil => _globalPermissionUntil;
void _safeNotify() {
if (!_disposed) notifyListeners();
}
void init() {
fetchAll();
_timer = Timer.periodic(const Duration(seconds: 3), (timer) => fetchAll());
}
@override
void dispose() {
_disposed = true;
_timer?.cancel();
super.dispose();
}
Future<void> manualRefresh() async {
_isRefreshing = true;
_safeNotify();
await fetchAll();
_isRefreshing = false;
_safeNotify();
}
Future<void> fetchAll() async {
try {
final results = await Future.wait([
http.get(Uri.parse('$baseUrl/api/users')),
http.get(Uri.parse('$baseUrl/api/logs')),
http.get(Uri.parse('$baseUrl/api/violations/active')),
http.get(Uri.parse('$baseUrl/api/dismissal/latest')),
http.get(Uri.parse('$baseUrl/api/settings/attendance-time')),
http.get(Uri.parse('$baseUrl/api/permissions/status')),
]);
final usersRes = results[0];
final logsRes = results[1];
final violationsRes = results[2];
final dismissalRes = results[3];
final attendanceTimeRes = results[4];
final permissionStatusRes = results[5];
if (usersRes.statusCode == 200 && logsRes.statusCode == 200) {
final usersData = jsonDecode(utf8.decode(usersRes.bodyBytes));
final logsData = jsonDecode(utf8.decode(logsRes.bodyBytes));
Map<String, dynamic> activeViolations = {};
if (violationsRes.statusCode == 200) {
final violationsData = jsonDecode(
utf8.decode(violationsRes.bodyBytes),
);
for (final v in (violationsData['violations'] ?? [])) {
activeViolations[v['student_id'].toString()] = v;
}
}
String? dismissedAt;
if (dismissalRes.statusCode == 200) {
final dismissalData = jsonDecode(utf8.decode(dismissalRes.bodyBytes));
dismissedAt = dismissalData['dismissedAt'];
}
String? attendanceTime;
if (attendanceTimeRes.statusCode == 200) {
final attendanceTimeData = jsonDecode(
utf8.decode(attendanceTimeRes.bodyBytes),
);
attendanceTime = attendanceTimeData['attendanceTime'];
}
String? globalPermissionUntil;
if (permissionStatusRes.statusCode == 200) {
final permissionStatusData = jsonDecode(
utf8.decode(permissionStatusRes.bodyBytes),
);
if (permissionStatusData['active'] == true) {
globalPermissionUntil = permissionStatusData['permittedUntil'];
}
}
_roster = usersData['users'] ?? [];
_logs = logsData['logs'] ?? [];
_activeViolationsByStudentId = activeViolations;
_dismissedAt = dismissedAt;
_attendanceTime = attendanceTime;
_globalPermissionUntil = globalPermissionUntil;
_isLoading = false;
_safeNotify();
} else {
_isLoading = false;
_safeNotify();
}
} catch (e) {
_isLoading = false;
_safeNotify();
}
}
/// 🏫 하교 처리: 전체 반출 허용 + 오늘 출석 표시 기준선을 지금 시각으로 옮긴다.
Future<String> dismissAll() async {
try {
final response = await http.post(Uri.parse('$baseUrl/api/dismiss'));
final result = jsonDecode(utf8.decode(response.bodyBytes));
await fetchAll();
return result['message'] ?? '하교 처리되었습니다.';
} catch (e) {
return '❌ 하교 처리 실패: $e';
}
}
/// 🚨 선생님이 특정 학생에게 지금부터 N분간 반출을 허용한다.
Future<String> allowRemoval(String studentId, int minutes) async {
try {
final response = await http.post(
Uri.parse('$baseUrl/api/violations/allow'),
headers: {"Content-Type": "application/json"},
body: jsonEncode({"studentId": studentId, "minutes": minutes}),
);
final result = jsonDecode(utf8.decode(response.bodyBytes));
await fetchAll();
return result['message'] ?? '처리되었습니다.';
} catch (e) {
return '❌ 반출 허용 실패: $e';
}
}
/// ⏰ 지금부터 N분간 전체 학생의 반출을 자동으로 허용한다 (쉬는시간 등).
Future<String> setPermissionWindow(int minutes) async {
try {
final response = await http.post(
Uri.parse('$baseUrl/api/permissions/window'),
headers: {"Content-Type": "application/json"},
body: jsonEncode({"minutes": minutes}),
);
final result = jsonDecode(utf8.decode(response.bodyBytes));
await fetchAll();
return result['message'] ?? '처리되었습니다.';
} catch (e) {
return '❌ 설정 실패: $e';
}
}
/// ⏰ 자습실 출석시간(기준 시각)을 지정한다. 이 시각 이후 태깅한 학생만 "출석 완료"로 강조 표시된다.
Future<String> setAttendanceTime(String time) async {
try {
final response = await http.post(
Uri.parse('$baseUrl/api/settings/attendance-time'),
headers: {"Content-Type": "application/json"},
body: jsonEncode({"time": time}),
);
final result = jsonDecode(utf8.decode(response.bodyBytes));
await fetchAll();
return result['message'] ?? '처리되었습니다.';
} catch (e) {
return '❌ 출석시간 설정 실패: $e';
}
}
/// 🧪 테스트용: 하교(12시간)/반출 허용 시간 설정 등으로 켜져 있는 허용 시간대를 즉시 해제한다.
Future<String> resetTestPermissions() async {
try {
final response = await http.post(
Uri.parse('$baseUrl/api/debug/reset-permissions'),
);
final result = jsonDecode(utf8.decode(response.bodyBytes));
await fetchAll();
return result['message'] ?? '처리되었습니다.';
} catch (e) {
return '❌ 초기화 실패: $e';
}
}
void setFilter(String type) {
_filterType = type;
_safeNotify();
}
/// 🏫 학년 필터. "ALL"이면 전체, "1"/"2"/"3"이면 그 학년만 보이게 한다.
void setGradeFilter(String grade) {
_gradeFilter = grade;
_safeNotify();
}
// ----------------------------------------------------------------
// 파생 로직 (출석 상태 계산)
// ----------------------------------------------------------------
/// "오늘 출석"의 기준선. 하교 처리를 했다면 그 시각 이후, 안 했다면 오늘 자정부터.
Map<String, Map<String, String>> get _todaysCheckInsByStudentId {
// "YYYY-MM-DD HH:MM:SS" 형태의 문자열끼리는 그대로 비교해도 시간 순서가 맞는다.
final String cutoff =
_dismissedAt ??
'${DateTime.now().toIso8601String().substring(0, 10)} 00:00:00';
final Map<String, Map<String, String>> result = {};
for (final log in _logs) {
final String time = log['time']?.toString() ?? '';
if (time.isEmpty || time.compareTo(cutoff) <= 0) {
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;
}
/// 지금까지(오늘 이전 포함) 단 한 번이라도 태깅한 적 있는 학생 학번 집합.
/// (오늘 미제출인 학생 중에서도 "한 번도 태깅 안 해본 애"를 구분하기 위함)
Set<String> get _everCheckedInStudentIds {
final Set<String> ids = {};
for (final log in _logs) {
final String studentId = log['student_id']?.toString() ?? '';
if (studentId.isNotEmpty) ids.add(studentId);
}
return ids;
}
/// ⏰ 태깅 시간과 지정된 자습실 출석시간을 비교해 'NONE' / 'PENDING' / 'COMPLETE'를 반환한다.
/// - NONE: 아직 태깅 안 함
/// - PENDING: 태깅은 했지만 지정된 출석시간 이전이라 아직 "출석 완료"로 안 침
/// - COMPLETE: 출석시간 미지정이거나, 지정된 출석시간 이후에 태깅함
String _computeAttendanceStatus(String? checkInTime) {
if (checkInTime == null) return 'NONE';
if (_attendanceTime == null) return 'COMPLETE';
final String todayStr = DateTime.now().toIso8601String().substring(0, 10);
final String cutoff = '$todayStr $_attendanceTime:00';
return checkInTime.compareTo(cutoff) >= 0 ? 'COMPLETE' : 'PENDING';
}
List<Map<String, dynamic>> get combinedStudentStatus {
final checkIns = _todaysCheckInsByStudentId;
final everCheckedIn = _everCheckedInStudentIds;
return _roster.map((u) {
final String id = u['id']?.toString() ?? '';
final checkIn = checkIns[id];
final violation = _activeViolationsByStudentId[id];
final String attendanceStatus = _computeAttendanceStatus(
checkIn?['time'],
);
return {
'studentId': id,
'studentName': u['name']?.toString() ?? '',
'grade': u['grade'] as int?,
'isCheckedIn': checkIn != null,
'pocketNumber': checkIn?['pocketNumber'],
'checkInTime': checkIn?['time'],
'attendanceStatus': attendanceStatus,
'isAttendanceComplete': attendanceStatus == 'COMPLETE',
'hasEverCheckedIn': everCheckedIn.contains(id),
'hasActiveViolation': violation != null,
'violationPocket': violation?['pocket_number'],
'violationTime': violation?['time'],
};
}).toList();
}
List<Map<String, dynamic>> get filteredStudents {
var result = combinedStudentStatus;
if (_filterType == "CHECKED_IN") {
result = result.where((s) => s['isAttendanceComplete'] == true).toList();
} else if (_filterType == "ABSENT") {
result = result.where((s) => s['isAttendanceComplete'] == false).toList();
}
if (_gradeFilter != "ALL") {
final int? grade = int.tryParse(_gradeFilter);
result = result.where((s) => s['grade'] == grade).toList();
}
return result;
}
/// 🏫 [학년별 카테고리] filteredStudents를 학년(1~3학년) 순서로 묶는다.
/// 학년이 아직 지정 안 된 학생은 맨 뒤 "미배정" 그룹으로 모인다.
List<MapEntry<String, List<Map<String, dynamic>>>> get studentsByGrade {
final Map<int, List<Map<String, dynamic>>> byGrade = {};
final List<Map<String, dynamic>> ungraded = [];
for (final student in filteredStudents) {
final int? grade = student['grade'] as int?;
if (grade == null) {
ungraded.add(student);
} else {
byGrade.putIfAbsent(grade, () => []).add(student);
}
}
final sortedGrades = byGrade.keys.toList()..sort();
final groups = <MapEntry<String, List<Map<String, dynamic>>>>[
for (final grade in sortedGrades) MapEntry('$grade학년', byGrade[grade]!),
];
if (ungraded.isNotEmpty) {
groups.add(MapEntry('미배정', ungraded));
}
return groups;
}
}
@@ -0,0 +1,51 @@
// 👨‍🏫 교사 회원가입 화면의 기능(서버 통신/상태) 담당 컨트롤러.
import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:http/http.dart' as http;
import '../config.dart';
class TeacherRegisterController extends ChangeNotifier {
bool _isLoading = false;
bool get isLoading => _isLoading;
/// (성공여부, 메시지)를 반환한다 — UI가 성공 시에만 이전 화면으로 돌아간다.
Future<(bool success, String message)> registerTeacher({
required String id,
required String password,
required String name,
required String secretCode,
}) async {
// 💡 테스트용 고유값 (실제 디바이스 UUID 연동 로직이 있다면 그걸 넣으세요)
final String dummyUuid = "TEACHER_PHONE_$id";
_isLoading = true;
notifyListeners();
try {
final response = await http.post(
Uri.parse('$baseUrl/api/users/register-teacher'),
headers: {"Content-Type": "application/json"},
body: jsonEncode({
"teacherId": id,
"password": password,
"name": name,
"secretCode": secretCode,
"deviceUuid": dummyUuid,
}),
);
final res = jsonDecode(response.body);
if (response.statusCode == 200 && res['status'] == 'success') {
return (true, '✅ ${res['message']}');
} else {
String errorMsg = res['detail'] ?? res['message'] ?? '회원가입에 실패했습니다.';
return (false, '❌ $errorMsg');
}
} catch (e) {
return (false, '❌ 서버와 통신에 실패했습니다.');
} finally {
_isLoading = false;
notifyListeners();
}
}
}
@@ -0,0 +1,280 @@
// 👥 교사용 학생 계정 관리 화면의 기능(서버 통신/상태/엑셀 일괄등록) 담당 컨트롤러.
import 'dart:convert';
import 'package:excel/excel.dart';
import 'package:flutter/foundation.dart';
import 'package:http/http.dart' as http;
import '../config.dart';
/// 📄 엑셀 한 줄에서 뽑아낸 학생 정보 (아직 서버에 보내기 전, 미리보기용).
class BulkStudentRow {
final String studentId;
final String name;
final int? grade;
const BulkStudentRow({
required this.studentId,
required this.name,
this.grade,
});
}
/// 📊 일괄 등록 결과. [failures]는 "학번 이름: 실패사유" 형태의 문자열 목록.
class BulkImportResult {
final int successCount;
final List<String> failures;
const BulkImportResult({required this.successCount, required this.failures});
}
class TeacherStudentManagementController extends ChangeNotifier {
bool _isWorking = false;
bool get isWorking => _isWorking;
int _bulkTotal = 0;
int _bulkDone = 0;
int get bulkTotal => _bulkTotal;
int get bulkDone => _bulkDone;
List<dynamic> _students = [];
bool _isLoadingStudents = true;
List<dynamic> get students => _students;
bool get isLoadingStudents => _isLoadingStudents;
Future<void> init() => fetchStudents();
/// 🌐 전체 학생 목록 불러오기 (학번/이름/학년/기기등록상태).
Future<void> fetchStudents() async {
_isLoadingStudents = true;
notifyListeners();
try {
final response = await http.get(Uri.parse('$baseUrl/api/users'));
if (response.statusCode == 200) {
final data = jsonDecode(utf8.decode(response.bodyBytes));
_students = data['users'] ?? [];
}
} catch (_) {
// 목록 새로고침 실패는 조용히 무시 — 화면엔 마지막으로 불러온 목록이 남는다.
} finally {
_isLoadingStudents = false;
notifyListeners();
}
}
/// 🌐 학생의 등록된 기기(스마트폰) UUID + 비밀번호(1234)를 초기화한다.
/// (학생이 폰을 바꿨을 때 다시 등록할 수 있게 해주는 용도)
Future<(bool success, String message)> resetDevice(
String studentId,
String studentName,
) async {
_isWorking = true;
notifyListeners();
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) {
await fetchStudents();
return (true, '✅ $studentName 학생 기기 초기화 완료! (비밀번호도 1234로 초기화됨)');
}
return (false, '❌ 초기화 통신 실패');
} catch (e) {
return (false, '❌ 초기화 통신 실패: $e');
} finally {
_isWorking = false;
notifyListeners();
}
}
/// 📄 엑셀 파일 바이트를 파싱한다. 1행은 머리글로 보고 건너뛰며,
/// A열=학번, B열=이름, C열=학년(선택, 숫자) 순서를 기대한다.
/// 학번이나 이름이 비어있는 줄은 조용히 무시한다.
List<BulkStudentRow> parseExcelBytes(Uint8List bytes) {
final excel = Excel.decodeBytes(bytes);
if (excel.tables.isEmpty) return [];
final sheet = excel.tables.values.first;
final rows = <BulkStudentRow>[];
for (final row in sheet.rows.skip(1)) {
final studentId = _cellText(row.isNotEmpty ? row[0]?.value : null);
final name = _cellText(row.length > 1 ? row[1]?.value : null);
if (studentId == null || name == null) continue;
final grade = _cellToGrade(row.length > 2 ? row[2]?.value : null);
rows.add(BulkStudentRow(studentId: studentId, name: name, grade: grade));
}
return rows;
}
String? _cellText(CellValue? value) {
if (value == null) return null;
String text;
if (value is IntCellValue) {
text = value.value.toString();
} else if (value is DoubleCellValue) {
text = value.value == value.value.roundToDouble()
? value.value.toInt().toString()
: value.value.toString();
} else {
text = value.toString().trim();
}
return text.isEmpty ? null : text;
}
int? _cellToGrade(CellValue? value) {
if (value == null) return null;
if (value is IntCellValue) return value.value;
if (value is DoubleCellValue) return value.value.round();
return int.tryParse(value.toString().trim());
}
/// ➕ 엑셀에서 뽑아낸 학생 목록을 한 명씩 순서대로 서버에 등록한다 (기본 비밀번호 1234).
/// 한 명 실패해도 나머지는 계속 진행하고, 마지막에 성공/실패를 모아서 돌려준다.
Future<BulkImportResult> bulkAddStudents(List<BulkStudentRow> rows) async {
_isWorking = true;
_bulkTotal = rows.length;
_bulkDone = 0;
notifyListeners();
int successCount = 0;
final failures = <String>[];
for (final row in rows) {
final (success, message) = await _createStudentRequest(
studentId: row.studentId,
name: row.name,
password: "1234",
grade: row.grade,
);
if (success) {
successCount++;
} else {
failures.add('${row.studentId} ${row.name}: $message');
}
_bulkDone++;
notifyListeners();
}
await fetchStudents();
_isWorking = false;
notifyListeners();
return BulkImportResult(successCount: successCount, failures: failures);
}
/// ➕ 학생 계정 추가. (성공여부, 메시지)를 반환한다 — UI가 성공했을 때만 입력칸을 비운다.
Future<(bool success, String message)> addStudentAccount({
required String studentId,
required String name,
required String password,
int? grade,
}) async {
_isWorking = true;
notifyListeners();
try {
final result = await _createStudentRequest(
studentId: studentId,
name: name,
password: password,
grade: grade,
);
if (result.$1) await fetchStudents();
return result;
} finally {
_isWorking = false;
notifyListeners();
}
}
/// 실제 계정 생성 HTTP 요청. isWorking 상태는 건드리지 않는다 —
/// 단건 등록(addStudentAccount)과 일괄 등록(bulkAddStudents) 양쪽에서
/// 각자 알맞은 시점에 isWorking을 관리하기 위해 분리했다.
Future<(bool success, String message)> _createStudentRequest({
required String studentId,
required String name,
required String password,
int? grade,
}) async {
try {
final url = Uri.parse('$baseUrl/api/users/create');
final response = await http.post(
url,
headers: {"Content-Type": "application/json"},
body: jsonEncode({
"studentId": studentId,
"name": name,
"password": password,
"grade": grade,
}),
);
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');
}
}
/// 🏫 이미 만들어진 학생 계정의 학년을 지정/변경한다. grade가 null이면 "미배정"으로 되돌린다.
Future<(bool success, String message)> updateStudentGrade({
required String studentId,
int? grade,
}) async {
_isWorking = true;
notifyListeners();
try {
final url = Uri.parse('$baseUrl/api/users/update-grade');
final response = await http.post(
url,
headers: {"Content-Type": "application/json"},
body: jsonEncode({"studentId": studentId, "grade": grade}),
);
final result = jsonDecode(utf8.decode(response.bodyBytes));
if (response.statusCode == 200 && result['status'] == 'success') {
await fetchStudents();
return (true, '✅ ${result['message'] ?? '학년이 지정되었습니다.'}');
} else {
return (false, '❌ 지정 실패: ${result['message'] ?? '오류 발생'}');
}
} catch (e) {
return (false, '🚨 네트워크 에러: $e');
} finally {
_isWorking = false;
notifyListeners();
}
}
/// ❌ 학생 계정 삭제.
Future<(bool success, String message)> deleteStudentAccount(
String studentId,
) async {
_isWorking = true;
notifyListeners();
final url = Uri.parse('$baseUrl/api/users/delete');
try {
final response = await http.delete(
url,
headers: {"Content-Type": "application/json"},
body: jsonEncode({"studentId": studentId}),
);
if (response.statusCode == 200) {
final resData = jsonDecode(utf8.decode(response.bodyBytes));
await fetchStudents();
return (true, '✅ ${resData['message']}');
} else {
return (false, '❌ 삭제 실패하였습니다.');
}
} catch (e) {
return (false, '❌ 서버 에러 발생');
} finally {
_isWorking = false;
notifyListeners();
}
}
}
+2 -2
View File
@@ -7,7 +7,7 @@ import 'package:firebase_core/firebase_core.dart';
import 'firebase_options.dart';
import 'config.dart';
import 'pocket_watch_service.dart';
import 'screens/login_screen.dart';
import 'ui/login_screen.dart';
void main() async {
WidgetsFlutterBinding.ensureInitialized();
@@ -26,7 +26,7 @@ class SchoolAttendanceApp extends StatelessWidget {
Widget build(BuildContext context) {
return MaterialApp(
debugShowCheckedModeBanner: false,
title: '백산고등학교 학생 도우미',
title: '$schoolName 학생 도우미',
theme: ThemeData(primarySwatch: Colors.indigo, useMaterial3: true),
home: const LoginScreen(), // 🚪 앱을 켜면 무조건 로그인 화면이 먼저 등장합니다.
);
-379
View File
@@ -1,379 +0,0 @@
// 📲 자습실 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/nfc_manager_android.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. 파이썬 서버로 출석 정보 보내기 (복제 방지용 태그 UID도 함께 전송)
await _sendAttendanceToBackend(cleanPocketNumber, tagUid: _getTagUid(tag));
} catch (e) {
_showSnackBar("❌ 태그 읽기 실패: $e", Colors.red);
} finally {
// 3초 후 연타 방지 해제 (쿨다운)
await Future.delayed(const Duration(seconds: 3));
if (mounted) {
setState(() => _isProcessing = false);
}
}
},
);
}
/// 태그의 공장 각인 하드웨어 UID를 16진수 문자열로 반환한다 (쓰기로 바꿀 수 없어 복제 방지 기준으로 씀).
String? _getTagUid(NfcTag tag) {
final id = NfcTagAndroid.from(tag)?.id;
if (id == null || id.isEmpty) return null;
return id.map((b) => b.toRadixString(16).padLeft(2, '0')).join().toUpperCase();
}
/// 🚚 파이썬 백엔드로 출석 데이터 HTTP POST 전송
Future<void> _sendAttendanceToBackend(
String pocketNumber, {
String? tagUid,
}) 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, // 주머니 번호 전달
"tagUid": tagUid, // 🔒 서버가 태그 진위를 대조할 하드웨어 UID
}),
);
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),
),
],
),
),
),
);
}
/// 💳 삼성페이 결제창 느낌의 전체화면 "주머니 제출 모드" 안내 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),
),
),
],
),
),
),
);
}
}
+25 -1
View File
@@ -112,6 +112,30 @@ void onPocketWatchServiceStart(ServiceInstance service) {
}
}
/// 하교 처리/반출 허용 시간대라면 조용히 감시만 종료하고, 아니면 위반으로 경고한다.
Future<void> handlePhoneRemoved() async {
bool isPermitted = false;
try {
final res = await http.get(
Uri.parse("$baseUrl/api/permissions/check?studentId=$studentId"),
);
if (res.statusCode == 200) {
final data = jsonDecode(utf8.decode(res.bodyBytes));
isPermitted = data['permitted'] == true;
}
} catch (_) {
isPermitted = false; // 네트워크 오류 시엔 안전하게 위반으로 처리
}
if (isPermitted) {
updateNotification("✅ 폰 회수 완료", "[$pocketNumber] 주머니에서 정상적으로 회수되었습니다.");
service.invoke('checked_out', {"pocketNumber": pocketNumber});
service.stopSelf();
} else {
reportViolation();
}
}
void onLightReading(int luxValue) {
if (violated) return;
@@ -129,7 +153,7 @@ void onPocketWatchServiceStart(ServiceInstance service) {
if (luxValue > baselineLux! + _luxJumpThreshold) {
violated = true;
lightSub?.cancel();
reportViolation();
handlePhoneRemoved();
}
}
-154
View File
@@ -1,154 +0,0 @@
// 🛠️ 시스템 관리자 대시보드. 출석 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,
),
),
],
),
),
),
);
}
}
+1 -1
View File
@@ -3,7 +3,7 @@ import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:http/http.dart' as http;
import '../config.dart';
import 'login_screen.dart';
import '../ui/login_screen.dart';
// 💡 main.dart 파일의 최하단(다른 클래스 중괄호 밖)에 붙여넣으세요.
class ChangePasswordScreen extends StatefulWidget {
-541
View File
@@ -1,541 +0,0 @@
// 🔑 로그인 화면. 학번/비밀번호 인증, 최초 로그인 비밀번호 변경, 권한(학생/교사/관리자)별 화면 분기를 담당한다.
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(),
),
),
],
),
],
),
),
),
);
}
}
-204
View File
@@ -1,204 +0,0 @@
// 🏷️ (관리자용) 주머니 NFC 스티커 초기 설정 화면. 빈 태그에 "POCKET_번호" 텍스트를 써넣는다.
import 'dart:convert';
import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'package:http/http.dart' as http;
import 'package:nfc_manager/nfc_manager.dart';
import 'package:nfc_manager/nfc_manager_android.dart';
import 'package:nfc_manager/ndef_record.dart';
import 'package:nfc_manager_ndef/nfc_manager_ndef.dart';
import '../config.dart';
class NfcTagWriterScreen extends StatefulWidget {
const NfcTagWriterScreen({super.key});
@override
State<NfcTagWriterScreen> createState() => _NfcTagWriterScreenState();
}
class _NfcTagWriterScreenState extends State<NfcTagWriterScreen> {
final TextEditingController _numberController = TextEditingController(
text: "1",
);
bool _isWriting = false;
String _statusMessage = "주머니 번호를 입력하고 '쓰기 시작'을 눌러주세요.";
@override
void dispose() {
NfcManager.instance.stopSession();
_numberController.dispose();
super.dispose();
}
void _startWriteSession() async {
final String number = _numberController.text.trim();
if (number.isEmpty) {
_showSnackBar("⚠️ 주머니 번호를 입력해주세요.", Colors.orange);
return;
}
final String pocketLabel = "POCKET_$number";
bool isAvailable = await NfcManager.instance.isAvailable();
if (!isAvailable) {
_showSnackBar("❌ NFC가 꺼져있거나 지원되지 않습니다.", Colors.red);
return;
}
setState(() {
_isWriting = true;
_statusMessage = "📡 [$pocketLabel] 쓸 준비 완료! 스티커에 폰 뒷면을 대주세요.";
});
NfcManager.instance.startSession(
pollingOptions: {NfcPollingOption.iso14443, NfcPollingOption.iso15693},
onDiscovered: (NfcTag tag) async {
try {
final ndef = Ndef.from(tag);
if (ndef == null) {
_showSnackBar("❌ 이 태그는 NDEF 쓰기를 지원하지 않는 종류입니다.", Colors.red);
return;
}
if (!ndef.isWritable) {
_showSnackBar("❌ 이 태그는 쓰기 잠금(read-only) 상태입니다.", Colors.red);
return;
}
await ndef.write(
message: NdefMessage(records: [_createTextRecord(pocketLabel)]),
);
// 🔒 태그 복제 방지: 하드웨어 UID를 서버에 등록해서 이 물리 태그만 [pocketLabel]로 인정되게 한다.
final String? tagUid = _getTagUid(tag);
if (tagUid != null) {
await _registerTagUid(tagUid, pocketLabel);
}
if (mounted) {
_showSnackBar(
tagUid != null
? "✅ [$pocketLabel] 쓰기 + UID 등록 성공!"
: "⚠️ [$pocketLabel] 쓰기는 성공했지만 UID를 못 읽어 등록은 안 됐습니다.",
tagUid != null ? Colors.green : Colors.orange,
);
// 다음 스티커를 연달아 쓰기 편하도록 번호를 자동으로 1 올려준다.
final int? n = int.tryParse(number);
setState(() {
if (n != null) _numberController.text = (n + 1).toString();
_statusMessage = "다음 번호를 확인하고 '쓰기 시작'을 다시 눌러주세요.";
});
}
} catch (e) {
_showSnackBar("❌ 쓰기 실패: $e", Colors.red);
} finally {
await NfcManager.instance.stopSession();
if (mounted) setState(() => _isWriting = false);
}
},
);
}
/// 태그의 공장 각인 하드웨어 UID를 16진수 문자열로 반환한다 (쓰기로 바꿀 수 없어 복제 방지 기준으로 씀).
String? _getTagUid(NfcTag tag) {
final id = NfcTagAndroid.from(tag)?.id;
if (id == null || id.isEmpty) return null;
return id.map((b) => b.toRadixString(16).padLeft(2, '0')).join().toUpperCase();
}
/// 서버에 "이 UID는 이 주머니 번호다"를 등록한다.
Future<void> _registerTagUid(String tagUid, String pocketLabel) async {
try {
await http.post(
Uri.parse("$baseUrl/api/pockets/register"),
headers: {"Content-Type": "application/json"},
body: jsonEncode({"tagUid": tagUid, "pocketNumber": pocketLabel}),
);
} catch (e) {
_showSnackBar("❌ 서버에 UID 등록 실패: $e", Colors.red);
}
}
/// NFC Forum Text Record Type Definition에 맞춰 "언어코드+텍스트" 페이로드를 만든다.
NdefRecord _createTextRecord(String text) {
const languageCode = 'en';
final languageBytes = utf8.encode(languageCode);
final textBytes = utf8.encode(text);
final payload = Uint8List.fromList([
languageBytes.length, // 상태 바이트: UTF-8 + 언어코드 길이
...languageBytes,
...textBytes,
]);
return NdefRecord(
typeNameFormat: TypeNameFormat.wellKnown,
type: Uint8List.fromList([0x54]), // 'T' = Text Record
identifier: Uint8List(0),
payload: payload,
);
}
void _showSnackBar(String text, Color color) {
if (!mounted) return;
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(text), backgroundColor: color));
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text("🏷️ NFC 주머니 태그 쓰기"),
backgroundColor: Colors.deepPurple,
foregroundColor: Colors.white,
),
body: Padding(
padding: const EdgeInsets.all(24.0),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
TextField(
controller: _numberController,
keyboardType: TextInputType.number,
enabled: !_isWriting,
decoration: const InputDecoration(
labelText: "주머니 번호",
prefixText: "POCKET_",
border: OutlineInputBorder(),
),
),
const SizedBox(height: 32),
Icon(
_isWriting ? Icons.nfc : Icons.edit_note_rounded,
size: 80,
color: _isWriting ? Colors.orange : Colors.deepPurple,
),
const SizedBox(height: 16),
Text(
_statusMessage,
textAlign: TextAlign.center,
style: const TextStyle(fontSize: 15),
),
const SizedBox(height: 32),
SizedBox(
width: double.infinity,
height: 55,
child: ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: Colors.deepPurple,
foregroundColor: Colors.white,
),
onPressed: _isWriting ? null : _startWriteSession,
child: Text(
_isWriting ? "태그를 기다리는 중..." : "쓰기 시작",
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
),
),
],
),
),
);
}
}
-496
View File
@@ -1,496 +0,0 @@
// 🎓 학생 대시보드. 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';
import 'nfc_tag_writer_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),
),
if (isDeveloper)
_buildModernCard(
icon: Icons.edit_note_rounded,
title: 'NFC 태그 쓰기',
subtitle: '주머니 스티커 초기 설정',
color: Colors.deepPurple,
onTap: () => Navigator.push(
context,
MaterialPageRoute(
builder: (context) => const NfcTagWriterScreen(),
),
),
),
],
),
),
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
@@ -1,338 +0,0 @@
// 🔐 (마스터 계정용) 학생 계정 및 기기 관리 화면. 기기 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
@@ -1,322 +0,0 @@
// 📋 실시간 출석 현황 화면. 전체 학생 명단(/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"),
),
],
),
);
}
}
-152
View File
@@ -1,152 +0,0 @@
// 👨‍🏫 교사 회원가입 화면. 교사 인증 코드 확인 후 신규 교사 계정을 생성한다.
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('교사 계정 생성하기'),
),
),
],
),
),
);
}
}
@@ -1,363 +0,0 @@
// 👥 교사용 학생 계정 관리 화면. 신규 학생 계정 추가와 계정 강제 삭제를 담당한다.
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,
),
],
),
),
),
],
),
),
);
}
}
+142
View File
@@ -0,0 +1,142 @@
// 🛠️ 시스템 관리자 대시보드 (UI 전용). 출석 DB 전체 초기화 버튼을 그린다.
// 서버 통신/상태는 lib/function/admin_controller.dart가 담당한다.
import 'package:flutter/material.dart';
import '../function/admin_controller.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> {
final AdminController _controller = AdminController();
@override
void dispose() {
_controller.dispose();
super.dispose();
}
Future<void> _resetDatabase() async {
final message = await _controller.resetDatabase();
if (!mounted) return;
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(message)));
}
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 ListenableBuilder(
listenable: _controller,
builder: (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),
_controller.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,
),
),
],
),
),
),
);
},
);
}
}
+436
View File
@@ -0,0 +1,436 @@
// 🔑 로그인 화면 (UI 전용). 입력폼/다이얼로그/화면 이동만 담당한다.
// 인증 로직/서버 통신은 lib/function/login_controller.dart가 담당한다.
import 'package:flutter/material.dart';
import '../config.dart' show schoolName;
import '../function/login_controller.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 LoginController _controller = LoginController();
final TextEditingController _idController = TextEditingController();
final TextEditingController _pwController = TextEditingController();
@override
void dispose() {
_controller.dispose();
_idController.dispose();
_pwController.dispose();
super.dispose();
}
Future<void> _login() async {
final result = await _controller.login(
_idController.text.trim(),
_pwController.text.trim(),
);
if (!mounted) return;
switch (result.outcome) {
case LoginOutcome.error:
_showErrorDialog(result.errorMessage!);
break;
case LoginOutcome.masterSuccess:
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('👑 개발자 최고 권한으로 로그인되었습니다.')),
);
Navigator.pushReplacement(
context,
MaterialPageRoute(
builder: (context) => StudentDashboard(
studentId: result.studentId!,
studentName: result.name!,
isDeviceMatched: true,
),
),
);
break;
case LoginOutcome.needsPasswordChange:
_showFirstLoginPasswordDialog(
result.studentId!,
result.name!,
result.role!,
result.isDeviceMatched,
);
break;
case LoginOutcome.success:
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text('✅ ${result.name}님 환영합니다!')));
_navigateBasedOnRole(
result.role!,
result.studentId!,
result.name!,
result.isDeviceMatched,
);
break;
}
}
// 🛠️ 초기 비밀번호 변경 팝업창 (기기 매칭 데이터 파라미터 추가)
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);
final (success, message) = await _controller
.changePassword(
studentId: studentId,
newPassword: newPassword,
);
setDialogState(() => isUpdating = false);
if (success) {
Navigator.pop(dialogContext);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(message)),
);
_navigateBasedOnRole(
role,
studentId,
name,
isDeviceMatched,
);
} else {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(message)),
);
}
},
child: const Text('변경하고 시작하기'),
),
],
);
},
);
},
);
}
// 🆕 권한별 화면 이동시 기기 일치 여부 파라미터(`isDeviceMatched`) 수신 및 대시보드 전달
void _navigateBasedOnRole(
String role,
String studentId,
String name,
bool isDeviceMatched,
) {
if (role == 'teacher') {
Navigator.pushReplacement(
context,
MaterialPageRoute(
builder: (context) =>
TeacherDashboard(teacherId: studentId, teacherName: name),
),
);
} 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,
),
),
);
}
}
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 ListenableBuilder(
listenable: _controller,
builder: (context, _) {
return Scaffold(
backgroundColor: Colors.grey[50],
body: Center(
child: SingleChildScrollView(
padding: const EdgeInsets.all(24.0),
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 420),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.school, size: 80, color: Colors.indigo),
const SizedBox(height: 16),
Text(
'$schoolName 모니터',
style: const 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),
_controller.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(),
),
),
],
),
],
),
),
),
),
);
},
);
}
}
+272
View File
@@ -0,0 +1,272 @@
// 📲 자습실 NFC 출석체크 화면 (UI 전용). 태깅 상태 화면/팝업/스낵바만 그린다.
// NFC 세션, 서버 통신, 백그라운드 감시 연동은
// lib/function/nfc_pocket_checkin_controller.dart가 담당한다.
import 'dart:io' show Platform;
import 'package:flutter/foundation.dart' show kIsWeb;
import 'package:flutter/material.dart';
import '../function/nfc_pocket_checkin_controller.dart';
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> {
late final NfcPocketCheckinController _controller;
bool _isDialogOpen = false; // 출석/위반 팝업이 겹쳐서 뜨는 것을 막기 위한 플래그
@override
void initState() {
super.initState();
_controller = NfcPocketCheckinController(
studentId: widget.studentId,
studentName: widget.studentName,
onMessage: _handleMessage,
onCheckInSuccess: _showSuccessDialog,
onViolationDetected: _showViolationDialog,
onNeedGuidedAccessSetup: _showGuidedAccessDialog,
);
_controller.init();
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
void _handleMessage(String text, WatchMessageLevel level) {
final Color color = switch (level) {
WatchMessageLevel.info => Colors.blueGrey,
WatchMessageLevel.success => Colors.green,
WatchMessageLevel.warning => Colors.orange,
WatchMessageLevel.error => Colors.red,
};
_showSnackBar(text, color);
}
/// 이미 떠 있는 팝업(출석 완료/무단반출 감지)이 있으면 새 팝업을 띄우기 전에 먼저 닫는다.
/// (태깅→반출→재태깅이 빠르게 반복되면 팝업이 여러 개 겹쳐 쌓이는 것을 방지)
void _closeAnyOpenDialog() {
if (_isDialogOpen && mounted) {
Navigator.of(context, rootNavigator: true).pop();
}
_isDialogOpen = false;
}
void _showViolationDialog(String? pocketNumber) {
if (!mounted) return;
_closeAnyOpenDialog();
_isDialogOpen = true;
showDialog(
context: context,
builder: (context) => AlertDialog(
backgroundColor: Colors.red[50],
title: const Text(
"🚨 무단 반출 감지",
style: TextStyle(color: Colors.red, fontWeight: FontWeight.bold),
),
content: Text(
"[${pocketNumber ?? _controller.activePocketNumber}] 주머니에서 휴대폰이 꺼내진 것으로 감지되었습니다.\n담당 선생님께 알림이 전송되었습니다.",
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text("확인"),
),
],
),
).then((_) => _isDialogOpen = false);
}
/// 🛡️ iOS 전용: 근접센서 감시가 화면 잠금으로 끊기지 않도록, "가이드 접근"을
/// 직접 켜달라고 안내하는 팝업. 학생이 켰다고 확인해야 실제 감시가 시작된다.
void _showGuidedAccessDialog(String pocketNumber) {
if (!mounted) return;
_closeAnyOpenDialog();
_isDialogOpen = true;
showDialog(
context: context,
barrierDismissible: false,
builder: (context) => AlertDialog(
title: const Text("🛡️ 감시 모드 켜기 전에"),
content: const Text(
"iOS는 화면을 잠그면 감시가 끊겨요. 아래 순서로 '가이드 접근'을 먼저 켜주세요.\n\n"
"1. 사이드(또는 홈) 버튼 3번 빠르게 누르기\n"
"2. 목록에서 '가이드 접근' 선택 → 화면 오른쪽 아래 '시작' 누르기\n\n"
"다 켜셨으면 아래 버튼을 눌러 감시를 시작하세요.\n"
"(감시를 끝낼 땐 먼저 사이드 버튼 3번으로 가이드 접근을 끈 다음 다시 태깅하세요.)",
),
actions: [
TextButton(
onPressed: () {
Navigator.pop(context);
_controller.beginIosProximityWatch(pocketNumber);
},
child: const Text("가이드 접근 켰어요"),
),
],
),
).then((_) => _isDialogOpen = false);
}
/// 🎊 출석 성공 알림창
void _showSuccessDialog(String pocketNumber) {
if (!mounted) return;
_closeAnyOpenDialog();
_isDialogOpen = true;
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("확인"),
),
],
),
).then((_) => _isDialogOpen = false);
}
void _showSnackBar(String text, Color color) {
if (!mounted) return;
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(text), backgroundColor: color));
}
@override
Widget build(BuildContext context) {
return ListenableBuilder(
listenable: _controller,
builder: (context, _) {
if (_controller.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(
_controller.isProcessing ? Icons.sync : Icons.nfc,
size: 100,
color: _controller.isProcessing
? Colors.orange
: Colors.blueAccent,
),
const SizedBox(height: 30),
// 상태 메시지 표시
Text(
_controller.statusMessage,
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
color: _controller.isProcessing
? Colors.orange
: Colors.black87,
),
),
const SizedBox(height: 15),
const Text(
"자기 주머니 번호 숫자에 폰 뒷면을 '톡' 대면\n자동으로 출석 처리됩니다.",
textAlign: TextAlign.center,
style: TextStyle(color: Colors.grey, fontSize: 14),
),
],
),
),
),
);
},
);
}
/// 💳 삼성페이 결제창 느낌의 전체화면 "주머니 제출 모드" 안내 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(
_controller.activePocketNumber ?? "",
style: const TextStyle(
color: Colors.white54,
fontSize: 14,
letterSpacing: 2,
),
),
const SizedBox(height: 12),
Text(
_controller.statusMessage,
textAlign: TextAlign.center,
style: const TextStyle(
color: Colors.white,
fontSize: 20,
fontWeight: FontWeight.bold,
height: 1.4,
),
),
const SizedBox(height: 8),
Text(
!kIsWeb && Platform.isIOS
? "🔒 가이드 접근을 켠 채로 화면만 잠기도록 두세요.\n(임의로 앱을 나가면 감시가 끊깁니다)\n감시를 끝내려면 가이드 접근을 먼저 끄고 다시 태깅하세요."
: "📴 화면을 꺼도 감시는 계속됩니다.\n알림바에서 상태를 확인할 수 있어요.\n(폰을 꺼내 다시 태깅하면 반출 처리됩니다)",
textAlign: TextAlign.center,
style: const TextStyle(color: Colors.white38, fontSize: 13),
),
],
),
),
),
);
}
}
+114
View File
@@ -0,0 +1,114 @@
// 🏷️ (관리자용) 주머니 NFC 스티커 초기 설정 화면 (UI 전용). 빈 태그에 "POCKET_번호"를 쓰는
// 버튼/입력폼을 그린다. NFC 세션/서버 통신은 lib/function/nfc_tag_writer_controller.dart가 담당한다.
import 'package:flutter/material.dart';
import '../function/nfc_tag_writer_controller.dart';
class NfcTagWriterScreen extends StatefulWidget {
const NfcTagWriterScreen({super.key});
@override
State<NfcTagWriterScreen> createState() => _NfcTagWriterScreenState();
}
class _NfcTagWriterScreenState extends State<NfcTagWriterScreen> {
final TextEditingController _numberController = TextEditingController(
text: "1",
);
late final NfcTagWriterController _controller;
@override
void initState() {
super.initState();
_controller = NfcTagWriterController(
onMessage: _showSnackBar,
onNextNumberSuggested: (next) => _numberController.text = next,
);
}
@override
void dispose() {
_controller.dispose();
_numberController.dispose();
super.dispose();
}
void _showSnackBar(String text, bool isError) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(text),
backgroundColor: isError ? Colors.red : Colors.green,
),
);
}
@override
Widget build(BuildContext context) {
return ListenableBuilder(
listenable: _controller,
builder: (context, _) {
final bool isWriting = _controller.isWriting;
return Scaffold(
appBar: AppBar(
title: const Text("🏷️ NFC 주머니 태그 쓰기"),
backgroundColor: Colors.deepPurple,
foregroundColor: Colors.white,
),
body: Padding(
padding: const EdgeInsets.all(24.0),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
TextField(
controller: _numberController,
keyboardType: TextInputType.number,
enabled: !isWriting,
decoration: const InputDecoration(
labelText: "주머니 번호",
prefixText: "POCKET_",
border: OutlineInputBorder(),
),
),
const SizedBox(height: 32),
Icon(
isWriting ? Icons.nfc : Icons.edit_note_rounded,
size: 80,
color: isWriting ? Colors.orange : Colors.deepPurple,
),
const SizedBox(height: 16),
Text(
_controller.statusMessage,
textAlign: TextAlign.center,
style: const TextStyle(fontSize: 15),
),
const SizedBox(height: 32),
SizedBox(
width: double.infinity,
height: 55,
child: ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: Colors.deepPurple,
foregroundColor: Colors.white,
),
onPressed: isWriting
? null
: () => _controller.startWriteSession(
_numberController.text.trim(),
),
child: Text(
isWriting ? "태그를 기다리는 중..." : "쓰기 시작",
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
),
),
],
),
),
);
},
);
}
}
+511
View File
@@ -0,0 +1,511 @@
// 🎓 학생 대시보드 (UI 전용). NFC 주머니 체크인 진입, 실시간 학교 상황 안내,
// 개발자 마스터 계정 전용 관리 메뉴(현황/DB제어/계정관리/삭제) 카드를 그린다.
// 계정 삭제 서버 통신은 lib/function/student_dashboard_controller.dart가 담당한다.
import 'package:flutter/material.dart';
import '../function/student_dashboard_controller.dart';
import 'nfc_poccket_checkin_screen.dart';
import 'login_screen.dart';
import 'teacher_attendance_page.dart';
import 'admin_dashboard.dart';
import 'teacher_student_management_page.dart';
import 'nfc_tag_writer_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> {
final StudentDashboardController _controller = StudentDashboardController();
@override
void dispose() {
_controller.dispose();
super.dispose();
}
// 1️⃣ NFC 태그 카드 → 실제 NFC 주머니 체크인 화면으로 이동
void _openPocketCheckIn(BuildContext context) {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => NfcPocketCheckInScreen(
studentId: widget.studentId,
studentName: widget.studentName,
),
),
);
}
// 2️⃣ [기존 동일] 마스터 계정 전용 회원 삭제 버튼 동작
Future<void> _deleteUser(String userId) async {
final (_, message) = await _controller.deleteUser(userId);
if (!mounted) return;
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(message)));
}
// 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(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) {
return ListenableBuilder(
listenable: _controller,
builder: (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),
LayoutBuilder(
builder: (context, constraints) {
// 🖥️ 웹(넓은 화면)은 한 줄에 5개씩, 폰(좁은 화면)은 기존 2개 그대로.
final bool isWide = constraints.maxWidth >= 800;
return Center(
child: ConstrainedBox(
constraints: BoxConstraints(
maxWidth: isWide ? 1300 : 700,
),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 24.0),
child: GridView.count(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
crossAxisCount: isWide ? 5 : 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: _controller.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 TeacherStudentManagementPage(),
),
),
),
if (isDeveloper)
_buildModernCard(
icon: Icons.delete_sweep_rounded,
title: '계정 강제 삭제',
subtitle: '학생 및 교사 DB 삭제',
color: Colors.red[600]!,
onTap: () => _showDeleteUserDialog(context),
),
if (isDeveloper)
_buildModernCard(
icon: Icons.edit_note_rounded,
title: 'NFC 태그 쓰기',
subtitle: '주머니 스티커 초기 설정',
color: Colors.deepPurple,
onTap: () => Navigator.push(
context,
MaterialPageRoute(
builder: (context) =>
const NfcTagWriterScreen(),
),
),
),
],
),
),
),
);
},
),
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,
),
),
],
),
],
),
),
);
}
}
+764
View File
@@ -0,0 +1,764 @@
// 📋 실시간 출석 현황 화면 (UI 전용). 위젯 빌드/레이아웃/스타일만 담당하고,
// 서버 통신·상태·파생 로직은 lib/function/teacher_attendance_controller.dart가 담당한다.
import 'package:flutter/material.dart';
import '../function/teacher_attendance_controller.dart';
// -----------------------------------------------------------------------------
// 📅 [서브 화면 1] 실시간 출석 확인 란 (StudentDashboard 카드 스타일 리스트화)
// -----------------------------------------------------------------------------
class TeacherAttendancePage extends StatefulWidget {
const TeacherAttendancePage({super.key});
@override
State<TeacherAttendancePage> createState() => _TeacherAttendancePageState();
}
class _TeacherAttendancePageState extends State<TeacherAttendancePage> {
final TeacherAttendanceController _controller = TeacherAttendanceController();
@override
void initState() {
super.initState();
_controller.init();
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
/// 컨트롤러 액션을 실행하고, 결과 메시지를 스낵바로 보여준다.
Future<void> _runAction(Future<String> Function() action) async {
final message = await action();
if (!mounted) return;
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(message)));
}
Future<void> _showAttendanceTimeDialog() async {
final String? current = _controller.attendanceTime;
final TimeOfDay initial = current != null
? TimeOfDay(
hour: int.parse(current.split(':')[0]),
minute: int.parse(current.split(':')[1]),
)
: const TimeOfDay(hour: 19, minute: 0);
final TimeOfDay? picked = await showTimePicker(
context: context,
initialTime: initial,
helpText: '자습실 출석시간 지정',
);
if (picked == null) return;
final String formatted =
'${picked.hour.toString().padLeft(2, '0')}:${picked.minute.toString().padLeft(2, '0')}';
await _runAction(() => _controller.setAttendanceTime(formatted));
}
void _showTestResetConfirmDialog() {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('🧪 테스트용 허용시간 초기화'),
content: const Text(
'하교 처리나 반출 허용 시간 설정으로 켜져 있는 모든 허용 시간대를 지금 즉시 해제합니다.\n'
'(무단반출 감지 테스트할 때만 사용하세요)',
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('취소'),
),
ElevatedButton(
onPressed: () {
Navigator.pop(context);
_runAction(() => _controller.resetTestPermissions());
},
style: ElevatedButton.styleFrom(
backgroundColor: Colors.grey[700],
foregroundColor: Colors.white,
),
child: const Text('초기화'),
),
],
),
);
}
void _showAllowDialog(String studentId, String studentName) {
final controller = TextEditingController(text: "5");
showDialog(
context: context,
builder: (context) => AlertDialog(
title: Text('$studentName 학생 반출 허용'),
content: TextField(
controller: controller,
keyboardType: TextInputType.number,
decoration: const InputDecoration(
labelText: '허용 시간 (분)',
border: OutlineInputBorder(),
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('취소'),
),
ElevatedButton(
onPressed: () {
final minutes = int.tryParse(controller.text.trim()) ?? 5;
Navigator.pop(context);
_runAction(() => _controller.allowRemoval(studentId, minutes));
},
child: const Text('허용하기'),
),
],
),
);
}
void _showPermissionWindowDialog() {
final controller = TextEditingController(text: "10");
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('⏰ 전체 학생 반출 허용 시간 설정'),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'쉬는시간처럼 지금부터 일정 시간 동안 모든 학생의 반출을 자동으로 허용합니다.',
style: TextStyle(fontSize: 13, color: Colors.grey),
),
const SizedBox(height: 16),
TextField(
controller: controller,
keyboardType: TextInputType.number,
decoration: const InputDecoration(
labelText: '지금부터 허용 시간 (분)',
border: OutlineInputBorder(),
),
),
],
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('취소'),
),
ElevatedButton(
onPressed: () {
final minutes = int.tryParse(controller.text.trim()) ?? 10;
Navigator.pop(context);
_runAction(() => _controller.setPermissionWindow(minutes));
},
child: const Text('설정하기'),
),
],
),
);
}
/// 🏫 하교 처리: 시간 입력 없이 바로 전체 학생의 반출을 (사실상 무기한) 허용한다.
void _showDismissalConfirmDialog() {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('🏫 하교 처리'),
content: const Text(
'지금부터 모든 학생의 반출이 자동으로 허용되며, 더 이상 무단반출 경고가 뜨지 않습니다.\n하교 처리하시겠습니까?',
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('취소'),
),
ElevatedButton(
onPressed: () {
Navigator.pop(context);
_runAction(() => _controller.dismissAll());
},
child: const Text('하교 처리'),
),
],
),
);
}
@override
Widget build(BuildContext context) {
return ListenableBuilder(
listenable: _controller,
builder: (context, _) {
final all = _controller.combinedStudentStatus;
final int totalCount = all.length;
final int checkedInCount = all
.where((s) => s['isAttendanceComplete'] == 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,
actions: [
IconButton(
onPressed: (_controller.isLoading || _controller.isRefreshing)
? null
: _controller.manualRefresh,
icon: _controller.isRefreshing
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
color: Colors.white,
),
)
: const Icon(Icons.refresh_rounded),
tooltip: '새로고침',
),
TextButton.icon(
onPressed: _showAttendanceTimeDialog,
icon: const Icon(
Icons.access_time_rounded,
color: Colors.white,
),
label: Text(
_controller.attendanceTime != null
? '출석시간 ${_controller.attendanceTime}'
: '자습실 출석시간 설정',
style: const TextStyle(color: Colors.white),
),
),
TextButton.icon(
onPressed: _showPermissionWindowDialog,
icon: const Icon(Icons.timer_outlined, color: Colors.white),
label: const Text(
'반출 허용 시간 설정',
style: TextStyle(color: Colors.white),
),
),
TextButton.icon(
onPressed: _showDismissalConfirmDialog,
icon: const Icon(Icons.school_rounded, color: Colors.white),
label: const Text('하교', style: TextStyle(color: Colors.white)),
),
IconButton(
onPressed: _showTestResetConfirmDialog,
icon: const Icon(
Icons.bug_report_outlined,
color: Colors.white70,
),
tooltip: '🧪 테스트용: 허용시간 초기화',
),
const SizedBox(width: 8),
],
),
body: _controller.isLoading
? const Center(child: CircularProgressIndicator())
: LayoutBuilder(
builder: (context, constraints) {
final bool isWide = constraints.maxWidth >= 800;
return isWide
? _buildDesktopBody(
totalCount,
checkedInCount,
absentCount,
)
: _buildMobileBody(
totalCount,
checkedInCount,
absentCount,
);
},
),
);
},
);
}
// -----------------------------------------------------------------------
// 📱 모바일 레이아웃 (기존 카드 리스트)
// -----------------------------------------------------------------------
Widget _buildMobileBody(int total, int checkedIn, int absent) {
final groups = _controller.studentsByGrade;
return Column(
children: [
_buildSummaryCards(total, checkedIn, absent),
_buildPermissionBanner(),
_buildFilterChips(),
Expanded(
child: groups.isEmpty
? const Center(
child: Text(
'해당하는 학생이 없습니다.',
style: TextStyle(color: Colors.grey),
),
)
: _buildGradeGroupedList(
groups,
padding: const EdgeInsets.fromLTRB(16, 12, 16, 16),
),
),
],
);
}
/// 🏫 학년별로 섹션 제목을 붙여 그리드를 이어붙인 목록. 모바일/데스크톱 공용.
Widget _buildGradeGroupedList(
List<MapEntry<String, List<Map<String, dynamic>>>> groups, {
required EdgeInsets padding,
}) {
return ListView.builder(
padding: padding,
itemCount: groups.length,
itemBuilder: (context, index) {
final group = groups[index];
return Padding(
padding: EdgeInsets.only(bottom: index == groups.length - 1 ? 0 : 24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Container(
width: 4,
height: 16,
decoration: BoxDecoration(
color: Colors.blue,
borderRadius: BorderRadius.circular(2),
),
),
const SizedBox(width: 8),
Text(
group.key,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: Colors.black87,
),
),
const SizedBox(width: 6),
Text(
'${group.value.length}명',
style: TextStyle(fontSize: 13, color: Colors.grey[500]),
),
],
),
const SizedBox(height: 10),
GridView.builder(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent(
maxCrossAxisExtent: 220,
mainAxisSpacing: 12,
crossAxisSpacing: 12,
mainAxisExtent: 148,
),
itemCount: group.value.length,
itemBuilder: (context, i) => _buildStudentTile(group.value[i]),
),
],
),
);
},
);
}
/// 🀄 학생 한 명을 "한눈에 보기" 타일로 그린다. 모바일/데스크톱 그리드에서 공용으로 쓴다.
Widget _buildStudentTile(Map<String, dynamic> student) {
final bool isCheckedIn = student['isCheckedIn'];
final bool hasViolation = student['hasActiveViolation'] == true;
final bool isPending = student['attendanceStatus'] == 'PENDING';
final bool isComplete = student['attendanceStatus'] == 'COMPLETE';
final Color statusColor = hasViolation
? Colors.red
: (isPending ? Colors.orange : (isComplete ? Colors.blue : Colors.red));
final String statusLine = hasViolation
? '🚨 무단반출 (${student['violationTime']})'
: (isPending
? '⏳ 미완료 (${student['checkInTime']})'
: (isComplete
? '${student['checkInTime']}'
: (student['hasEverCheckedIn'] == true ? '미제출' : '미등록')));
return Container(
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: hasViolation ? Colors.red[50] : Colors.white,
borderRadius: BorderRadius.circular(20),
border: hasViolation
? Border.all(color: Colors.red[300]!, width: 1.5)
: null,
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.03),
blurRadius: 12,
offset: const Offset(0, 4),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: statusColor.withValues(alpha: 0.1),
shape: BoxShape.circle,
),
child: Icon(
hasViolation
? Icons.warning_amber_rounded
: (isPending
? Icons.hourglass_bottom_rounded
: (isComplete
? Icons.check_circle_rounded
: Icons.error_rounded)),
color: statusColor,
size: 18,
),
),
const Spacer(),
if (isCheckedIn && student['pocketNumber'] != null)
Container(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 3,
),
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: 11,
),
),
),
],
),
const SizedBox(height: 8),
Text(
'${student['studentName']}',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 15),
),
Text(
'학번 ${student['studentId']}',
style: TextStyle(color: Colors.grey[500], fontSize: 11),
),
const SizedBox(height: 4),
Text(
statusLine,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: hasViolation
? Colors.red[700]
: (isPending
? Colors.orange[800]
: (isComplete ? Colors.grey[600] : Colors.red[400])),
fontSize: 11.5,
fontWeight: hasViolation ? FontWeight.bold : FontWeight.normal,
),
),
if (hasViolation) ...[
const Spacer(),
SizedBox(
width: double.infinity,
height: 30,
child: ElevatedButton(
onPressed: () => _showAllowDialog(
student['studentId'],
student['studentName'],
),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.red[600],
foregroundColor: Colors.white,
padding: EdgeInsets.zero,
),
child: const Text('반출 허용', style: TextStyle(fontSize: 12)),
),
),
],
],
),
);
}
// -----------------------------------------------------------------------
// 🖥️ 데스크톱 레이아웃 (선생님이 교실 컴퓨터 브라우저로 접속했을 때)
// -----------------------------------------------------------------------
Widget _buildDesktopBody(int total, int checkedIn, int absent) {
final groups = _controller.studentsByGrade;
return Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 1100),
child: Padding(
padding: const EdgeInsets.all(24.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: _statTile(
"전체 학생",
"$total명",
Icons.groups_rounded,
Colors.blueGrey,
),
),
const SizedBox(width: 16),
Expanded(
child: _statTile(
"출석 완료",
"$checkedIn명",
Icons.check_circle_rounded,
Colors.green,
),
),
const SizedBox(width: 16),
Expanded(
child: _statTile(
"미출석",
"$absent명",
Icons.error_rounded,
Colors.red,
),
),
],
),
const SizedBox(height: 20),
_buildPermissionBanner(),
_buildFilterChips(),
const SizedBox(height: 12),
Expanded(
child: Container(
width: double.infinity,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.04),
blurRadius: 16,
offset: const Offset(0, 4),
),
],
),
child: groups.isEmpty
? const Center(
child: Text(
'해당하는 학생이 없습니다.',
style: TextStyle(color: Colors.grey),
),
)
: _buildGradeGroupedList(
groups,
padding: const EdgeInsets.all(20),
),
),
),
],
),
),
),
);
}
Widget _statTile(String label, String value, IconData icon, Color color) {
return Container(
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.04),
blurRadius: 12,
offset: const Offset(0, 4),
),
],
),
child: Row(
children: [
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: color.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(12),
),
child: Icon(icon, color: color, size: 26),
),
const SizedBox(width: 14),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
label,
style: TextStyle(color: Colors.grey[600], fontSize: 13),
),
const SizedBox(height: 2),
Text(
value,
style: TextStyle(
color: color,
fontSize: 22,
fontWeight: FontWeight.bold,
),
),
],
),
],
),
);
}
/// 📊 상단 요약 카드 뷰 (전체 / 출석 완료 / 미출석)
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,
),
),
],
);
}
/// 🔓 하교(12시간)/반출 허용 시간 설정으로 지금 전체 반출이 허용 중이면 눈에 띄게 배너로 알려준다.
/// (허용 중일 땐 무단반출을 감지해도 대시보드에 뜨지 않기 때문에, 왜 안 뜨는지 헷갈리지 않게 하기 위함)
Widget _buildPermissionBanner() {
final String? until = _controller.globalPermissionUntil;
if (until == null) return const SizedBox.shrink();
return Container(
width: double.infinity,
margin: const EdgeInsets.fromLTRB(16, 8, 16, 0),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
decoration: BoxDecoration(
color: Colors.amber[100],
borderRadius: BorderRadius.circular(12),
border: Border.all(color: Colors.amber[400]!),
),
child: Row(
children: [
Icon(Icons.lock_open_rounded, color: Colors.amber[800], size: 20),
const SizedBox(width: 8),
Expanded(
child: Text(
'🔓 지금 전체 반출 허용 중입니다 ($until 까지) — 이 시간 동안은 무단반출 경고가 뜨지 않아요.',
style: TextStyle(
color: Colors.amber[900],
fontWeight: FontWeight.bold,
fontSize: 12.5,
),
),
),
],
),
);
}
/// 🔘 필터 칩버튼 (전체 / 출석자 / 미출석자)
Widget _buildFilterChips() {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Wrap(
spacing: 8,
runSpacing: 8,
children: [
FilterChip(
label: const Text("전체"),
selected: _controller.filterType == "ALL",
onSelected: (_) => _controller.setFilter("ALL"),
),
FilterChip(
label: const Text("🟢 출석자"),
selected: _controller.filterType == "CHECKED_IN",
onSelected: (_) => _controller.setFilter("CHECKED_IN"),
),
FilterChip(
label: const Text("🔴 미출석자"),
selected: _controller.filterType == "ABSENT",
onSelected: (_) => _controller.setFilter("ABSENT"),
),
],
),
const SizedBox(height: 8),
Wrap(
spacing: 8,
runSpacing: 8,
children: [
ChoiceChip(
label: const Text("학년 전체"),
selected: _controller.gradeFilter == "ALL",
onSelected: (_) => _controller.setGradeFilter("ALL"),
),
ChoiceChip(
label: const Text("1학년"),
selected: _controller.gradeFilter == "1",
onSelected: (_) => _controller.setGradeFilter("1"),
),
ChoiceChip(
label: const Text("2학년"),
selected: _controller.gradeFilter == "2",
onSelected: (_) => _controller.setGradeFilter("2"),
),
ChoiceChip(
label: const Text("3학년"),
selected: _controller.gradeFilter == "3",
onSelected: (_) => _controller.setGradeFilter("3"),
),
],
),
],
),
);
}
}
@@ -135,7 +135,11 @@ class _TeacherDashboardState extends State<TeacherDashboard> {
const SizedBox(height: 16),
// 📊 [그리드 레이아웃 메뉴] 시후의 카드 컴포넌트 스타일 적용
Padding(
// 🖥️ 데스크톱 브라우저에서 카드가 지나치게 커지지 않도록 최대 너비를 제한한다.
Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 700),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 24.0),
child: GridView.count(
shrinkWrap: true,
@@ -173,6 +177,8 @@ class _TeacherDashboardState extends State<TeacherDashboard> {
],
),
),
),
),
const SizedBox(height: 32),
],
),
+141
View File
@@ -0,0 +1,141 @@
// 👨‍🏫 교사 회원가입 화면 (UI 전용). 서버 통신/상태는
// lib/function/teacher_register_controller.dart가 담당한다.
import 'package:flutter/material.dart';
import '../function/teacher_register_controller.dart';
class TeacherRegisterScreen extends StatefulWidget {
const TeacherRegisterScreen({super.key});
@override
State<TeacherRegisterScreen> createState() => _TeacherRegisterScreenState();
}
class _TeacherRegisterScreenState extends State<TeacherRegisterScreen> {
final TeacherRegisterController _controller = TeacherRegisterController();
final _idController = TextEditingController();
final _pwController = TextEditingController();
final _nameController = TextEditingController();
final _secretController = TextEditingController();
@override
void dispose() {
_controller.dispose();
_idController.dispose();
_pwController.dispose();
_nameController.dispose();
_secretController.dispose();
super.dispose();
}
Future<void> _registerTeacher() async {
String id = _idController.text.trim();
String pw = _pwController.text.trim();
String name = _nameController.text.trim();
String secret = _secretController.text.trim();
if (id.isEmpty || pw.isEmpty || name.isEmpty || secret.isEmpty) {
ScaffoldMessenger.of(
context,
).showSnackBar(const SnackBar(content: Text('⚠️ 모든 빈칸을 입력해 주세요.')));
return;
}
final (success, message) = await _controller.registerTeacher(
id: id,
password: pw,
name: name,
secretCode: secret,
);
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(
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: _controller.isLoading ? null : _registerTeacher,
child: _controller.isLoading
? const CircularProgressIndicator(color: Colors.white)
: const Text('교사 계정 생성하기'),
),
),
],
),
),
);
},
);
}
}
+739
View File
@@ -0,0 +1,739 @@
// 👥 교사용 학생 계정 관리 화면 (UI 전용). 신규 학생 계정 추가 폼과 강제 삭제 다이얼로그를 그린다.
// 서버 통신/상태는 lib/function/teacher_student_management_controller.dart가 담당한다.
import 'dart:typed_data';
import 'package:desktop_drop/desktop_drop.dart';
import 'package:flutter/material.dart';
import '../function/excel_file_picker.dart';
import '../function/teacher_student_management_controller.dart';
// -----------------------------------------------------------------------------
// 👥 [서브 화면 2] 학생 계정 관리 란 (추가 양식 폼 + 강제 삭제 다이얼로그 완전 내장)
// -----------------------------------------------------------------------------
class TeacherStudentManagementPage extends StatefulWidget {
const TeacherStudentManagementPage({super.key});
@override
State<TeacherStudentManagementPage> createState() =>
_TeacherStudentManagementPageState();
}
class _TeacherStudentManagementPageState
extends State<TeacherStudentManagementPage> {
final TeacherStudentManagementController _controller =
TeacherStudentManagementController();
final TextEditingController _addIdController = TextEditingController();
final TextEditingController _addNameController = TextEditingController();
final TextEditingController _addPwController = TextEditingController();
int? _selectedGrade;
bool _isDragging = false;
@override
void initState() {
super.initState();
_controller.init();
}
@override
void dispose() {
_controller.dispose();
_addIdController.dispose();
_addNameController.dispose();
_addPwController.dispose();
super.dispose();
}
// ➕ [학생 추가 버튼 동작]
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;
}
final (success, message) = await _controller.addStudentAccount(
studentId: sId,
name: sName,
password: sPw,
grade: _selectedGrade,
);
if (!mounted) return;
if (success) {
_addIdController.clear();
_addNameController.clear();
_addPwController.clear();
setState(() => _selectedGrade = null);
}
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(message)));
}
// 📄 [파일 선택 버튼 동작] "파일 선택"으로 엑셀 고르기
Future<void> _pickExcelFile() async {
final bytes = await pickExcelFileBytes();
if (bytes == null) return;
if (!mounted) return;
await _handleExcelBytes(bytes);
}
// 📄 엑셀 바이트를 파싱해서 미리보기 다이얼로그를 띄운다 (드래그/파일선택 공용).
Future<void> _handleExcelBytes(Uint8List bytes) async {
List<BulkStudentRow> rows;
try {
rows = _controller.parseExcelBytes(bytes);
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text('❌ 엑셀 파일을 읽는 데 실패했습니다: $e')));
return;
}
if (rows.isEmpty) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('⚠️ 유효한 학생 데이터를 찾지 못했습니다. (1행은 머리글로 건너뜁니다)'),
),
);
return;
}
_showBulkPreviewDialog(rows);
}
// 📋 [일괄 등록 확인 팝업] 엑셀에서 뽑아낸 명단을 미리 보여주고 확정받는다.
void _showBulkPreviewDialog(List<BulkStudentRow> rows) {
showDialog(
context: context,
builder: (context) => AlertDialog(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(24)),
title: Text('📄 ${rows.length}명 확인됨'),
content: SizedBox(
width: 400,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'아래 명단으로 계정을 일괄 생성합니다 (초기 비밀번호: 1234).',
style: TextStyle(color: Colors.black54, fontSize: 13),
),
const SizedBox(height: 12),
ConstrainedBox(
constraints: const BoxConstraints(maxHeight: 300),
child: ListView.builder(
shrinkWrap: true,
itemCount: rows.length,
itemBuilder: (context, i) {
final row = rows[i];
return ListTile(
dense: true,
leading: CircleAvatar(
radius: 14,
child: Text(
'${i + 1}',
style: const TextStyle(fontSize: 11),
),
),
title: Text('${row.name} (${row.studentId})'),
trailing: Text(
row.grade != null ? '${row.grade}학년' : '미배정',
style: TextStyle(color: Colors.grey[600]),
),
);
},
),
),
],
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('취소', style: TextStyle(color: Colors.grey)),
),
ElevatedButton(
onPressed: () {
Navigator.pop(context);
_runBulkImport(rows);
},
style: ElevatedButton.styleFrom(
backgroundColor: Colors.orange,
foregroundColor: Colors.white,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
child: Text('${rows.length}명 일괄 등록'),
),
],
),
);
}
// 🚀 실제 일괄 등록 실행 + 진행 상황 표시 + 결과 요약 팝업.
Future<void> _runBulkImport(List<BulkStudentRow> rows) async {
showDialog(
context: context,
barrierDismissible: false,
builder: (context) => ListenableBuilder(
listenable: _controller,
builder: (context, _) => AlertDialog(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(24),
),
content: Row(
children: [
const CircularProgressIndicator(),
const SizedBox(width: 20),
Text(
'등록 중... (${_controller.bulkDone}/${_controller.bulkTotal})',
),
],
),
),
),
);
final result = await _controller.bulkAddStudents(rows);
if (!mounted) return;
Navigator.pop(context); // 진행 팝업 닫기
showDialog(
context: context,
builder: (context) => AlertDialog(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(24)),
title: Text(
result.failures.isEmpty ? '✅ 일괄 등록 완료' : '⚠️ 일괄 등록 완료 (일부 실패)',
),
content: SizedBox(
width: 400,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'성공 ${result.successCount}명 / 실패 ${result.failures.length}명',
),
if (result.failures.isNotEmpty) ...[
const SizedBox(height: 12),
const Text(
'실패 목록',
style: TextStyle(fontWeight: FontWeight.bold),
),
const SizedBox(height: 6),
ConstrainedBox(
constraints: const BoxConstraints(maxHeight: 200),
child: SingleChildScrollView(
child: Text(
result.failures.join('\n'),
style: const TextStyle(fontSize: 12, color: Colors.red),
),
),
),
],
],
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('확인'),
),
],
),
);
}
// 🏫 [목록 행의 "학년" 칩] 눌러서 바로 학년 변경 (미배정/1/2/3학년 중 선택).
Future<void> _showGradeMenu(
BuildContext tileContext,
String studentId,
String studentName,
int? currentGrade,
) async {
final RenderBox box = tileContext.findRenderObject() as RenderBox;
final Offset position = box.localToGlobal(Offset.zero);
final selected = await showMenu<int?>(
context: tileContext,
position: RelativeRect.fromLTRB(
position.dx,
position.dy + box.size.height,
position.dx,
0,
),
items: const [
PopupMenuItem(value: null, child: Text('미배정')),
PopupMenuItem(value: 1, child: Text('1학년')),
PopupMenuItem(value: 2, child: Text('2학년')),
PopupMenuItem(value: 3, child: Text('3학년')),
],
);
if (selected == currentGrade) return; // 취소했거나 같은 값 선택
if (!mounted) return;
final (_, message) = await _controller.updateStudentGrade(
studentId: studentId,
grade: selected,
);
if (!mounted) return;
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(message)));
}
// 🔓 [목록 행의 "기기 리셋" 버튼] 학생이 폰을 바꿨을 때 등록 초기화.
void _confirmResetDevice(String studentId, String studentName) {
showDialog(
context: context,
builder: (ctx) => AlertDialog(
title: const Text(
'⚠️ 기기 등록 초기화',
style: TextStyle(fontWeight: FontWeight.bold, color: Colors.purple),
),
content: Text('$studentName 학생의 스마트폰 기기 등록과 비밀번호(1234)를 초기화하시겠습니까?'),
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);
final (_, message) = await _controller.resetDevice(
studentId,
studentName,
);
if (!mounted) return;
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(message)));
},
child: const Text('초기화 승인'),
),
],
),
);
}
// 🚨 [목록 행의 삭제 버튼] 계정 완전 삭제 확인 팝업.
void _confirmDeleteStudent(String studentId, String studentName) {
showDialog(
context: context,
builder: (ctx) => AlertDialog(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(24)),
title: Row(
children: [
Icon(Icons.warning_amber_rounded, color: Colors.red[700]),
const SizedBox(width: 10),
const Text(
'계정 완전 삭제',
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 18),
),
],
),
content: Text(
'정말로 $studentName ($studentId) 학생의 계정을 영구 삭제하시겠습니까?\n이 작업은 되돌릴 수 없습니다.',
),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx),
child: const Text('취소', style: TextStyle(color: Colors.grey)),
),
ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: Colors.red[600],
foregroundColor: Colors.white,
),
onPressed: () async {
Navigator.pop(ctx);
final (_, message) = await _controller.deleteStudentAccount(
studentId,
);
if (!mounted) return;
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(message)));
},
child: const Text('영구 삭제'),
),
],
),
);
}
@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.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: 12),
DropdownButtonFormField<int>(
initialValue: _selectedGrade,
decoration: const InputDecoration(
labelText: '학년 (선택)',
prefixIcon: Icon(Icons.class_),
),
items: const [
DropdownMenuItem(value: 1, child: Text('1학년')),
DropdownMenuItem(value: 2, child: Text('2학년')),
DropdownMenuItem(value: 3, child: Text('3학년')),
],
onChanged: (value) =>
setState(() => _selectedGrade = value),
),
const SizedBox(height: 20),
SizedBox(
width: double.infinity,
height: 50,
child: ElevatedButton(
onPressed: _controller.isWorking
? null
: _addStudentAccount,
style: ElevatedButton.styleFrom(
backgroundColor: Colors.orange,
foregroundColor: Colors.white,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
child: _controller.isWorking
? const CircularProgressIndicator(
color: Colors.white,
)
: const Text(
'학생 등록 완료',
style: TextStyle(
fontWeight: FontWeight.bold,
fontSize: 15,
),
),
),
),
],
),
),
const SizedBox(height: 36),
// 🏷️ 인디케이터 바 (엑셀 일괄 등록)
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),
DropTarget(
onDragEntered: (_) => setState(() => _isDragging = true),
onDragExited: (_) => setState(() => _isDragging = false),
onDragDone: (details) async {
setState(() => _isDragging = false);
if (details.files.isEmpty) return;
final bytes = await details.files.first.readAsBytes();
if (!mounted) return;
await _handleExcelBytes(bytes);
},
child: InkWell(
onTap: _pickExcelFile,
borderRadius: BorderRadius.circular(24),
child: Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(vertical: 28),
decoration: BoxDecoration(
color: _isDragging ? Colors.orange[50] : Colors.white,
borderRadius: BorderRadius.circular(24),
border: Border.all(
color: _isDragging
? Colors.orange
: Colors.grey.shade300,
width: _isDragging ? 2 : 1,
),
),
child: Column(
children: [
Icon(
Icons.upload_file_rounded,
size: 40,
color: _isDragging
? Colors.orange
: Colors.grey[400],
),
const SizedBox(height: 12),
Text(
_isDragging ? '여기에 놓으세요' : '엑셀 파일을 드래그하거나 눌러서 선택',
style: TextStyle(
fontWeight: FontWeight.bold,
color: _isDragging
? Colors.orange[800]
: Colors.black87,
),
),
const SizedBox(height: 4),
Text(
'.xlsx · 1행은 머리글, A열=학번 B열=이름 C열=학년(선택)',
style: TextStyle(
fontSize: 12,
color: Colors.grey[500],
),
textAlign: TextAlign.center,
),
],
),
),
),
),
const SizedBox(height: 36),
// 🏷️ 인디케이터 바 (전체 학생 목록)
Row(
children: [
Container(
width: 4,
height: 16,
decoration: BoxDecoration(
color: Colors.blue,
borderRadius: BorderRadius.circular(2),
),
),
const SizedBox(width: 8),
const Text(
'전체 학생 목록',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
const Spacer(),
IconButton(
icon: const Icon(Icons.refresh_rounded),
onPressed: _controller.isLoadingStudents
? null
: _controller.fetchStudents,
tooltip: '새로고침',
),
],
),
const SizedBox(height: 8),
const Text(
'학년 칩을 눌러 학년을 바꾸고, 기기 리셋은 학생이 폰을 바꿨을 때 사용하세요.',
style: TextStyle(color: Colors.black54, fontSize: 12),
),
const SizedBox(height: 12),
_controller.isLoadingStudents
? const Padding(
padding: EdgeInsets.symmetric(vertical: 40),
child: Center(child: CircularProgressIndicator()),
)
: _controller.students.isEmpty
? const Padding(
padding: EdgeInsets.symmetric(vertical: 24),
child: Center(
child: Text(
'가입된 학생 계정이 없습니다.',
style: TextStyle(color: Colors.grey),
),
),
)
: ListView.builder(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
itemCount: _controller.students.length,
itemBuilder: (context, index) {
final student = _controller.students[index];
final String studentId = student['id'].toString();
final String studentName = student['name'].toString();
final int? grade = student['grade'] as int?;
final bool needsReset = student['device'] == '초기화 필요';
return Card(
elevation: 1,
margin: const EdgeInsets.only(bottom: 10),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
child: ListTile(
contentPadding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 8,
),
leading: CircleAvatar(
backgroundColor: needsReset
? Colors.red[50]
: Colors.blue[50],
child: Icon(
needsReset ? Icons.lock_reset : Icons.person,
color: needsReset ? Colors.red : Colors.blue,
),
),
title: Text(
'$studentName ($studentId)',
style: const TextStyle(
fontWeight: FontWeight.bold,
),
),
subtitle: Text(
needsReset ? '기기 초기화 승인 대기중' : '정상 등록 상태',
style: TextStyle(
color: needsReset ? Colors.red : Colors.grey,
fontWeight: needsReset
? FontWeight.bold
: FontWeight.normal,
),
),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
Builder(
builder: (chipContext) => ActionChip(
label: Text(
grade != null ? '$grade학년' : '미배정',
),
onPressed: () => _showGradeMenu(
chipContext,
studentId,
studentName,
grade,
),
),
),
IconButton(
icon: const Icon(
Icons.lock_reset,
color: Colors.purple,
),
tooltip: '기기 리셋',
onPressed: () => _confirmResetDevice(
studentId,
studentName,
),
),
IconButton(
icon: const Icon(
Icons.delete_forever_rounded,
color: Colors.redAccent,
),
tooltip: '계정 영구 삭제',
onPressed: () => _confirmDeleteStudent(
studentId,
studentName,
),
),
],
),
),
);
},
),
],
),
),
);
},
);
}
}
@@ -6,6 +6,10 @@
#include "generated_plugin_registrant.h"
#include <desktop_drop/desktop_drop_plugin.h>
void fl_register_plugins(FlPluginRegistry* registry) {
g_autoptr(FlPluginRegistrar) desktop_drop_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "DesktopDropPlugin");
desktop_drop_plugin_register_with_registrar(desktop_drop_registrar);
}
+1
View File
@@ -3,6 +3,7 @@
#
list(APPEND FLUTTER_PLUGIN_LIST
desktop_drop
)
list(APPEND FLUTTER_FFI_PLUGIN_LIST
@@ -5,13 +5,17 @@
import FlutterMacOS
import Foundation
import desktop_drop
import device_info_plus
import file_picker_darwin
import firebase_core
import firebase_messaging
import flutter_local_notifications
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
DesktopDropPlugin.register(with: registry.registrar(forPlugin: "DesktopDropPlugin"))
DeviceInfoPlusMacosPlugin.register(with: registry.registrar(forPlugin: "DeviceInfoPlusMacosPlugin"))
FilePickerPlugin.register(with: registry.registrar(forPlugin: "FilePickerPlugin"))
FLTFirebaseCorePlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseCorePlugin"))
FLTFirebaseMessagingPlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseMessagingPlugin"))
FlutterLocalNotificationsPlugin.register(with: registry.registrar(forPlugin: "FlutterLocalNotificationsPlugin"))
+113 -1
View File
@@ -9,6 +9,22 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.3.75"
android_file_picker:
dependency: transitive
description:
name: android_file_picker
sha256: "1f111ed6bb33724ba782cc86357e3fd97f57051d55fc61adf33dcfc5049a1581"
url: "https://pub.dev"
source: hosted
version: "1.0.3"
archive:
dependency: transitive
description:
name: archive
sha256: cb6a278ef2dbb298455e1a713bda08524a175630ec643a242c399c932a0a1f7d
url: "https://pub.dev"
source: hosted
version: "3.6.1"
args:
dependency: transitive
description:
@@ -57,6 +73,22 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.19.1"
cross_file:
dependency: transitive
description:
name: cross_file
sha256: f141ea4f277af142a0356955707f6556f37b03947d39d55585981a06ca437bd6
url: "https://pub.dev"
source: hosted
version: "0.3.5+5"
crypto:
dependency: transitive
description:
name: crypto
sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf
url: "https://pub.dev"
source: hosted
version: "3.0.7"
cupertino_icons:
dependency: "direct main"
description:
@@ -73,6 +105,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "0.7.14"
desktop_drop:
dependency: "direct main"
description:
name: desktop_drop
sha256: c65d1f959ccf4bb6803f984303d03e1e0dd4c0f73a3a2eca2d1b46d48ea16999
url: "https://pub.dev"
source: hosted
version: "0.8.2"
device_info_plus:
dependency: "direct main"
description:
@@ -89,6 +129,22 @@ packages:
url: "https://pub.dev"
source: hosted
version: "8.1.0"
equatable:
dependency: transitive
description:
name: equatable
sha256: "3bce007a596ff8b3119c45d68aaef631272537c03d30e5d4534dd24bf4c5eaa2"
url: "https://pub.dev"
source: hosted
version: "2.1.0"
excel:
dependency: "direct main"
description:
name: excel
sha256: "1a15327dcad260d5db21d1f6e04f04838109b39a2f6a84ea486ceda36e468780"
url: "https://pub.dev"
source: hosted
version: "4.0.6"
fake_async:
dependency: transitive
description:
@@ -121,6 +177,46 @@ packages:
url: "https://pub.dev"
source: hosted
version: "7.0.1"
file_picker:
dependency: "direct main"
description:
name: file_picker
sha256: b7acb5d123cb398f6b4a8a38e43777545254f8fc1fabef3ee11cbbb56aece9c8
url: "https://pub.dev"
source: hosted
version: "12.1.2"
file_picker_darwin:
dependency: transitive
description:
name: file_picker_darwin
sha256: "6e7cae501d48fd57a27911608db718ebd898e6ccf934bf09e33a8c0d0365bec0"
url: "https://pub.dev"
source: hosted
version: "1.0.4"
file_picker_linux:
dependency: transitive
description:
name: file_picker_linux
sha256: f0f01ed42967b7355f6f25c8b121ea531d1948e2a9b4b44f4b4de8489d7b04ae
url: "https://pub.dev"
source: hosted
version: "1.0.2"
file_picker_platform_interface:
dependency: transitive
description:
name: file_picker_platform_interface
sha256: "11ef1b5c14d9186b4788cc273dd8d5cb81bca847145060991a28234ff7916fc1"
url: "https://pub.dev"
source: hosted
version: "3.2.0"
file_picker_web:
dependency: transitive
description:
name: file_picker_web
sha256: df472142f63c4557fdfbb375c81454ca1c251491069eefcdc6a866bc12f8750b
url: "https://pub.dev"
source: hosted
version: "3.0.3"
firebase_core:
dependency: "direct main"
description:
@@ -517,6 +613,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.4.0"
universal_platform:
dependency: transitive
description:
name: universal_platform
sha256: "64e16458a0ea9b99260ceb5467a214c1f298d647c659af1bff6d3bf82536b1ec"
url: "https://pub.dev"
source: hosted
version: "1.1.0"
vector_math:
dependency: transitive
description:
@@ -550,7 +654,7 @@ packages:
source: hosted
version: "15.2.0"
web:
dependency: transitive
dependency: "direct main"
description:
name: web
sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a"
@@ -573,6 +677,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "3.0.3"
windows_file_picker:
dependency: transitive
description:
name: windows_file_picker
sha256: "225f58e64c15c2d7b34fb8faf3ca188831967f26b5a723d7c3a7969e41c9b5a5"
url: "https://pub.dev"
source: hosted
version: "1.1.0"
xdg_directories:
dependency: transitive
description:
+5 -6
View File
@@ -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.0.0+1
version: 1.2.0+6
environment:
sdk: ^3.12.2
@@ -52,6 +52,10 @@ dependencies:
# 📳 무단 반출 감지 시 진동 (백그라운드 서비스에서도 동작하도록 HapticFeedback 대신 사용)
vibration: ^3.2.0
excel: ^4.0.6
file_picker: ^12.1.2
desktop_drop: ^0.8.2
web: ^1.1.1
dev_dependencies:
flutter_test:
@@ -63,11 +67,6 @@ dev_dependencies:
# package. See that file for information about deactivating specific lint
# rules and activating additional ones.
flutter_lints: ^6.0.0
# For information on the generic Dart part of this file, see the
# following page: https://dart.dev/tools/pub/pubspec
# The following section is specific to Flutter packages.
flutter:
# The following line ensures that the Material Icons font is
@@ -6,10 +6,13 @@
#include "generated_plugin_registrant.h"
#include <desktop_drop/desktop_drop_plugin.h>
#include <firebase_core/firebase_core_plugin_c_api.h>
#include <permission_handler_windows/permission_handler_windows_plugin.h>
void RegisterPlugins(flutter::PluginRegistry* registry) {
DesktopDropPluginRegisterWithRegistrar(
registry->GetRegistrarForPlugin("DesktopDropPlugin"));
FirebaseCorePluginCApiRegisterWithRegistrar(
registry->GetRegistrarForPlugin("FirebaseCorePluginCApi"));
PermissionHandlerWindowsPluginRegisterWithRegistrar(
+1
View File
@@ -3,6 +3,7 @@
#
list(APPEND FLUTTER_PLUGIN_LIST
desktop_drop
firebase_core
permission_handler_windows
)