Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
80667d20f1 | ||
|
|
f5aa832ad9 | ||
|
|
4f64315197 | ||
|
|
1140747af2 | ||
|
|
cb0984ed6f | ||
|
|
e8b375a18c | ||
|
|
8a9b414535 | ||
|
|
bdc79b94c3 | ||
|
|
86a8f7b9d1 | ||
|
|
4cf759de62 | ||
|
|
89487a7dfc | ||
|
|
d183370cca | ||
|
|
7277eb5962 | ||
|
|
094ce2292c | ||
|
|
93104362be | ||
|
|
73772de78a | ||
|
|
a59a3054fb | ||
|
|
26e54a1798 | ||
|
|
04892d5e65 | ||
|
|
a3894cc878 | ||
|
|
ce77c32026 | ||
|
|
a0403e40f2 | ||
|
|
7e83884d33 | ||
|
|
0467f31996 | ||
|
|
2d87a681ca | ||
|
|
c40cbed0bb | ||
|
|
5d37d3e3b1 | ||
|
|
527661b6e5 | ||
|
|
d9ca4913d4 | ||
|
|
3946e2ef04 | ||
|
|
f80f1f7837 | ||
|
|
32203baffc | ||
|
|
20f751b65e | ||
|
|
2d0768da73 | ||
|
|
2fb24f5e46 | ||
|
|
75b906c648 | ||
|
|
73542fa11c | ||
|
|
79938898a0 | ||
|
|
f885719674 | ||
|
|
c94f51a033 | ||
|
|
7495ece0c8 | ||
|
|
83c148186d | ||
|
|
9089b9032e | ||
|
|
dce5f1ad1b | ||
|
|
dcb5dbc71b | ||
|
|
9217a13206 | ||
|
|
b1c8447eaf | ||
|
|
2b3227f268 | ||
|
|
383de8b3a1 | ||
|
|
83cb4a5300 | ||
|
|
f1b9f14eb0 | ||
|
|
f49d6d8722 | ||
|
|
75f12b8c46 | ||
|
|
d9ab6adb51 | ||
|
|
eba23ecf9b | ||
|
|
e9d31ce41e | ||
|
|
517aa791b5 | ||
|
|
5b3eb2fcf8 | ||
|
|
debf733afd | ||
|
|
eccd55c201 | ||
|
|
fd0bc4f814 | ||
|
|
ccff219874 | ||
|
|
94ba3efff7 | ||
|
|
a622249008 | ||
|
|
db903fcdef | ||
|
|
d1c59c521b | ||
|
|
9354e2998a | ||
|
|
f9fa05f817 | ||
|
|
26d143d713 | ||
|
|
29e319110c | ||
|
|
dca7912f8d | ||
|
|
4e12b279e2 | ||
|
|
d864927957 | ||
|
|
61aedac312 | ||
|
|
5ab1c4c37c | ||
|
|
3a62ebacd8 | ||
|
|
26fb4c32cf | ||
|
|
4137a6815c | ||
|
|
4ff063c76d | ||
|
|
77688170b7 | ||
|
|
c17a28bad9 | ||
|
|
9adaba10cd | ||
|
|
77ef666e16 | ||
|
|
c8ed8f2fd2 | ||
|
|
49ab432fe9 |
@@ -43,3 +43,10 @@ app.*.map.json
|
|||||||
/android/app/debug
|
/android/app/debug
|
||||||
/android/app/profile
|
/android/app/profile
|
||||||
/android/app/release
|
/android/app/release
|
||||||
|
|
||||||
|
# Gradle build output (실수로 커밋되면 리포트 HTML 등 큰 파일이 섞여 들어옴)
|
||||||
|
android/**/build/
|
||||||
|
ios/**/build/
|
||||||
|
|
||||||
|
# Firebase CLI 로컬 캐시 (배포 이력 해시 등, 소스 아님)
|
||||||
|
.firebase/
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
# school_attendance
|
# school_attendance
|
||||||
|
|
||||||
A new Flutter project.
|
학교생활 편의 프로젝트
|
||||||
|
|
||||||
|
|
||||||
## Getting Started
|
## Getting Started
|
||||||
|
|
||||||
|
|||||||
@@ -26,6 +26,11 @@ subprojects {
|
|||||||
tasks.register<Delete>("clean") {
|
tasks.register<Delete>("clean") {
|
||||||
delete(rootProject.layout.buildDirectory)
|
delete(rootProject.layout.buildDirectory)
|
||||||
}
|
}
|
||||||
|
subprojects {
|
||||||
|
if (project.name == "desktop_drop") {
|
||||||
|
pluginManager.apply("org.jetbrains.kotlin.android")
|
||||||
|
}
|
||||||
|
}
|
||||||
subprojects {
|
subprojects {
|
||||||
plugins.withId("com.android.library") {
|
plugins.withId("com.android.library") {
|
||||||
extensions.configure<com.android.build.gradle.BaseExtension> {
|
extensions.configure<com.android.build.gradle.BaseExtension> {
|
||||||
|
|||||||
@@ -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"}]}]}}
|
||||||
@@ -11,6 +11,7 @@
|
|||||||
331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; };
|
331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; };
|
||||||
3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; };
|
3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; };
|
||||||
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; };
|
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 */; };
|
7884E8682EC3CC0700C636F2 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */; };
|
||||||
78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */ = {isa = PBXBuildFile; productRef = 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */; };
|
78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */ = {isa = PBXBuildFile; productRef = 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */; };
|
||||||
97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = "<group>"; };
|
||||||
@@ -122,6 +124,7 @@
|
|||||||
1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */,
|
1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */,
|
||||||
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */,
|
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */,
|
||||||
74858FAE1ED2DC5600515810 /* AppDelegate.swift */,
|
74858FAE1ED2DC5600515810 /* AppDelegate.swift */,
|
||||||
|
E653BC1DC10E8731479786F7 /* ProximityMonitorPlugin.swift */,
|
||||||
7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */,
|
7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */,
|
||||||
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */,
|
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */,
|
||||||
);
|
);
|
||||||
@@ -282,6 +285,7 @@
|
|||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
files = (
|
files = (
|
||||||
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */,
|
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */,
|
||||||
|
5FA389C5018E5592CC402E5C /* ProximityMonitorPlugin.swift in Sources */,
|
||||||
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */,
|
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */,
|
||||||
7884E8682EC3CC0700C636F2 /* SceneDelegate.swift in Sources */,
|
7884E8682EC3CC0700C636F2 /* SceneDelegate.swift in Sources */,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -12,5 +12,8 @@ import UIKit
|
|||||||
|
|
||||||
func didInitializeImplicitFlutterEngine(_ engineBridge: FlutterImplicitEngineBridge) {
|
func didInitializeImplicitFlutterEngine(_ engineBridge: FlutterImplicitEngineBridge) {
|
||||||
GeneratedPluginRegistrant.register(with: engineBridge.pluginRegistry)
|
GeneratedPluginRegistrant.register(with: engineBridge.pluginRegistry)
|
||||||
|
ProximityMonitorPlugin.register(
|
||||||
|
with: engineBridge.pluginRegistry.registrar(forPlugin: "ProximityMonitorPlugin")
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,13 +5,28 @@ import 'package:device_info_plus/device_info_plus.dart';
|
|||||||
import 'package:firebase_messaging/firebase_messaging.dart';
|
import 'package:firebase_messaging/firebase_messaging.dart';
|
||||||
import 'package:flutter/foundation.dart' show kIsWeb;
|
import 'package:flutter/foundation.dart' show kIsWeb;
|
||||||
|
|
||||||
// 🌐 기존에 사용하시던 실제 백엔드 서버 도메인 주소로 통일합니다.
|
// 🌐 학교마다 다른 값들. 빌드할 때 --dart-define으로 안 넘기면 지금 쓰는
|
||||||
const String baseUrl = 'https://backsanhi.mcv.kr';
|
// 백산고 값이 그대로 기본값이 된다 (기존 빌드 명령이 안 깨지게 하기 위함).
|
||||||
|
// 예: flutter build web --dart-define=BASE_URL=https://다른학교도메인.com
|
||||||
|
const String baseUrl = String.fromEnvironment(
|
||||||
|
'BASE_URL',
|
||||||
|
defaultValue: 'https://api.backsanhi.shop',
|
||||||
|
);
|
||||||
|
|
||||||
// 🔑 웹에서 FCM 토큰을 받으려면 필요한 VAPID 키
|
// 🔑 웹에서 FCM 토큰을 받으려면 필요한 VAPID 키
|
||||||
// (Firebase Console > 프로젝트 설정 > Cloud Messaging > 웹 구성 > 웹 푸시 인증서에서 발급)
|
// (Firebase Console > 프로젝트 설정 > Cloud Messaging > 웹 구성 > 웹 푸시 인증서에서 발급)
|
||||||
const String webVapidKey =
|
// 학교마다 Firebase 프로젝트가 다르므로 VAPID 키도 학교별로 --dart-define=WEB_VAPID_KEY=...로 지정.
|
||||||
'BG4LnirukBdW3cXVWxhvY9pcjevBKDhpsaq1SLZdBs2cOEiwLAFyuuZLkMqpEGp9uOuHdPOe_y3FdwdZaz3qdfk';
|
const String webVapidKey = String.fromEnvironment(
|
||||||
|
'WEB_VAPID_KEY',
|
||||||
|
defaultValue:
|
||||||
|
'BG4LnirukBdW3cXVWxhvY9pcjevBKDhpsaq1SLZdBs2cOEiwLAFyuuZLkMqpEGp9uOuHdPOe_y3FdwdZaz3qdfk',
|
||||||
|
);
|
||||||
|
|
||||||
|
// 🏫 앱/로그인 화면에 표시할 학교 이름. --dart-define=SCHOOL_NAME=...으로 학교별 지정.
|
||||||
|
const String schoolName = String.fromEnvironment(
|
||||||
|
'SCHOOL_NAME',
|
||||||
|
defaultValue: '백산고등학교',
|
||||||
|
);
|
||||||
|
|
||||||
// 권한 요청 및 FCM 토큰을 가져와서 콘솔에 출력하는 함수
|
// 권한 요청 및 FCM 토큰을 가져와서 콘솔에 출력하는 함수
|
||||||
Future<void> printFCMToken() async {
|
Future<void> printFCMToken() async {
|
||||||
|
|||||||
@@ -0,0 +1,134 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
// 📅 선생님 호출 화면에서 "지금 수업 중" 배지를 보여주기 위한 시간표 데이터.
|
||||||
|
// 원래 별도 Firebase 페이지(index.html)에 있던 데이터를 그대로 옮기면서,
|
||||||
|
// "김지" 약어가 실제로는 조신현 선생님을 가리키던 오타를 "조신"으로 바로잡았다.
|
||||||
|
// (다른 모든 약어는 이름 앞 두 글자 규칙을 따르는데 이것만 깨져 있었음)
|
||||||
|
class PeriodTime {
|
||||||
|
final int period;
|
||||||
|
final String start;
|
||||||
|
final String end;
|
||||||
|
const PeriodTime(this.period, this.start, this.end);
|
||||||
|
}
|
||||||
|
|
||||||
|
const List<PeriodTime> kPeriodSettings = [
|
||||||
|
PeriodTime(1, "08:40", "09:30"),
|
||||||
|
PeriodTime(2, "09:40", "10:30"),
|
||||||
|
PeriodTime(3, "10:40", "11:30"),
|
||||||
|
PeriodTime(4, "11:40", "12:30"),
|
||||||
|
PeriodTime(5, "13:30", "14:20"),
|
||||||
|
PeriodTime(6, "14:30", "15:20"),
|
||||||
|
PeriodTime(7, "15:30", "16:20"),
|
||||||
|
];
|
||||||
|
|
||||||
|
// 이름 앞 두 글자 약어 → 전체 이름. ("조신":"조신현" 으로 수정됨 - 원래 "김지"였던 오타)
|
||||||
|
const Map<String, String> kTeacherAbbrevMap = {
|
||||||
|
"손계": "손계강",
|
||||||
|
"조신": "조신현",
|
||||||
|
"진경": "진경아",
|
||||||
|
"김만": "김만중",
|
||||||
|
"이길": "이길석",
|
||||||
|
"김효": "김효정",
|
||||||
|
"이부": "이부원",
|
||||||
|
"고경": "고경철",
|
||||||
|
"김경": "김경원",
|
||||||
|
"양성": "양성욱",
|
||||||
|
"김명": "김명선",
|
||||||
|
"김현": "김현진",
|
||||||
|
"박은": "박은호",
|
||||||
|
"심윤": "심윤주",
|
||||||
|
"조영": "조영희",
|
||||||
|
"이정": "이정상",
|
||||||
|
"조용": "조용우",
|
||||||
|
"이민": "이민혁",
|
||||||
|
};
|
||||||
|
|
||||||
|
// day: 1=월 ~ 5=금, period: 1~7. 값은 그 시간에 수업 중인 선생님 약어 목록.
|
||||||
|
const Map<int, Map<int, List<String>>> kFullTimetable = {
|
||||||
|
1: {
|
||||||
|
1: ["손계", "조신", "진경", "김만", "이길", "김효", "이부"],
|
||||||
|
2: ["김경", "손계", "이길", "조영", "김효", "양성", "김명", "김현"],
|
||||||
|
3: ["박은", "심윤", "조영", "고경", "이정", "이부", "김경"],
|
||||||
|
4: ["심윤", "진경", "김효", "이길", "이정", "김만", "김명", "김현", "양성"],
|
||||||
|
5: ["박은", "손계", "이정", "고경", "조영", "김현", "양성", "김명"],
|
||||||
|
6: ["이민", "심윤", "고경", "김만", "이길", "이부", "김경"],
|
||||||
|
7: ["이민", "김경", "박은", "김만", "이길", "조영", "양성", "조신", "손계"],
|
||||||
|
},
|
||||||
|
2: {
|
||||||
|
1: ["손계", "이정", "박은", "김만", "심윤", "이길", "조영", "김현", "조신", "양성"],
|
||||||
|
2: ["김효", "김경", "이정", "고경", "조영", "김만", "양성", "김현", "김명"],
|
||||||
|
3: ["진경", "심윤", "손계", "이민", "이부", "김명", "조용", "조신"],
|
||||||
|
4: ["이정", "진경", "김효", "이민", "이부", "조용", "양성", "김현"],
|
||||||
|
5: ["박은", "김효", "심윤", "이민", "이부", "김명", "김현", "손계"],
|
||||||
|
6: ["김경", "박은", "진경", "이민", "이부", "양성", "김명", "조용"],
|
||||||
|
7: ["진경", "손계", "이부", "김만", "이길", "김효", "박은", "김경", "김현"],
|
||||||
|
},
|
||||||
|
3: {
|
||||||
|
1: ["김효", "김경", "이민", "이부", "김명", "김현", "이정"],
|
||||||
|
2: ["조용", "진경", "김효", "조영", "김만", "이길", "이부", "고경"],
|
||||||
|
3: ["심윤", "손계", "조용", "이민", "박은", "조신", "양성", "이정", "김명"],
|
||||||
|
4: ["진경", "김효", "심윤", "이민", "박은", "조신", "이정", "양성", "김현"],
|
||||||
|
},
|
||||||
|
4: {
|
||||||
|
1: ["심윤", "김경", "조신", "조영", "이길", "김만", "이정", "양성", "김명"],
|
||||||
|
2: ["박은", "진경", "김만", "심윤", "이길", "조영", "이부", "고경"],
|
||||||
|
3: ["진경", "김효", "이민", "이부", "김현", "김명", "이정"],
|
||||||
|
4: ["손계", "김효", "김경", "이민", "박은", "조신", "양성", "이정", "김현"],
|
||||||
|
5: ["조신", "진경", "심윤", "김만", "고경", "조영", "이부", "이민"],
|
||||||
|
6: ["김경", "심윤", "손계", "이길", "조영", "김효", "박은", "진경", "조신"],
|
||||||
|
7: ["김효", "손계", "박은", "고경", "김만", "이길", "김현", "김명", "양성"],
|
||||||
|
},
|
||||||
|
5: {
|
||||||
|
1: ["손계", "박은", "이정", "김만", "조영", "이길", "김현", "김명", "양성"],
|
||||||
|
2: ["이정", "손계", "김경", "이길", "고경", "조영", "김명", "조신", "김현"],
|
||||||
|
3: ["진경", "심윤", "조용", "고경", "김만", "김효", "이부", "김경"],
|
||||||
|
4: ["조용", "이정", "진경", "김만", "심윤", "이길", "조영", "양성", "김현", "김명"],
|
||||||
|
5: ["진경", "손계", "이부", "조용", "조신", "고경", "박은", "김경", "김현"],
|
||||||
|
7: ["김효", "조용", "손계", "조영", "이길", "김만", "이부", "고경"],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/// 지금 이 순간(day: 1~5, HH:mm) 수업 중인 선생님 전체 이름 목록. 주말/쉬는시간이면 빈 목록.
|
||||||
|
List<String> teachersInClassNow(int weekday, String hhmm) {
|
||||||
|
if (weekday < 1 || weekday > 5) return [];
|
||||||
|
PeriodTime? current;
|
||||||
|
for (final p in kPeriodSettings) {
|
||||||
|
if (hhmm.compareTo(p.start) >= 0 && hhmm.compareTo(p.end) <= 0) {
|
||||||
|
current = p;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (current == null) return [];
|
||||||
|
final abbrevs = kFullTimetable[weekday]?[current.period] ?? [];
|
||||||
|
return abbrevs.map((a) => kTeacherAbbrevMap[a]).whereType<String>().toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
const Map<String, IconData> kLocationIcons = {
|
||||||
|
"교무실": Icons.business_rounded,
|
||||||
|
"급식실": Icons.restaurant_rounded,
|
||||||
|
"과학실": Icons.science_rounded,
|
||||||
|
"도서관": Icons.menu_book_rounded,
|
||||||
|
"출장": Icons.directions_car_rounded,
|
||||||
|
"조퇴/퇴근": Icons.home_rounded,
|
||||||
|
"상담실": Icons.forum_rounded,
|
||||||
|
"기숙사": Icons.hotel_rounded,
|
||||||
|
};
|
||||||
|
|
||||||
|
const List<String> kLocationOptions = [
|
||||||
|
"교무실",
|
||||||
|
"급식실",
|
||||||
|
"과학실",
|
||||||
|
"도서관",
|
||||||
|
"출장",
|
||||||
|
"조퇴/퇴근",
|
||||||
|
"상담실",
|
||||||
|
"기숙사",
|
||||||
|
];
|
||||||
|
|
||||||
|
const List<String> kCallPurposes = [
|
||||||
|
"간단한 용무",
|
||||||
|
"질문 있어요",
|
||||||
|
"상담 신청",
|
||||||
|
"과제 제출",
|
||||||
|
"물건 전달",
|
||||||
|
];
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'nfc_poccket_checkin_screen.dart';
|
import 'ui/nfc_poccket_checkin_screen.dart';
|
||||||
|
|
||||||
// 🧪 디버그 전용 진입점: NFC 주머니 체크인 + 조도 센서 감시 화면을 바로 테스트하기 위한 파일.
|
// 🧪 디버그 전용 진입점: NFC 주머니 체크인 + 조도 센서 감시 화면을 바로 테스트하기 위한 파일.
|
||||||
void main() {
|
void main() {
|
||||||
@@ -13,10 +13,7 @@ class DebugPocketApp extends StatelessWidget {
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return const MaterialApp(
|
return const MaterialApp(
|
||||||
debugShowCheckedModeBanner: false,
|
debugShowCheckedModeBanner: false,
|
||||||
home: NfcPocketCheckInScreen(
|
home: NfcPocketCheckInScreen(studentId: "2061", studentName: "디버그테스트"),
|
||||||
studentId: "2061",
|
|
||||||
studentName: "디버그테스트",
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
// 🗣️ 커뮤니티 게시판 - 카테고리별 게시글 목록 화면의 기능(서버 통신/상태) 담당 컨트롤러.
|
||||||
|
import 'dart:convert';
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:http/http.dart' as http;
|
||||||
|
import '../config.dart';
|
||||||
|
|
||||||
|
class BoardListController extends ChangeNotifier {
|
||||||
|
final String category;
|
||||||
|
final String studentId;
|
||||||
|
|
||||||
|
BoardListController({required this.category, required this.studentId});
|
||||||
|
|
||||||
|
bool _isLoading = true;
|
||||||
|
bool _isLoadingMore = false;
|
||||||
|
bool _hasMore = true;
|
||||||
|
int _page = 1;
|
||||||
|
List<dynamic> _posts = [];
|
||||||
|
bool _disposed = false;
|
||||||
|
|
||||||
|
bool get isLoading => _isLoading;
|
||||||
|
bool get isLoadingMore => _isLoadingMore;
|
||||||
|
bool get hasMore => _hasMore;
|
||||||
|
List<dynamic> get posts => _posts;
|
||||||
|
|
||||||
|
static const int _pageSize = 20;
|
||||||
|
|
||||||
|
void _safeNotify() {
|
||||||
|
if (!_disposed) notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_disposed = true;
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> init() => refresh();
|
||||||
|
|
||||||
|
Future<void> refresh() async {
|
||||||
|
_isLoading = true;
|
||||||
|
_page = 1;
|
||||||
|
_hasMore = true;
|
||||||
|
_safeNotify();
|
||||||
|
await _fetchPage(replace: true);
|
||||||
|
_isLoading = false;
|
||||||
|
_safeNotify();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> loadMore() async {
|
||||||
|
if (_isLoadingMore || !_hasMore || _isLoading) return;
|
||||||
|
_isLoadingMore = true;
|
||||||
|
_safeNotify();
|
||||||
|
_page += 1;
|
||||||
|
await _fetchPage(replace: false);
|
||||||
|
_isLoadingMore = false;
|
||||||
|
_safeNotify();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _fetchPage({required bool replace}) async {
|
||||||
|
try {
|
||||||
|
final response = await http.get(
|
||||||
|
Uri.parse(
|
||||||
|
'$baseUrl/api/board/posts?category=$category&studentId=$studentId&page=$_page',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (response.statusCode == 200) {
|
||||||
|
final data = jsonDecode(utf8.decode(response.bodyBytes));
|
||||||
|
final fetched = (data['posts'] as List<dynamic>?) ?? [];
|
||||||
|
_posts = replace ? fetched : [..._posts, ...fetched];
|
||||||
|
_hasMore = fetched.length >= _pageSize;
|
||||||
|
}
|
||||||
|
} catch (_) {
|
||||||
|
// 조용히 무시 - 마지막으로 받아온 목록을 유지한다.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
// 💬 커뮤니티 게시판 - 게시글 상세(+댓글) 화면의 기능(서버 통신/상태) 담당 컨트롤러.
|
||||||
|
import 'dart:convert';
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:http/http.dart' as http;
|
||||||
|
import '../config.dart';
|
||||||
|
|
||||||
|
class BoardPostController extends ChangeNotifier {
|
||||||
|
final int postId;
|
||||||
|
final String studentId;
|
||||||
|
|
||||||
|
BoardPostController({required this.postId, required this.studentId});
|
||||||
|
|
||||||
|
bool _isLoading = true;
|
||||||
|
bool _isBusy = false;
|
||||||
|
bool _postDeleted = false;
|
||||||
|
Map<String, dynamic>? _post;
|
||||||
|
List<dynamic> _comments = [];
|
||||||
|
bool _disposed = false;
|
||||||
|
|
||||||
|
bool get isLoading => _isLoading;
|
||||||
|
bool get isBusy => _isBusy;
|
||||||
|
bool get postDeleted => _postDeleted;
|
||||||
|
Map<String, dynamic>? get post => _post;
|
||||||
|
List<dynamic> get comments => _comments;
|
||||||
|
|
||||||
|
void _safeNotify() {
|
||||||
|
if (!_disposed) notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_disposed = true;
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> init() => fetchDetail();
|
||||||
|
|
||||||
|
Future<void> fetchDetail() async {
|
||||||
|
_isLoading = true;
|
||||||
|
_safeNotify();
|
||||||
|
try {
|
||||||
|
final response = await http.get(
|
||||||
|
Uri.parse('$baseUrl/api/board/posts/$postId?studentId=$studentId'),
|
||||||
|
);
|
||||||
|
final result = jsonDecode(utf8.decode(response.bodyBytes));
|
||||||
|
if (response.statusCode == 200 && result['status'] == 'success') {
|
||||||
|
_post = Map<String, dynamic>.from(result['post']);
|
||||||
|
_comments = result['comments'] ?? [];
|
||||||
|
} else {
|
||||||
|
_postDeleted = true;
|
||||||
|
}
|
||||||
|
} catch (_) {
|
||||||
|
// 조용히 무시 - 마지막으로 받아온 상태를 유지한다.
|
||||||
|
} finally {
|
||||||
|
_isLoading = false;
|
||||||
|
_safeNotify();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<(bool success, String message)> addComment(String content) async {
|
||||||
|
if (content.trim().isEmpty) return (false, '댓글 내용을 입력해주세요.');
|
||||||
|
_isBusy = true;
|
||||||
|
_safeNotify();
|
||||||
|
try {
|
||||||
|
final response = await http.post(
|
||||||
|
Uri.parse('$baseUrl/api/board/posts/$postId/comments'),
|
||||||
|
headers: {"Content-Type": "application/json"},
|
||||||
|
body: jsonEncode({"studentId": studentId, "content": content.trim()}),
|
||||||
|
);
|
||||||
|
final result = jsonDecode(utf8.decode(response.bodyBytes));
|
||||||
|
if (response.statusCode == 200 && result['status'] == 'success') {
|
||||||
|
await fetchDetail();
|
||||||
|
return (true, '댓글이 등록되었습니다.');
|
||||||
|
}
|
||||||
|
return (false, '${result['message'] ?? '댓글 등록 실패'}');
|
||||||
|
} catch (e) {
|
||||||
|
return (false, '네트워크 에러: $e');
|
||||||
|
} finally {
|
||||||
|
_isBusy = false;
|
||||||
|
_safeNotify();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<(bool success, String message)> deletePost() async {
|
||||||
|
return _delete('$baseUrl/api/board/posts/$postId');
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<(bool success, String message)> deleteComment(int commentId) async {
|
||||||
|
final (success, message) = await _delete(
|
||||||
|
'$baseUrl/api/board/comments/$commentId',
|
||||||
|
);
|
||||||
|
if (success) await fetchDetail();
|
||||||
|
return (success, message);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<(bool success, String message)> _delete(String url) async {
|
||||||
|
_isBusy = true;
|
||||||
|
_safeNotify();
|
||||||
|
try {
|
||||||
|
final response = await http.delete(
|
||||||
|
Uri.parse(url),
|
||||||
|
headers: {"Content-Type": "application/json"},
|
||||||
|
body: jsonEncode({"studentId": studentId}),
|
||||||
|
);
|
||||||
|
final result = jsonDecode(utf8.decode(response.bodyBytes));
|
||||||
|
if (response.statusCode == 200 && result['status'] == 'success') {
|
||||||
|
return (true, '${result['message'] ?? '삭제되었습니다.'}');
|
||||||
|
}
|
||||||
|
return (false, '${result['message'] ?? '삭제 실패'}');
|
||||||
|
} catch (e) {
|
||||||
|
return (false, '네트워크 에러: $e');
|
||||||
|
} finally {
|
||||||
|
_isBusy = false;
|
||||||
|
_safeNotify();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<(bool success, String message)> report({
|
||||||
|
required String targetType,
|
||||||
|
required int targetId,
|
||||||
|
String? reason,
|
||||||
|
}) async {
|
||||||
|
try {
|
||||||
|
final response = await http.post(
|
||||||
|
Uri.parse('$baseUrl/api/board/reports'),
|
||||||
|
headers: {"Content-Type": "application/json"},
|
||||||
|
body: jsonEncode({
|
||||||
|
"targetType": targetType,
|
||||||
|
"targetId": targetId,
|
||||||
|
"reporterStudentId": studentId,
|
||||||
|
"reason": reason,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
final result = jsonDecode(utf8.decode(response.bodyBytes));
|
||||||
|
if (response.statusCode == 200 && result['status'] == 'success') {
|
||||||
|
return (true, '${result['message'] ?? '신고가 접수되었습니다.'}');
|
||||||
|
}
|
||||||
|
return (false, '${result['message'] ?? '신고 접수 실패'}');
|
||||||
|
} catch (e) {
|
||||||
|
return (false, '네트워크 에러: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
// 🚩 커뮤니티 게시판 - 신고 검토(운영자용) 화면의 기능(서버 통신/상태) 담당 컨트롤러.
|
||||||
|
import 'dart:convert';
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:http/http.dart' as http;
|
||||||
|
import '../config.dart';
|
||||||
|
|
||||||
|
class BoardReportController extends ChangeNotifier {
|
||||||
|
final String reviewerId;
|
||||||
|
|
||||||
|
BoardReportController({required this.reviewerId});
|
||||||
|
|
||||||
|
bool _isLoading = true;
|
||||||
|
bool _isBusy = false;
|
||||||
|
List<dynamic> _reports = [];
|
||||||
|
bool _disposed = false;
|
||||||
|
|
||||||
|
bool get isLoading => _isLoading;
|
||||||
|
bool get isBusy => _isBusy;
|
||||||
|
List<dynamic> get reports => _reports;
|
||||||
|
|
||||||
|
void _safeNotify() {
|
||||||
|
if (!_disposed) notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_disposed = true;
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> init() => fetchPending();
|
||||||
|
|
||||||
|
Future<void> fetchPending() async {
|
||||||
|
_isLoading = true;
|
||||||
|
_safeNotify();
|
||||||
|
try {
|
||||||
|
final response = await http.get(
|
||||||
|
Uri.parse('$baseUrl/api/board/reports?status=pending'),
|
||||||
|
);
|
||||||
|
if (response.statusCode == 200) {
|
||||||
|
final data = jsonDecode(utf8.decode(response.bodyBytes));
|
||||||
|
_reports = data['reports'] ?? [];
|
||||||
|
}
|
||||||
|
} catch (_) {
|
||||||
|
// 조용히 무시 - 마지막으로 받아온 목록을 유지한다.
|
||||||
|
} finally {
|
||||||
|
_isLoading = false;
|
||||||
|
_safeNotify();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<(bool success, String message)> resolve(
|
||||||
|
int reportId,
|
||||||
|
String action,
|
||||||
|
) async {
|
||||||
|
_isBusy = true;
|
||||||
|
_safeNotify();
|
||||||
|
try {
|
||||||
|
final response = await http.post(
|
||||||
|
Uri.parse('$baseUrl/api/board/reports/$reportId/resolve'),
|
||||||
|
headers: {"Content-Type": "application/json"},
|
||||||
|
body: jsonEncode({"reviewerId": reviewerId, "action": action}),
|
||||||
|
);
|
||||||
|
final result = jsonDecode(utf8.decode(response.bodyBytes));
|
||||||
|
if (response.statusCode == 200 && result['status'] == 'success') {
|
||||||
|
await fetchPending();
|
||||||
|
return (true, '${result['message'] ?? '처리되었습니다.'}');
|
||||||
|
}
|
||||||
|
return (false, '${result['message'] ?? '처리 실패'}');
|
||||||
|
} catch (e) {
|
||||||
|
return (false, '네트워크 에러: $e');
|
||||||
|
} finally {
|
||||||
|
_isBusy = false;
|
||||||
|
_safeNotify();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
// ✍️ 커뮤니티 게시판 - 글쓰기 화면의 기능(서버 통신/상태) 담당 컨트롤러.
|
||||||
|
import 'dart:convert';
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:http/http.dart' as http;
|
||||||
|
import '../config.dart';
|
||||||
|
|
||||||
|
class BoardWriteController extends ChangeNotifier {
|
||||||
|
final String studentId;
|
||||||
|
|
||||||
|
BoardWriteController({required this.studentId});
|
||||||
|
|
||||||
|
bool _isSubmitting = false;
|
||||||
|
bool get isSubmitting => _isSubmitting;
|
||||||
|
|
||||||
|
Future<(bool success, String message)> submit({
|
||||||
|
required String category,
|
||||||
|
required String title,
|
||||||
|
required String content,
|
||||||
|
}) async {
|
||||||
|
if (title.trim().isEmpty || content.trim().isEmpty) {
|
||||||
|
return (false, '제목과 내용을 입력해주세요.');
|
||||||
|
}
|
||||||
|
_isSubmitting = true;
|
||||||
|
notifyListeners();
|
||||||
|
try {
|
||||||
|
final response = await http.post(
|
||||||
|
Uri.parse('$baseUrl/api/board/posts'),
|
||||||
|
headers: {"Content-Type": "application/json"},
|
||||||
|
body: jsonEncode({
|
||||||
|
"studentId": studentId,
|
||||||
|
"category": category,
|
||||||
|
"title": title.trim(),
|
||||||
|
"content": content.trim(),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
final result = jsonDecode(utf8.decode(response.bodyBytes));
|
||||||
|
if (response.statusCode == 200 && result['status'] == 'success') {
|
||||||
|
return (true, '게시글이 등록되었습니다.');
|
||||||
|
}
|
||||||
|
return (false, '${result['message'] ?? '게시글 등록 실패'}');
|
||||||
|
} catch (e) {
|
||||||
|
return (false, '네트워크 에러: $e');
|
||||||
|
} finally {
|
||||||
|
_isSubmitting = false;
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
// 🌐 플랫폼에 따라 웹 전용 구현/무동작 구현 중 하나로 연결되는 진입점.
|
||||||
|
export 'browser_notification_stub.dart'
|
||||||
|
if (dart.library.html) 'browser_notification_web.dart';
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
// 🌐 웹이 아닌 빌드(Android 등)에서는 브라우저 알림 API 자체가 없으니 아무 동작도 안 한다.
|
||||||
|
Future<bool> requestNotificationPermission() async => false;
|
||||||
|
|
||||||
|
void showBrowserNotification(String title, String body) {}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
// 🌐 웹 전용 - 브라우저 알림(Notification) API로 "타이머 켜놓고 탭을 벗어났을 때" 같은
|
||||||
|
// 즉각적인 로컬 알림을 띄운다. FCM 푸시와 달리 서버 왕복 없이 바로 뜬다.
|
||||||
|
// 알림 API를 지원하지 않는 브라우저에서는 조용히 무시한다.
|
||||||
|
import 'dart:js_interop';
|
||||||
|
import 'package:web/web.dart' as web;
|
||||||
|
|
||||||
|
Future<bool> requestNotificationPermission() async {
|
||||||
|
try {
|
||||||
|
if (web.Notification.permission == 'granted') return true;
|
||||||
|
final permission = await web.Notification.requestPermission().toDart;
|
||||||
|
return permission.toDart == 'granted';
|
||||||
|
} catch (_) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void showBrowserNotification(String title, String body) {
|
||||||
|
try {
|
||||||
|
if (web.Notification.permission != 'granted') return;
|
||||||
|
web.Notification(title, web.NotificationOptions(body: body));
|
||||||
|
} catch (_) {
|
||||||
|
// 조용히 무시
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
// 🧩 대시보드 타일 배치(위치/크기)의 서버 통신/상태 담당 컨트롤러.
|
||||||
|
// 캔버스는 6열×4행 고정, 타일 크기는 1x1~4x4. 계정별로 서버(계정=userId)에 저장한다.
|
||||||
|
import 'dart:convert';
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:http/http.dart' as http;
|
||||||
|
import '../config.dart';
|
||||||
|
import '../models/dashboard_tile_layout.dart';
|
||||||
|
|
||||||
|
class DashboardLayoutController extends ChangeNotifier {
|
||||||
|
final String userId;
|
||||||
|
DashboardLayoutController(this.userId);
|
||||||
|
|
||||||
|
Map<String, TileRect> _positions = {};
|
||||||
|
bool _isLoading = true;
|
||||||
|
bool _isSaving = false;
|
||||||
|
bool _isEditing = false;
|
||||||
|
|
||||||
|
Map<String, TileRect> get positions => _positions;
|
||||||
|
bool get isLoading => _isLoading;
|
||||||
|
bool get isSaving => _isSaving;
|
||||||
|
bool get isEditing => _isEditing;
|
||||||
|
|
||||||
|
void enterEditMode() {
|
||||||
|
_isEditing = true;
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 편집 중이던 변경을 서버에 저장하지 않고 그대로 유지한 채 편집 모드만 끈다.
|
||||||
|
/// (저장은 [save]를 명시적으로 불러야 함 — 완료 버튼이 save+exit를 같이 호출한다.)
|
||||||
|
void exitEditMode() {
|
||||||
|
_isEditing = false;
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 서버에 저장된 배치를 불러오고, 현재 화면에 보여야 할 타일 id 목록([visibleIds]) 중
|
||||||
|
/// 저장된 값이 없는 새 타일은 빈 칸을 찾아 1x1로 자동 배치한다.
|
||||||
|
Future<void> load(List<String> visibleIds) async {
|
||||||
|
_isLoading = true;
|
||||||
|
notifyListeners();
|
||||||
|
|
||||||
|
Map<String, TileRect> loaded = {};
|
||||||
|
try {
|
||||||
|
final response = await http.get(
|
||||||
|
Uri.parse('$baseUrl/api/dashboard/layout?userId=$userId'),
|
||||||
|
);
|
||||||
|
if (response.statusCode == 200) {
|
||||||
|
final data = jsonDecode(utf8.decode(response.bodyBytes));
|
||||||
|
final rawLayout = data['layout'];
|
||||||
|
if (rawLayout is List) {
|
||||||
|
for (final item in rawLayout) {
|
||||||
|
final id = item['id']?.toString();
|
||||||
|
if (id == null) continue;
|
||||||
|
final rect = TileRect.fromJson(item);
|
||||||
|
if (rect.isWithinCanvas) loaded[id] = rect;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (_) {
|
||||||
|
// 새로고침 실패는 조용히 무시하고 기본 배치로 대체한다.
|
||||||
|
}
|
||||||
|
|
||||||
|
_positions = _fillDefaults(loaded, visibleIds);
|
||||||
|
_isLoading = false;
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, TileRect> _fillDefaults(
|
||||||
|
Map<String, TileRect> existing,
|
||||||
|
List<String> ids,
|
||||||
|
) {
|
||||||
|
final Map<String, TileRect> result = {};
|
||||||
|
for (final id in ids) {
|
||||||
|
final saved = existing[id];
|
||||||
|
if (saved != null && !_collidesWithAny(result, saved, null)) {
|
||||||
|
result[id] = saved;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (final id in ids) {
|
||||||
|
if (result.containsKey(id)) continue;
|
||||||
|
final spot = _findFirstFit(result, 1, 1);
|
||||||
|
if (spot != null) {
|
||||||
|
result[id] = TileRect(x: spot.$1, y: spot.$2, w: 1, h: 1);
|
||||||
|
}
|
||||||
|
// 캔버스(6x4=24칸)가 꽉 찼으면 자리를 못 찾을 수도 있음 — 그 타일은 화면에 안 보이게 됨.
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool _collidesWithAny(
|
||||||
|
Map<String, TileRect> placed,
|
||||||
|
TileRect candidate,
|
||||||
|
String? ignoreId,
|
||||||
|
) {
|
||||||
|
for (final entry in placed.entries) {
|
||||||
|
if (entry.key == ignoreId) continue;
|
||||||
|
if (entry.value.overlaps(candidate)) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
(int, int)? _findFirstFit(Map<String, TileRect> placed, int w, int h) {
|
||||||
|
for (int y = 0; y <= kGridRows - h; y++) {
|
||||||
|
for (int x = 0; x <= kGridCols - w; x++) {
|
||||||
|
final candidate = TileRect(x: x, y: y, w: w, h: h);
|
||||||
|
if (!_collidesWithAny(placed, candidate, null)) return (x, y);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool _fits(String movingId, TileRect candidate) {
|
||||||
|
if (!candidate.isWithinCanvas) return false;
|
||||||
|
return !_collidesWithAny(_positions, candidate, movingId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 타일을 (newX, newY)로 옮긴다. 다른 타일과 겹치거나 캔버스를 벗어나면 무시하고 false 반환.
|
||||||
|
bool tryMove(String id, int newX, int newY) {
|
||||||
|
final cur = _positions[id];
|
||||||
|
if (cur == null) return false;
|
||||||
|
final candidate = cur.copyWith(x: newX, y: newY);
|
||||||
|
if (!_fits(id, candidate)) return false;
|
||||||
|
_positions = {..._positions, id: candidate};
|
||||||
|
notifyListeners();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 타일 크기를 (newW, newH)로 바꾼다. 다른 타일과 겹치거나 4x4/캔버스를 벗어나면 무시.
|
||||||
|
bool tryResize(String id, int newW, int newH) {
|
||||||
|
final cur = _positions[id];
|
||||||
|
if (cur == null) return false;
|
||||||
|
final candidate = cur.copyWith(w: newW, h: newH);
|
||||||
|
if (!_fits(id, candidate)) return false;
|
||||||
|
_positions = {..._positions, id: candidate};
|
||||||
|
notifyListeners();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> save() async {
|
||||||
|
_isSaving = true;
|
||||||
|
notifyListeners();
|
||||||
|
try {
|
||||||
|
final layout = _positions.entries
|
||||||
|
.map((e) => e.value.toJson(e.key))
|
||||||
|
.toList();
|
||||||
|
await http.post(
|
||||||
|
Uri.parse('$baseUrl/api/dashboard/layout'),
|
||||||
|
headers: {"Content-Type": "application/json"},
|
||||||
|
body: jsonEncode({"userId": userId, "layout": layout}),
|
||||||
|
);
|
||||||
|
} catch (_) {
|
||||||
|
// 저장 실패해도 로컬 배치는 유지 — 다음에 다시 시도 가능.
|
||||||
|
} finally {
|
||||||
|
_isSaving = false;
|
||||||
|
_isEditing = false;
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
// 📱 스마트기기(패드) 반출 신청 화면의 기능(서버 통신/상태) 담당 컨트롤러.
|
||||||
|
// NFC가 안 되는 패드용 대체 절차 — 시작~종료 시각과 사용 목적을 적어 신청하면
|
||||||
|
// 선생님 승인 후 저울(아두이노)이 실제 픽업/반납 여부를 감시한다 (하드웨어는 나중에 붙임).
|
||||||
|
import 'dart:convert';
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:http/http.dart' as http;
|
||||||
|
import '../config.dart';
|
||||||
|
|
||||||
|
class DeviceCheckoutController extends ChangeNotifier {
|
||||||
|
bool _isSubmitting = false;
|
||||||
|
bool get isSubmitting => _isSubmitting;
|
||||||
|
|
||||||
|
/// ➕ 반출 요청 제출. (성공여부, 메시지)를 반환한다.
|
||||||
|
Future<(bool success, String message)> submitRequest({
|
||||||
|
required String studentId,
|
||||||
|
required String studentName,
|
||||||
|
required String purpose,
|
||||||
|
required String startTime,
|
||||||
|
required String endTime,
|
||||||
|
}) async {
|
||||||
|
_isSubmitting = true;
|
||||||
|
notifyListeners();
|
||||||
|
try {
|
||||||
|
final response = await http.post(
|
||||||
|
Uri.parse('$baseUrl/api/device-checkout/request'),
|
||||||
|
headers: {"Content-Type": "application/json"},
|
||||||
|
body: jsonEncode({
|
||||||
|
"studentId": studentId,
|
||||||
|
"studentName": studentName,
|
||||||
|
"purpose": purpose,
|
||||||
|
"startTime": startTime,
|
||||||
|
"endTime": endTime,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
final result = jsonDecode(utf8.decode(response.bodyBytes));
|
||||||
|
if (response.statusCode == 200 && result['status'] == 'success') {
|
||||||
|
return (true, '${result['message'] ?? '요청이 접수되었습니다.'}');
|
||||||
|
} else {
|
||||||
|
return (false, '${result['message'] ?? '요청 실패'}');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
return (false, '네트워크 에러: $e');
|
||||||
|
} finally {
|
||||||
|
_isSubmitting = false;
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,196 @@
|
|||||||
|
// 📋 선생님용 "스마트기기 반출 대장" 화면의 기능(서버 통신/상태) 담당 컨트롤러.
|
||||||
|
import 'dart:async';
|
||||||
|
import 'dart:convert';
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:http/http.dart' as http;
|
||||||
|
import '../config.dart';
|
||||||
|
|
||||||
|
class DeviceCheckoutLedgerController extends ChangeNotifier {
|
||||||
|
List<dynamic> _requests = [];
|
||||||
|
bool _isLoading = true;
|
||||||
|
bool _isWorking = false;
|
||||||
|
Timer? _timer;
|
||||||
|
bool _disposed = false;
|
||||||
|
|
||||||
|
List<dynamic> get requests => _requests;
|
||||||
|
bool get isLoading => _isLoading;
|
||||||
|
bool get isWorking => _isWorking;
|
||||||
|
|
||||||
|
// 🚨 [장시간 요청 기준] 1시간 59분(119분) 이상이면 "많은 시간요청"으로 본다.
|
||||||
|
static const int longRequestMinutes = 119;
|
||||||
|
|
||||||
|
int? _durationMinutes(dynamic request) {
|
||||||
|
try {
|
||||||
|
final start = DateTime.parse(request['requestedStart']);
|
||||||
|
final end = DateTime.parse(request['requestedEnd']);
|
||||||
|
final minutes = end.difference(start).inMinutes;
|
||||||
|
return minutes > 0 ? minutes : null;
|
||||||
|
} catch (_) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// ⏰ 요청 시간이 얼마나 되는지(시간 단위). 형식이 이상하면 null.
|
||||||
|
double? durationHours(dynamic request) {
|
||||||
|
final minutes = _durationMinutes(request);
|
||||||
|
return minutes == null ? null : minutes / 60.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 🚨 [장시간 요청 감지] 1시간 59분 넘게 신청했는지. 상태(대기/승인 등)와 무관하게 판단한다 -
|
||||||
|
/// 목록 카드에 빨간 테두리를 씌우는 데 쓴다.
|
||||||
|
bool isLongRequest(dynamic request) {
|
||||||
|
final minutes = _durationMinutes(request);
|
||||||
|
return minutes != null && minutes >= longRequestMinutes;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 학생이 시간을 비정상적으로 길게 적어냈을 수 있으니, 아직 대기 중인 장시간 요청만
|
||||||
|
/// 따로 모아서 전체 승인 전에 훑어볼 수 있게 한다.
|
||||||
|
List<dynamic> get longPendingRequests => _requests
|
||||||
|
.where((r) => r['status'] == 'PENDING' && isLongRequest(r))
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
void _safeNotify() {
|
||||||
|
if (!_disposed) notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
void init() {
|
||||||
|
fetchAll();
|
||||||
|
_timer = Timer.periodic(const Duration(seconds: 5), (_) => fetchAll());
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_disposed = true;
|
||||||
|
_timer?.cancel();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> fetchAll() async {
|
||||||
|
try {
|
||||||
|
final response = await http.get(
|
||||||
|
Uri.parse('$baseUrl/api/device-checkout/list'),
|
||||||
|
);
|
||||||
|
if (response.statusCode == 200) {
|
||||||
|
final data = jsonDecode(utf8.decode(response.bodyBytes));
|
||||||
|
_requests = data['requests'] ?? [];
|
||||||
|
}
|
||||||
|
} catch (_) {
|
||||||
|
// 새로고침 실패는 조용히 무시하고 마지막으로 받아온 목록을 유지한다.
|
||||||
|
} finally {
|
||||||
|
_isLoading = false;
|
||||||
|
_safeNotify();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ✅ 승인 시 승인한 선생님 계정을 함께 보낸다 (대장에 "OOO 선생님이 허용했습니다" 표시 +
|
||||||
|
// 미반납 알림을 그 선생님에게만 보내기 위함).
|
||||||
|
Future<(bool success, String message)> approve(
|
||||||
|
int requestId, {
|
||||||
|
required String teacherId,
|
||||||
|
required String teacherName,
|
||||||
|
}) async {
|
||||||
|
return _postAction('/api/device-checkout/approve', {
|
||||||
|
"requestId": requestId,
|
||||||
|
"teacherId": teacherId,
|
||||||
|
"teacherName": teacherName,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 🙌 [전체 학생 허용] 대상이 될 요청 id 목록 (대기 중이면서 장시간 요청은 아닌 것만).
|
||||||
|
/// 확인 다이얼로그에서 몇 건이 승인되고 몇 건이 제외되는지 미리 보여주는 데 쓴다.
|
||||||
|
List<int> get autoApprovableIds => _requests
|
||||||
|
.where((r) => r['status'] == 'PENDING' && !isLongRequest(r))
|
||||||
|
.map<int>((r) => r['id'] as int)
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
// 🙌 [전체 학생 허용] 대기 중인 요청을 한 명씩 누르기 귀찮을 때, 한 번에 전부 승인한다.
|
||||||
|
// 단, 1시간 59분 넘는 장시간 요청은 자동으로 제외하고 대기중 상태로 남겨서,
|
||||||
|
// "많은 시간요청 학생" 목록에서 따로 검토/승인하게 한다.
|
||||||
|
// 서버에 일괄 승인 API가 따로 없어서, 대상 요청마다 승인 요청을 순서대로 보낸다.
|
||||||
|
Future<(bool success, String message)> approveAllPending({
|
||||||
|
required String teacherId,
|
||||||
|
required String teacherName,
|
||||||
|
}) async {
|
||||||
|
final targetIds = autoApprovableIds;
|
||||||
|
final excludedCount = longPendingRequests.length;
|
||||||
|
|
||||||
|
if (targetIds.isEmpty) {
|
||||||
|
return (
|
||||||
|
true,
|
||||||
|
excludedCount > 0
|
||||||
|
? '장시간 요청 $excludedCount건을 제외하면 승인할 대기 요청이 없습니다.'
|
||||||
|
: '승인 대기 중인 요청이 없습니다.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
_isWorking = true;
|
||||||
|
_safeNotify();
|
||||||
|
int successCount = 0;
|
||||||
|
for (final id in targetIds) {
|
||||||
|
try {
|
||||||
|
final response = await http.post(
|
||||||
|
Uri.parse('$baseUrl/api/device-checkout/approve'),
|
||||||
|
headers: {"Content-Type": "application/json"},
|
||||||
|
body: jsonEncode({
|
||||||
|
"requestId": id,
|
||||||
|
"teacherId": teacherId,
|
||||||
|
"teacherName": teacherName,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
final result = jsonDecode(utf8.decode(response.bodyBytes));
|
||||||
|
if (response.statusCode == 200 && result['status'] == 'success') {
|
||||||
|
successCount++;
|
||||||
|
}
|
||||||
|
} catch (_) {
|
||||||
|
// 개별 요청 실패는 건너뛰고 나머지는 계속 진행한다.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await fetchAll();
|
||||||
|
_isWorking = false;
|
||||||
|
_safeNotify();
|
||||||
|
|
||||||
|
final allOk = successCount == targetIds.length;
|
||||||
|
final excludedNote = excludedCount > 0
|
||||||
|
? ' (장시간 요청 $excludedCount건은 제외 - 대기중으로 남음)'
|
||||||
|
: '';
|
||||||
|
return (
|
||||||
|
allOk,
|
||||||
|
'${targetIds.length}건 중 $successCount건 승인 완료${allOk ? '' : ' (일부 실패)'}$excludedNote',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<(bool success, String message)> reject(int requestId) async {
|
||||||
|
return _postAction('/api/device-checkout/reject', {"requestId": requestId});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 🖐️ [하드웨어 자동 감지 전까지 임시] 선생님이 기기를 실제로 돌려받았을 때 누르는 버튼.
|
||||||
|
Future<(bool success, String message)> confirmReturn(int requestId) async {
|
||||||
|
return _postAction('/api/device-checkout/return', {"requestId": requestId});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<(bool success, String message)> _postAction(
|
||||||
|
String path,
|
||||||
|
Map<String, dynamic> body,
|
||||||
|
) async {
|
||||||
|
_isWorking = true;
|
||||||
|
_safeNotify();
|
||||||
|
try {
|
||||||
|
final response = await http.post(
|
||||||
|
Uri.parse('$baseUrl$path'),
|
||||||
|
headers: {"Content-Type": "application/json"},
|
||||||
|
body: jsonEncode(body),
|
||||||
|
);
|
||||||
|
final result = jsonDecode(utf8.decode(response.bodyBytes));
|
||||||
|
if (response.statusCode == 200 && result['status'] == 'success') {
|
||||||
|
await fetchAll();
|
||||||
|
return (true, '${result['message'] ?? '처리되었습니다.'}');
|
||||||
|
}
|
||||||
|
return (false, '${result['message'] ?? '처리 실패'}');
|
||||||
|
} catch (e) {
|
||||||
|
return (false, '네트워크 에러: $e');
|
||||||
|
} finally {
|
||||||
|
_isWorking = false;
|
||||||
|
_safeNotify();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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';
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
// 🧑🤝🧑 백품타 친구 목록/신청 화면의 기능(서버 통신/상태) 담당 컨트롤러.
|
||||||
|
import 'dart:convert';
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:http/http.dart' as http;
|
||||||
|
import '../config.dart';
|
||||||
|
|
||||||
|
class FriendController extends ChangeNotifier {
|
||||||
|
final String studentId;
|
||||||
|
final String studentName;
|
||||||
|
|
||||||
|
FriendController({required this.studentId, required this.studentName});
|
||||||
|
|
||||||
|
bool _isLoading = true;
|
||||||
|
bool _isBusy = false;
|
||||||
|
List<dynamic> _friends = [];
|
||||||
|
List<dynamic> _requests = [];
|
||||||
|
bool _disposed = false;
|
||||||
|
|
||||||
|
bool get isLoading => _isLoading;
|
||||||
|
bool get isBusy => _isBusy;
|
||||||
|
List<dynamic> get friends => _friends;
|
||||||
|
List<dynamic> get requests => _requests;
|
||||||
|
|
||||||
|
void _safeNotify() {
|
||||||
|
if (!_disposed) notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_disposed = true;
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> init() async {
|
||||||
|
await refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> refresh() async {
|
||||||
|
_isLoading = true;
|
||||||
|
_safeNotify();
|
||||||
|
await Future.wait([_fetchFriends(), _fetchRequests()]);
|
||||||
|
_isLoading = false;
|
||||||
|
_safeNotify();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _fetchFriends() async {
|
||||||
|
try {
|
||||||
|
final response = await http.get(
|
||||||
|
Uri.parse('$baseUrl/api/friends/list?studentId=$studentId'),
|
||||||
|
);
|
||||||
|
if (response.statusCode == 200) {
|
||||||
|
final data = jsonDecode(utf8.decode(response.bodyBytes));
|
||||||
|
_friends = data['friends'] ?? [];
|
||||||
|
}
|
||||||
|
} catch (_) {
|
||||||
|
// 조용히 무시
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _fetchRequests() async {
|
||||||
|
try {
|
||||||
|
final response = await http.get(
|
||||||
|
Uri.parse('$baseUrl/api/friends/requests?studentId=$studentId'),
|
||||||
|
);
|
||||||
|
if (response.statusCode == 200) {
|
||||||
|
final data = jsonDecode(utf8.decode(response.bodyBytes));
|
||||||
|
_requests = data['requests'] ?? [];
|
||||||
|
}
|
||||||
|
} catch (_) {
|
||||||
|
// 조용히 무시
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<(bool success, String message)> sendRequest(String friendId) async {
|
||||||
|
_isBusy = true;
|
||||||
|
_safeNotify();
|
||||||
|
try {
|
||||||
|
final response = await http.post(
|
||||||
|
Uri.parse('$baseUrl/api/friends/request'),
|
||||||
|
headers: {"Content-Type": "application/json"},
|
||||||
|
body: jsonEncode({
|
||||||
|
"studentId": studentId,
|
||||||
|
"studentName": studentName,
|
||||||
|
"friendId": friendId.trim(),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
final result = jsonDecode(utf8.decode(response.bodyBytes));
|
||||||
|
if (response.statusCode == 200 && result['status'] == 'success') {
|
||||||
|
return (true, '${result['message'] ?? '친구 신청을 보냈습니다.'}');
|
||||||
|
}
|
||||||
|
return (false, '${result['message'] ?? '친구 신청 실패'}');
|
||||||
|
} catch (e) {
|
||||||
|
return (false, '네트워크 에러: $e');
|
||||||
|
} finally {
|
||||||
|
_isBusy = false;
|
||||||
|
_safeNotify();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<(bool success, String message)> respondRequest(
|
||||||
|
int requestId,
|
||||||
|
bool accept,
|
||||||
|
) async {
|
||||||
|
_isBusy = true;
|
||||||
|
_safeNotify();
|
||||||
|
try {
|
||||||
|
final response = await http.post(
|
||||||
|
Uri.parse('$baseUrl/api/friends/respond'),
|
||||||
|
headers: {"Content-Type": "application/json"},
|
||||||
|
body: jsonEncode({
|
||||||
|
"requestId": requestId,
|
||||||
|
"responderId": studentId,
|
||||||
|
"accept": accept,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
final result = jsonDecode(utf8.decode(response.bodyBytes));
|
||||||
|
if (response.statusCode == 200 && result['status'] == 'success') {
|
||||||
|
await refresh();
|
||||||
|
return (true, '${result['message'] ?? '처리했습니다.'}');
|
||||||
|
}
|
||||||
|
return (false, '${result['message'] ?? '처리 실패'}');
|
||||||
|
} catch (e) {
|
||||||
|
return (false, '네트워크 에러: $e');
|
||||||
|
} finally {
|
||||||
|
_isBusy = false;
|
||||||
|
_safeNotify();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<(bool success, String message)> removeFriend(String friendId) async {
|
||||||
|
_isBusy = true;
|
||||||
|
_safeNotify();
|
||||||
|
try {
|
||||||
|
final response = await http.delete(
|
||||||
|
Uri.parse('$baseUrl/api/friends'),
|
||||||
|
headers: {"Content-Type": "application/json"},
|
||||||
|
body: jsonEncode({"studentId": studentId, "friendId": friendId}),
|
||||||
|
);
|
||||||
|
final result = jsonDecode(utf8.decode(response.bodyBytes));
|
||||||
|
if (response.statusCode == 200 && result['status'] == 'success') {
|
||||||
|
await _fetchFriends();
|
||||||
|
return (true, '${result['message'] ?? '삭제했습니다.'}');
|
||||||
|
}
|
||||||
|
return (false, '${result['message'] ?? '삭제 실패'}');
|
||||||
|
} catch (e) {
|
||||||
|
return (false, '네트워크 에러: $e');
|
||||||
|
} finally {
|
||||||
|
_isBusy = false;
|
||||||
|
_safeNotify();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,167 @@
|
|||||||
|
// 👥 백품타 그룹 스터디(목록/생성/초대) 화면의 기능(서버 통신/상태) 담당 컨트롤러.
|
||||||
|
import 'dart:convert';
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:http/http.dart' as http;
|
||||||
|
import '../config.dart';
|
||||||
|
|
||||||
|
class GroupController extends ChangeNotifier {
|
||||||
|
final String studentId;
|
||||||
|
final String studentName;
|
||||||
|
|
||||||
|
GroupController({required this.studentId, required this.studentName});
|
||||||
|
|
||||||
|
bool _isLoading = true;
|
||||||
|
bool _isBusy = false;
|
||||||
|
List<dynamic> _groups = [];
|
||||||
|
List<dynamic> _invites = [];
|
||||||
|
List<dynamic> _publicGroups = [];
|
||||||
|
bool _disposed = false;
|
||||||
|
|
||||||
|
bool get isLoading => _isLoading;
|
||||||
|
bool get isBusy => _isBusy;
|
||||||
|
List<dynamic> get groups => _groups;
|
||||||
|
List<dynamic> get invites => _invites;
|
||||||
|
List<dynamic> get publicGroups => _publicGroups;
|
||||||
|
|
||||||
|
void _safeNotify() {
|
||||||
|
if (!_disposed) notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_disposed = true;
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> init() => refresh();
|
||||||
|
|
||||||
|
Future<void> refresh() async {
|
||||||
|
_isLoading = true;
|
||||||
|
_safeNotify();
|
||||||
|
await Future.wait([_fetchGroups(), _fetchInvites(), _fetchPublicGroups()]);
|
||||||
|
_isLoading = false;
|
||||||
|
_safeNotify();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _fetchGroups() async {
|
||||||
|
try {
|
||||||
|
final response = await http.get(
|
||||||
|
Uri.parse('$baseUrl/api/groups/mine?studentId=$studentId'),
|
||||||
|
);
|
||||||
|
if (response.statusCode == 200) {
|
||||||
|
final data = jsonDecode(utf8.decode(response.bodyBytes));
|
||||||
|
_groups = data['groups'] ?? [];
|
||||||
|
}
|
||||||
|
} catch (_) {
|
||||||
|
// 조용히 무시
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _fetchInvites() async {
|
||||||
|
try {
|
||||||
|
final response = await http.get(
|
||||||
|
Uri.parse('$baseUrl/api/groups/invites?studentId=$studentId'),
|
||||||
|
);
|
||||||
|
if (response.statusCode == 200) {
|
||||||
|
final data = jsonDecode(utf8.decode(response.bodyBytes));
|
||||||
|
_invites = data['invites'] ?? [];
|
||||||
|
}
|
||||||
|
} catch (_) {
|
||||||
|
// 조용히 무시
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _fetchPublicGroups() async {
|
||||||
|
try {
|
||||||
|
final response = await http.get(
|
||||||
|
Uri.parse('$baseUrl/api/groups/public?studentId=$studentId'),
|
||||||
|
);
|
||||||
|
if (response.statusCode == 200) {
|
||||||
|
final data = jsonDecode(utf8.decode(response.bodyBytes));
|
||||||
|
_publicGroups = data['groups'] ?? [];
|
||||||
|
}
|
||||||
|
} catch (_) {
|
||||||
|
// 조용히 무시
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<(bool success, String message)> createGroup(
|
||||||
|
String name, {
|
||||||
|
bool isPublic = false,
|
||||||
|
}) async {
|
||||||
|
_isBusy = true;
|
||||||
|
_safeNotify();
|
||||||
|
try {
|
||||||
|
final response = await http.post(
|
||||||
|
Uri.parse('$baseUrl/api/groups'),
|
||||||
|
headers: {"Content-Type": "application/json"},
|
||||||
|
body: jsonEncode({
|
||||||
|
"ownerId": studentId,
|
||||||
|
"ownerName": studentName,
|
||||||
|
"name": name.trim(),
|
||||||
|
"isPublic": isPublic,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
final result = jsonDecode(utf8.decode(response.bodyBytes));
|
||||||
|
if (response.statusCode == 200 && result['status'] == 'success') {
|
||||||
|
await _fetchGroups();
|
||||||
|
return (true, '${result['message'] ?? '그룹을 만들었습니다.'}');
|
||||||
|
}
|
||||||
|
return (false, '${result['message'] ?? '그룹 생성 실패'}');
|
||||||
|
} catch (e) {
|
||||||
|
return (false, '네트워크 에러: $e');
|
||||||
|
} finally {
|
||||||
|
_isBusy = false;
|
||||||
|
_safeNotify();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<(bool success, String message)> respondInvite(
|
||||||
|
int inviteId,
|
||||||
|
bool accept,
|
||||||
|
) async {
|
||||||
|
_isBusy = true;
|
||||||
|
_safeNotify();
|
||||||
|
try {
|
||||||
|
final response = await http.post(
|
||||||
|
Uri.parse('$baseUrl/api/groups/invites/$inviteId/respond'),
|
||||||
|
headers: {"Content-Type": "application/json"},
|
||||||
|
body: jsonEncode({"responderId": studentId, "accept": accept}),
|
||||||
|
);
|
||||||
|
final result = jsonDecode(utf8.decode(response.bodyBytes));
|
||||||
|
if (response.statusCode == 200 && result['status'] == 'success') {
|
||||||
|
await refresh();
|
||||||
|
return (true, '${result['message'] ?? '처리했습니다.'}');
|
||||||
|
}
|
||||||
|
return (false, '${result['message'] ?? '처리 실패'}');
|
||||||
|
} catch (e) {
|
||||||
|
return (false, '네트워크 에러: $e');
|
||||||
|
} finally {
|
||||||
|
_isBusy = false;
|
||||||
|
_safeNotify();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<(bool success, String message)> requestJoin(int groupId) async {
|
||||||
|
_isBusy = true;
|
||||||
|
_safeNotify();
|
||||||
|
try {
|
||||||
|
final response = await http.post(
|
||||||
|
Uri.parse('$baseUrl/api/groups/$groupId/join-request'),
|
||||||
|
headers: {"Content-Type": "application/json"},
|
||||||
|
body: jsonEncode({"studentId": studentId, "studentName": studentName}),
|
||||||
|
);
|
||||||
|
final result = jsonDecode(utf8.decode(response.bodyBytes));
|
||||||
|
if (response.statusCode == 200 && result['status'] == 'success') {
|
||||||
|
await _fetchPublicGroups();
|
||||||
|
return (true, '${result['message'] ?? '참여 신청을 보냈습니다.'}');
|
||||||
|
}
|
||||||
|
return (false, '${result['message'] ?? '참여 신청 실패'}');
|
||||||
|
} catch (e) {
|
||||||
|
return (false, '네트워크 에러: $e');
|
||||||
|
} finally {
|
||||||
|
_isBusy = false;
|
||||||
|
_safeNotify();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,175 @@
|
|||||||
|
// 👥 백품타 그룹 상세(멤버/오늘 출석/초대/나가기) 화면의 기능 담당 컨트롤러.
|
||||||
|
import 'dart:convert';
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:http/http.dart' as http;
|
||||||
|
import '../config.dart';
|
||||||
|
|
||||||
|
class GroupDetailController extends ChangeNotifier {
|
||||||
|
final int groupId;
|
||||||
|
final String studentId;
|
||||||
|
|
||||||
|
GroupDetailController({required this.groupId, required this.studentId});
|
||||||
|
|
||||||
|
bool _isLoading = true;
|
||||||
|
bool _isBusy = false;
|
||||||
|
List<dynamic> _members = [];
|
||||||
|
List<dynamic> _joinRequests = [];
|
||||||
|
bool _isPublic = false;
|
||||||
|
bool _disposed = false;
|
||||||
|
|
||||||
|
bool get isLoading => _isLoading;
|
||||||
|
bool get isBusy => _isBusy;
|
||||||
|
List<dynamic> get members => _members;
|
||||||
|
List<dynamic> get joinRequests => _joinRequests;
|
||||||
|
bool get isPublic => _isPublic;
|
||||||
|
bool get isOwner =>
|
||||||
|
_members.any((m) => m['studentId'] == studentId && m['role'] == 'owner');
|
||||||
|
|
||||||
|
void _safeNotify() {
|
||||||
|
if (!_disposed) notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_disposed = true;
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> init() => refresh();
|
||||||
|
|
||||||
|
Future<void> refresh() async {
|
||||||
|
_isLoading = true;
|
||||||
|
_safeNotify();
|
||||||
|
try {
|
||||||
|
final response = await http.get(
|
||||||
|
Uri.parse('$baseUrl/api/groups/$groupId/members'),
|
||||||
|
);
|
||||||
|
if (response.statusCode == 200) {
|
||||||
|
final data = jsonDecode(utf8.decode(response.bodyBytes));
|
||||||
|
_members = data['members'] ?? [];
|
||||||
|
_isPublic = data['isPublic'] == true;
|
||||||
|
}
|
||||||
|
} catch (_) {
|
||||||
|
// 조용히 무시
|
||||||
|
}
|
||||||
|
if (isOwner) await _fetchJoinRequests();
|
||||||
|
_isLoading = false;
|
||||||
|
_safeNotify();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _fetchJoinRequests() async {
|
||||||
|
try {
|
||||||
|
final response = await http.get(
|
||||||
|
Uri.parse(
|
||||||
|
'$baseUrl/api/groups/$groupId/join-requests?ownerId=$studentId',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (response.statusCode == 200) {
|
||||||
|
final data = jsonDecode(utf8.decode(response.bodyBytes));
|
||||||
|
_joinRequests = data['requests'] ?? [];
|
||||||
|
}
|
||||||
|
} catch (_) {
|
||||||
|
// 조용히 무시
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<(bool success, String message)> respondJoinRequest(
|
||||||
|
int requestId,
|
||||||
|
bool accept,
|
||||||
|
) async {
|
||||||
|
_isBusy = true;
|
||||||
|
_safeNotify();
|
||||||
|
try {
|
||||||
|
final response = await http.post(
|
||||||
|
Uri.parse('$baseUrl/api/groups/join-requests/$requestId/respond'),
|
||||||
|
headers: {"Content-Type": "application/json"},
|
||||||
|
body: jsonEncode({"responderId": studentId, "accept": accept}),
|
||||||
|
);
|
||||||
|
final result = jsonDecode(utf8.decode(response.bodyBytes));
|
||||||
|
if (response.statusCode == 200 && result['status'] == 'success') {
|
||||||
|
await refresh();
|
||||||
|
return (true, '${result['message'] ?? '처리했습니다.'}');
|
||||||
|
}
|
||||||
|
return (false, '${result['message'] ?? '처리 실패'}');
|
||||||
|
} catch (e) {
|
||||||
|
return (false, '네트워크 에러: $e');
|
||||||
|
} finally {
|
||||||
|
_isBusy = false;
|
||||||
|
_safeNotify();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<(bool success, String message)> setVisibility(bool isPublic) async {
|
||||||
|
_isBusy = true;
|
||||||
|
_safeNotify();
|
||||||
|
try {
|
||||||
|
final response = await http.post(
|
||||||
|
Uri.parse('$baseUrl/api/groups/$groupId/visibility'),
|
||||||
|
headers: {"Content-Type": "application/json"},
|
||||||
|
body: jsonEncode({"ownerId": studentId, "isPublic": isPublic}),
|
||||||
|
);
|
||||||
|
final result = jsonDecode(utf8.decode(response.bodyBytes));
|
||||||
|
if (response.statusCode == 200 && result['status'] == 'success') {
|
||||||
|
await refresh();
|
||||||
|
return (true, '${result['message'] ?? '변경했습니다.'}');
|
||||||
|
}
|
||||||
|
return (false, '${result['message'] ?? '변경 실패'}');
|
||||||
|
} catch (e) {
|
||||||
|
return (false, '네트워크 에러: $e');
|
||||||
|
} finally {
|
||||||
|
_isBusy = false;
|
||||||
|
_safeNotify();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<(bool success, String message)> invite(
|
||||||
|
String inviteeId,
|
||||||
|
String inviteeName,
|
||||||
|
) async {
|
||||||
|
_isBusy = true;
|
||||||
|
_safeNotify();
|
||||||
|
try {
|
||||||
|
final response = await http.post(
|
||||||
|
Uri.parse('$baseUrl/api/groups/$groupId/invite'),
|
||||||
|
headers: {"Content-Type": "application/json"},
|
||||||
|
body: jsonEncode({
|
||||||
|
"inviterId": studentId,
|
||||||
|
"inviteeId": inviteeId,
|
||||||
|
"inviteeName": inviteeName,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
final result = jsonDecode(utf8.decode(response.bodyBytes));
|
||||||
|
if (response.statusCode == 200 && result['status'] == 'success') {
|
||||||
|
return (true, '${result['message'] ?? '초대를 보냈습니다.'}');
|
||||||
|
}
|
||||||
|
return (false, '${result['message'] ?? '초대 실패'}');
|
||||||
|
} catch (e) {
|
||||||
|
return (false, '네트워크 에러: $e');
|
||||||
|
} finally {
|
||||||
|
_isBusy = false;
|
||||||
|
_safeNotify();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<(bool success, String message)> leave() async {
|
||||||
|
_isBusy = true;
|
||||||
|
_safeNotify();
|
||||||
|
try {
|
||||||
|
final response = await http.post(
|
||||||
|
Uri.parse('$baseUrl/api/groups/$groupId/leave'),
|
||||||
|
headers: {"Content-Type": "application/json"},
|
||||||
|
body: jsonEncode({"studentId": studentId}),
|
||||||
|
);
|
||||||
|
final result = jsonDecode(utf8.decode(response.bodyBytes));
|
||||||
|
if (response.statusCode == 200 && result['status'] == 'success') {
|
||||||
|
return (true, '${result['message'] ?? '그룹에서 나갔습니다.'}');
|
||||||
|
}
|
||||||
|
return (false, '${result['message'] ?? '처리 실패'}');
|
||||||
|
} catch (e) {
|
||||||
|
return (false, '네트워크 에러: $e');
|
||||||
|
} finally {
|
||||||
|
_isBusy = false;
|
||||||
|
_safeNotify();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,216 @@
|
|||||||
|
// 🔑 로그인 화면의 기능(서버 통신/인증 분기 로직) 담당 컨트롤러.
|
||||||
|
// 위젯/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;
|
||||||
|
final int? grade; // 🎓 공부 타이머 학년별 랭킹에서 본인 학년을 기본값으로 쓰기 위함.
|
||||||
|
|
||||||
|
const LoginResult.masterSuccess({required this.studentId, required this.name})
|
||||||
|
: outcome = LoginOutcome.masterSuccess,
|
||||||
|
role = null,
|
||||||
|
isDeviceMatched = true,
|
||||||
|
errorMessage = null,
|
||||||
|
grade = null;
|
||||||
|
|
||||||
|
const LoginResult.needsPasswordChange({
|
||||||
|
required this.studentId,
|
||||||
|
required this.name,
|
||||||
|
required this.role,
|
||||||
|
required this.isDeviceMatched,
|
||||||
|
this.grade,
|
||||||
|
}) : outcome = LoginOutcome.needsPasswordChange,
|
||||||
|
errorMessage = null;
|
||||||
|
|
||||||
|
const LoginResult.success({
|
||||||
|
required this.role,
|
||||||
|
required this.studentId,
|
||||||
|
required this.name,
|
||||||
|
required this.isDeviceMatched,
|
||||||
|
this.grade,
|
||||||
|
}) : outcome = LoginOutcome.success,
|
||||||
|
errorMessage = null;
|
||||||
|
|
||||||
|
const LoginResult.error(this.errorMessage)
|
||||||
|
: outcome = LoginOutcome.error,
|
||||||
|
role = null,
|
||||||
|
studentId = null,
|
||||||
|
name = null,
|
||||||
|
isDeviceMatched = false,
|
||||||
|
grade = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
int? grade;
|
||||||
|
|
||||||
|
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;
|
||||||
|
grade = int.tryParse(userObj['grade']?.toString() ?? '');
|
||||||
|
|
||||||
|
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;
|
||||||
|
grade = int.tryParse(resData['grade']?.toString() ?? '');
|
||||||
|
|
||||||
|
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,
|
||||||
|
grade: grade,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return LoginResult.success(
|
||||||
|
role: role,
|
||||||
|
studentId: studentId,
|
||||||
|
name: name,
|
||||||
|
isDeviceMatched: isDeviceMatched,
|
||||||
|
grade: grade,
|
||||||
|
);
|
||||||
|
} 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,391 @@
|
|||||||
|
// 📲 자습실 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 (_) {
|
||||||
|
// 서버 신고가 실패해도 학생에게는 이미 팝업으로 알렸으니 조용히 무시.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,70 @@
|
|||||||
|
// 🔐 "로그인 유지" 기능. 로그인 성공 시 계정 정보를 기기에 저장해두고,
|
||||||
|
// 다음 실행 때 로그인 화면을 건너뛰고 바로 대시보드로 들어가게 한다.
|
||||||
|
// (서버에 별도 세션/토큰 개념이 없어서, 비밀번호는 저장하지 않고 신원 정보만 저장한다.)
|
||||||
|
import 'dart:convert';
|
||||||
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
|
class SavedSession {
|
||||||
|
final String userId;
|
||||||
|
final String userName;
|
||||||
|
final String role;
|
||||||
|
final bool isDeviceMatched;
|
||||||
|
final int? grade; // 🎓 공부 타이머 학년별 랭킹에서 본인 학년을 기본값으로 쓰기 위함.
|
||||||
|
|
||||||
|
const SavedSession({
|
||||||
|
required this.userId,
|
||||||
|
required this.userName,
|
||||||
|
required this.role,
|
||||||
|
required this.isDeviceMatched,
|
||||||
|
this.grade,
|
||||||
|
});
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() => {
|
||||||
|
'userId': userId,
|
||||||
|
'userName': userName,
|
||||||
|
'role': role,
|
||||||
|
'isDeviceMatched': isDeviceMatched,
|
||||||
|
'grade': grade,
|
||||||
|
};
|
||||||
|
|
||||||
|
factory SavedSession.fromJson(Map<String, dynamic> json) => SavedSession(
|
||||||
|
userId: json['userId'] as String,
|
||||||
|
userName: json['userName'] as String,
|
||||||
|
role: json['role'] as String,
|
||||||
|
isDeviceMatched: json['isDeviceMatched'] as bool? ?? true,
|
||||||
|
grade: json['grade'] as int?,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
class SessionStore {
|
||||||
|
static const _key = 'saved_session_v1';
|
||||||
|
|
||||||
|
static Future<void> save(SavedSession session) async {
|
||||||
|
try {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
await prefs.setString(_key, jsonEncode(session.toJson()));
|
||||||
|
} catch (_) {
|
||||||
|
// 저장 실패해도 로그인 자체는 계속 진행 (로그인 유지만 안 될 뿐).
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<SavedSession?> load() async {
|
||||||
|
try {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
final raw = prefs.getString(_key);
|
||||||
|
if (raw == null) return null;
|
||||||
|
return SavedSession.fromJson(jsonDecode(raw));
|
||||||
|
} catch (_) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<void> clear() async {
|
||||||
|
try {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
await prefs.remove(_key);
|
||||||
|
} catch (_) {
|
||||||
|
// 무시 - 어차피 로그아웃 화면으로는 이동한다.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,150 @@
|
|||||||
|
// 📅 백품타 출석 달력(개인 스트릭 + 관리자 이벤트) 화면의 기능 담당 컨트롤러.
|
||||||
|
import 'dart:convert';
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:http/http.dart' as http;
|
||||||
|
import '../config.dart';
|
||||||
|
|
||||||
|
class StudyCalendarController extends ChangeNotifier {
|
||||||
|
final String studentId;
|
||||||
|
|
||||||
|
StudyCalendarController({required this.studentId});
|
||||||
|
|
||||||
|
bool _isLoading = true;
|
||||||
|
bool _isBusy = false;
|
||||||
|
int _currentStreak = 0;
|
||||||
|
int _longestStreak = 0;
|
||||||
|
Set<String> _studiedDates = {};
|
||||||
|
Map<String, dynamic> _events = {}; // date -> {title, description}
|
||||||
|
DateTime _visibleMonth = DateTime.now();
|
||||||
|
bool _disposed = false;
|
||||||
|
|
||||||
|
bool get isLoading => _isLoading;
|
||||||
|
bool get isBusy => _isBusy;
|
||||||
|
int get currentStreak => _currentStreak;
|
||||||
|
int get longestStreak => _longestStreak;
|
||||||
|
Set<String> get studiedDates => _studiedDates;
|
||||||
|
Map<String, dynamic> get events => _events;
|
||||||
|
DateTime get visibleMonth => _visibleMonth;
|
||||||
|
|
||||||
|
void _safeNotify() {
|
||||||
|
if (!_disposed) notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_disposed = true;
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> init() => refresh();
|
||||||
|
|
||||||
|
Future<void> goToPreviousMonth() async {
|
||||||
|
_visibleMonth = DateTime(_visibleMonth.year, _visibleMonth.month - 1);
|
||||||
|
await refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> goToNextMonth() async {
|
||||||
|
_visibleMonth = DateTime(_visibleMonth.year, _visibleMonth.month + 1);
|
||||||
|
await refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> refresh() async {
|
||||||
|
_isLoading = true;
|
||||||
|
_safeNotify();
|
||||||
|
await Future.wait([_fetchStreak(), _fetchEvents()]);
|
||||||
|
_isLoading = false;
|
||||||
|
_safeNotify();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _fetchStreak() async {
|
||||||
|
try {
|
||||||
|
final response = await http.get(
|
||||||
|
Uri.parse(
|
||||||
|
'$baseUrl/api/study/streak?studentId=$studentId'
|
||||||
|
'&year=${_visibleMonth.year}&month=${_visibleMonth.month}',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (response.statusCode == 200) {
|
||||||
|
final data = jsonDecode(utf8.decode(response.bodyBytes));
|
||||||
|
_currentStreak = (data['currentStreak'] as num?)?.toInt() ?? 0;
|
||||||
|
_longestStreak = (data['longestStreak'] as num?)?.toInt() ?? 0;
|
||||||
|
_studiedDates = ((data['studiedDates'] as List<dynamic>?) ?? [])
|
||||||
|
.map((d) => d.toString())
|
||||||
|
.toSet();
|
||||||
|
}
|
||||||
|
} catch (_) {
|
||||||
|
// 조용히 무시
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _fetchEvents() async {
|
||||||
|
try {
|
||||||
|
final response = await http.get(
|
||||||
|
Uri.parse(
|
||||||
|
'$baseUrl/api/calendar/events?year=${_visibleMonth.year}&month=${_visibleMonth.month}',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (response.statusCode == 200) {
|
||||||
|
final data = jsonDecode(utf8.decode(response.bodyBytes));
|
||||||
|
final List<dynamic> list = data['events'] ?? [];
|
||||||
|
_events = {for (final e in list) e['date']: e};
|
||||||
|
}
|
||||||
|
} catch (_) {
|
||||||
|
// 조용히 무시
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<(bool success, String message)> saveEvent({
|
||||||
|
required String date,
|
||||||
|
required String title,
|
||||||
|
required String description,
|
||||||
|
required String createdBy,
|
||||||
|
}) async {
|
||||||
|
_isBusy = true;
|
||||||
|
_safeNotify();
|
||||||
|
try {
|
||||||
|
final response = await http.post(
|
||||||
|
Uri.parse('$baseUrl/api/calendar/events'),
|
||||||
|
headers: {"Content-Type": "application/json"},
|
||||||
|
body: jsonEncode({
|
||||||
|
"date": date,
|
||||||
|
"title": title.trim(),
|
||||||
|
"description": description.trim(),
|
||||||
|
"createdBy": createdBy,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
final result = jsonDecode(utf8.decode(response.bodyBytes));
|
||||||
|
if (response.statusCode == 200 && result['status'] == 'success') {
|
||||||
|
await _fetchEvents();
|
||||||
|
return (true, '${result['message'] ?? '등록했습니다.'}');
|
||||||
|
}
|
||||||
|
return (false, '${result['message'] ?? '등록 실패'}');
|
||||||
|
} catch (e) {
|
||||||
|
return (false, '네트워크 에러: $e');
|
||||||
|
} finally {
|
||||||
|
_isBusy = false;
|
||||||
|
_safeNotify();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<(bool success, String message)> deleteEvent(String date) async {
|
||||||
|
_isBusy = true;
|
||||||
|
_safeNotify();
|
||||||
|
try {
|
||||||
|
final response = await http.delete(
|
||||||
|
Uri.parse('$baseUrl/api/calendar/events/$date'),
|
||||||
|
);
|
||||||
|
final result = jsonDecode(utf8.decode(response.bodyBytes));
|
||||||
|
if (response.statusCode == 200 && result['status'] == 'success') {
|
||||||
|
await _fetchEvents();
|
||||||
|
return (true, '${result['message'] ?? '삭제했습니다.'}');
|
||||||
|
}
|
||||||
|
return (false, '${result['message'] ?? '삭제 실패'}');
|
||||||
|
} catch (e) {
|
||||||
|
return (false, '네트워크 에러: $e');
|
||||||
|
} finally {
|
||||||
|
_isBusy = false;
|
||||||
|
_safeNotify();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
// 🍅 뽀모도로 루틴 공유(다른 학생과 공부/휴식 시간 조합 공유) 화면의 기능(서버 통신/상태) 담당 컨트롤러.
|
||||||
|
import 'dart:convert';
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:http/http.dart' as http;
|
||||||
|
import '../config.dart';
|
||||||
|
|
||||||
|
class StudyPresetController extends ChangeNotifier {
|
||||||
|
final String studentId;
|
||||||
|
final String studentName;
|
||||||
|
|
||||||
|
StudyPresetController({required this.studentId, required this.studentName});
|
||||||
|
|
||||||
|
bool _isLoading = true;
|
||||||
|
bool _isBusy = false;
|
||||||
|
String _sort = 'popular'; // popular / recent
|
||||||
|
List<dynamic> _presets = [];
|
||||||
|
bool _disposed = false;
|
||||||
|
|
||||||
|
bool get isLoading => _isLoading;
|
||||||
|
bool get isBusy => _isBusy;
|
||||||
|
String get sort => _sort;
|
||||||
|
List<dynamic> get presets => _presets;
|
||||||
|
|
||||||
|
void _safeNotify() {
|
||||||
|
if (!_disposed) notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_disposed = true;
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> init() => fetchPresets();
|
||||||
|
|
||||||
|
Future<void> setSort(String sort) async {
|
||||||
|
if (_sort == sort) return;
|
||||||
|
_sort = sort;
|
||||||
|
await fetchPresets();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> fetchPresets() async {
|
||||||
|
_isLoading = true;
|
||||||
|
_safeNotify();
|
||||||
|
try {
|
||||||
|
final response = await http.get(
|
||||||
|
Uri.parse('$baseUrl/api/study/presets?sort=$_sort'),
|
||||||
|
);
|
||||||
|
if (response.statusCode == 200) {
|
||||||
|
final data = jsonDecode(utf8.decode(response.bodyBytes));
|
||||||
|
_presets = data['presets'] ?? [];
|
||||||
|
}
|
||||||
|
} catch (_) {
|
||||||
|
// 조용히 무시 - 마지막으로 받아온 목록을 유지한다.
|
||||||
|
} finally {
|
||||||
|
_isLoading = false;
|
||||||
|
_safeNotify();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<(bool success, String message)> share({
|
||||||
|
required String title,
|
||||||
|
required int studyMinutes,
|
||||||
|
required int breakMinutes,
|
||||||
|
}) async {
|
||||||
|
_isBusy = true;
|
||||||
|
_safeNotify();
|
||||||
|
try {
|
||||||
|
final response = await http.post(
|
||||||
|
Uri.parse('$baseUrl/api/study/presets'),
|
||||||
|
headers: {"Content-Type": "application/json"},
|
||||||
|
body: jsonEncode({
|
||||||
|
"studentId": studentId,
|
||||||
|
"studentName": studentName,
|
||||||
|
"title": title.trim(),
|
||||||
|
"studyMinutes": studyMinutes,
|
||||||
|
"breakMinutes": breakMinutes,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
final result = jsonDecode(utf8.decode(response.bodyBytes));
|
||||||
|
if (response.statusCode == 200 && result['status'] == 'success') {
|
||||||
|
await fetchPresets();
|
||||||
|
return (true, '${result['message'] ?? '루틴을 공유했습니다.'}');
|
||||||
|
}
|
||||||
|
return (false, '${result['message'] ?? '루틴 공유 실패'}');
|
||||||
|
} catch (e) {
|
||||||
|
return (false, '네트워크 에러: $e');
|
||||||
|
} finally {
|
||||||
|
_isBusy = false;
|
||||||
|
_safeNotify();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 다른 학생의 루틴을 내 설정으로 적용할 때 호출 - 인기도(사용 횟수)만 올려준다.
|
||||||
|
Future<void> markUsed(int presetId) async {
|
||||||
|
try {
|
||||||
|
await http.post(Uri.parse('$baseUrl/api/study/presets/$presetId/use'));
|
||||||
|
} catch (_) {
|
||||||
|
// 인기도 집계 실패는 조용히 무시 - 적용 자체는 이미 끝난 뒤라 영향 없다.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<(bool success, String message)> delete(int presetId) async {
|
||||||
|
_isBusy = true;
|
||||||
|
_safeNotify();
|
||||||
|
try {
|
||||||
|
final response = await http.delete(
|
||||||
|
Uri.parse('$baseUrl/api/study/presets/$presetId'),
|
||||||
|
headers: {"Content-Type": "application/json"},
|
||||||
|
body: jsonEncode({"studentId": studentId}),
|
||||||
|
);
|
||||||
|
final result = jsonDecode(utf8.decode(response.bodyBytes));
|
||||||
|
if (response.statusCode == 200 && result['status'] == 'success') {
|
||||||
|
await fetchPresets();
|
||||||
|
return (true, '${result['message'] ?? '삭제되었습니다.'}');
|
||||||
|
}
|
||||||
|
return (false, '${result['message'] ?? '삭제 실패'}');
|
||||||
|
} catch (e) {
|
||||||
|
return (false, '네트워크 에러: $e');
|
||||||
|
} finally {
|
||||||
|
_isBusy = false;
|
||||||
|
_safeNotify();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
// 🏆 학년별 공부시간 랭킹 화면의 기능(서버 통신/상태) 담당 컨트롤러.
|
||||||
|
import 'dart:convert';
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:http/http.dart' as http;
|
||||||
|
import '../config.dart';
|
||||||
|
|
||||||
|
class StudyRankingController extends ChangeNotifier {
|
||||||
|
final int grade;
|
||||||
|
|
||||||
|
StudyRankingController({required this.grade});
|
||||||
|
|
||||||
|
bool _isLoading = true;
|
||||||
|
String _period = 'today'; // today / week / total
|
||||||
|
String _scope = 'grade'; // grade / all
|
||||||
|
List<dynamic> _ranking = [];
|
||||||
|
|
||||||
|
bool get isLoading => _isLoading;
|
||||||
|
String get period => _period;
|
||||||
|
String get scope => _scope;
|
||||||
|
List<dynamic> get ranking => _ranking;
|
||||||
|
|
||||||
|
Future<void> init() => fetchRanking();
|
||||||
|
|
||||||
|
Future<void> setPeriod(String period) async {
|
||||||
|
if (_period == period) return;
|
||||||
|
_period = period;
|
||||||
|
await fetchRanking();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> setScope(String scope) async {
|
||||||
|
if (_scope == scope) return;
|
||||||
|
_scope = scope;
|
||||||
|
await fetchRanking();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> fetchRanking() async {
|
||||||
|
_isLoading = true;
|
||||||
|
notifyListeners();
|
||||||
|
try {
|
||||||
|
final gradeParam = _scope == 'grade' ? '&grade=$grade' : '';
|
||||||
|
final response = await http.get(
|
||||||
|
Uri.parse('$baseUrl/api/study/ranking?period=$_period$gradeParam'),
|
||||||
|
);
|
||||||
|
if (response.statusCode == 200) {
|
||||||
|
final data = jsonDecode(utf8.decode(response.bodyBytes));
|
||||||
|
_ranking = data['ranking'] ?? [];
|
||||||
|
}
|
||||||
|
} catch (_) {
|
||||||
|
// 조용히 무시 - 마지막으로 받아온 랭킹을 유지한다.
|
||||||
|
} finally {
|
||||||
|
_isLoading = false;
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,324 @@
|
|||||||
|
// ⏱️ 우리 학교 전용 "백품타" 공부 타이머 화면의 기능(서버 통신/상태) 담당 컨트롤러.
|
||||||
|
import 'dart:async';
|
||||||
|
import 'dart:convert';
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:http/http.dart' as http;
|
||||||
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
import '../config.dart';
|
||||||
|
|
||||||
|
enum StudyTimerState { idle, running, paused }
|
||||||
|
|
||||||
|
// 🍅 뽀모도로 진행 단계. study/break가 아니면(=null) 뽀모도로가 꺼져있거나 아직 시작 전.
|
||||||
|
enum PomodoroPhase { study, breakTime }
|
||||||
|
|
||||||
|
const String _prefPomodoroEnabled = 'study_pomodoro_enabled';
|
||||||
|
const String _prefPomodoroStudyMinutes = 'study_pomodoro_study_minutes';
|
||||||
|
const String _prefPomodoroBreakMinutes = 'study_pomodoro_break_minutes';
|
||||||
|
|
||||||
|
class StudyTimerController extends ChangeNotifier {
|
||||||
|
final String studentId;
|
||||||
|
final String studentName;
|
||||||
|
|
||||||
|
StudyTimerController({required this.studentId, required this.studentName});
|
||||||
|
|
||||||
|
bool _isLoading = true;
|
||||||
|
bool _isBusy = false; // 시작/일시정지/재개/종료 버튼 눌러서 서버 응답 기다리는 중
|
||||||
|
StudyTimerState _state = StudyTimerState.idle;
|
||||||
|
int _elapsedSeconds = 0; // 지금 세션에서 "정지 상태"일 때 확정된 누적 초 (일시정지 구간 제외)
|
||||||
|
DateTime? _lastResumedAt; // running일 때만 의미 있음 - 여기서부터 실시간으로 흐른다
|
||||||
|
int _todaySeconds = 0;
|
||||||
|
int _weekSeconds = 0;
|
||||||
|
int _totalSeconds = 0;
|
||||||
|
Timer? _ticker;
|
||||||
|
bool _disposed = false;
|
||||||
|
|
||||||
|
bool pomodoroEnabled = false;
|
||||||
|
int studyMinutes = 50;
|
||||||
|
int breakMinutes = 10;
|
||||||
|
PomodoroPhase? _phase;
|
||||||
|
// 🐛 [웹 탭 딜레이 버그 수정] 브라우저가 백그라운드 탭의 setInterval을 느리게(심하면 분당
|
||||||
|
// 1번) 돌리기 때문에, 매 tick마다 초를 1씩 빼는 카운트다운은 다른 창을 오래 보고 있으면
|
||||||
|
// 실제 시간보다 많이 밀린다. 그래서 "언제 끝나야 하는지"(_phaseEndsAt)를 절대 시각으로
|
||||||
|
// 잡아두고, tick이 늦게 와도 그 시각을 기준으로 정확히 계산/전환한다.
|
||||||
|
DateTime? _phaseEndsAt;
|
||||||
|
int completedCycles = 0;
|
||||||
|
bool _phaseTransitioning = false;
|
||||||
|
Timer? _phaseTicker;
|
||||||
|
|
||||||
|
/// 🍅 뽀모도로 진행 상황이 바뀔 때(공부↔휴식 자동 전환) UI에 알림을 띄우기 위한 콜백.
|
||||||
|
/// 자동으로 일어나는 일이라 버튼 액션처럼 (bool,message)를 돌려줄 대상이 없어서 콜백으로 처리한다.
|
||||||
|
void Function(String message)? onPhaseChanged;
|
||||||
|
|
||||||
|
bool get isLoading => _isLoading;
|
||||||
|
bool get isBusy => _isBusy;
|
||||||
|
bool get isRunning => _state == StudyTimerState.running;
|
||||||
|
bool get isPaused => _state == StudyTimerState.paused;
|
||||||
|
bool get isIdle => _state == StudyTimerState.idle;
|
||||||
|
PomodoroPhase? get phase => _phase;
|
||||||
|
|
||||||
|
int get phaseSecondsLeft {
|
||||||
|
if (_phaseEndsAt == null) return 0;
|
||||||
|
final diff = _phaseEndsAt!.difference(DateTime.now()).inSeconds;
|
||||||
|
return diff > 0 ? diff : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 지금 세션 하나만의 경과 시간(타이머 화면 큰 숫자용). 일시정지 중엔 멈춰 있다.
|
||||||
|
int get liveElapsedSeconds {
|
||||||
|
if (_state != StudyTimerState.running || _lastResumedAt == null) {
|
||||||
|
return _elapsedSeconds;
|
||||||
|
}
|
||||||
|
final diff = DateTime.now().difference(_lastResumedAt!).inSeconds;
|
||||||
|
return _elapsedSeconds + (diff > 0 ? diff : 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 지금 진행 중인 세션까지 포함한 "오늘" 누적 초. running일 때만 실시간으로 늘어난다.
|
||||||
|
int get todaySecondsLive => _todaySeconds + _liveDelta;
|
||||||
|
int get weekSecondsLive => _weekSeconds + _liveDelta;
|
||||||
|
int get totalSecondsLive => _totalSeconds + _liveDelta;
|
||||||
|
|
||||||
|
int get _liveDelta => _state == StudyTimerState.idle ? 0 : liveElapsedSeconds;
|
||||||
|
|
||||||
|
void _safeNotify() {
|
||||||
|
if (!_disposed) notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> init() async {
|
||||||
|
await _loadPomodoroSettings();
|
||||||
|
await _fetchStatus();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 🐛 [웹 탭 딜레이 버그 수정] 다른 창/탭에 갔다가 이 화면으로 돌아왔을 때 호출한다.
|
||||||
|
/// 백그라운드 탭에서는 브라우저가 타이머를 느리게 돌려서 화면 숫자와 뽀모도로 전환이
|
||||||
|
/// 밀릴 수 있으므로, 서버 상태를 다시 불러오고(진짜 경과 시간 재동기화) 뽀모도로 구간이
|
||||||
|
/// 이미 끝났어야 한다면 바로 따라잡는다.
|
||||||
|
Future<void> onAppResumed() async {
|
||||||
|
await _fetchStatus();
|
||||||
|
checkPhaseDeadlineNow();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_disposed = true;
|
||||||
|
_ticker?.cancel();
|
||||||
|
_phaseTicker?.cancel();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _loadPomodoroSettings() async {
|
||||||
|
try {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
pomodoroEnabled = prefs.getBool(_prefPomodoroEnabled) ?? false;
|
||||||
|
studyMinutes = prefs.getInt(_prefPomodoroStudyMinutes) ?? 50;
|
||||||
|
breakMinutes = prefs.getInt(_prefPomodoroBreakMinutes) ?? 10;
|
||||||
|
} catch (_) {
|
||||||
|
// 조용히 무시 - 기본값(50/10)을 그대로 쓴다.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 뽀모도로 켜짐 여부와 공부/휴식 시간(분)을 저장한다. 타이머가 진행 중일 때 값이
|
||||||
|
/// 흔들리면 안 되므로, 화면에서 idle일 때만 호출하도록 막고 있다.
|
||||||
|
Future<void> setPomodoroSettings({
|
||||||
|
required bool enabled,
|
||||||
|
required int studyMinutes,
|
||||||
|
required int breakMinutes,
|
||||||
|
}) async {
|
||||||
|
pomodoroEnabled = enabled;
|
||||||
|
this.studyMinutes = studyMinutes;
|
||||||
|
this.breakMinutes = breakMinutes;
|
||||||
|
_safeNotify();
|
||||||
|
try {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
await prefs.setBool(_prefPomodoroEnabled, enabled);
|
||||||
|
await prefs.setInt(_prefPomodoroStudyMinutes, studyMinutes);
|
||||||
|
await prefs.setInt(_prefPomodoroBreakMinutes, breakMinutes);
|
||||||
|
} catch (_) {
|
||||||
|
// 조용히 무시 - 이번 세션 동안은 메모리 값으로라도 동작한다.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _fetchStatus() async {
|
||||||
|
try {
|
||||||
|
final response = await http.get(
|
||||||
|
Uri.parse('$baseUrl/api/study/status?studentId=$studentId'),
|
||||||
|
);
|
||||||
|
if (response.statusCode == 200) {
|
||||||
|
final data = jsonDecode(utf8.decode(response.bodyBytes));
|
||||||
|
_state = _parseState(data['state']);
|
||||||
|
_elapsedSeconds = (data['elapsedSeconds'] as num?)?.toInt() ?? 0;
|
||||||
|
_lastResumedAt = data['lastResumedAt'] != null
|
||||||
|
? DateTime.tryParse(data['lastResumedAt'])
|
||||||
|
: null;
|
||||||
|
_todaySeconds = (data['todaySeconds'] as num?)?.toInt() ?? 0;
|
||||||
|
_weekSeconds = (data['weekSeconds'] as num?)?.toInt() ?? 0;
|
||||||
|
_totalSeconds = (data['totalSeconds'] as num?)?.toInt() ?? 0;
|
||||||
|
_restartTickerIfNeeded();
|
||||||
|
}
|
||||||
|
} catch (_) {
|
||||||
|
// 조용히 무시 - 마지막으로 알던 상태를 유지한다.
|
||||||
|
} finally {
|
||||||
|
_isLoading = false;
|
||||||
|
_safeNotify();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
StudyTimerState _parseState(dynamic raw) {
|
||||||
|
switch (raw) {
|
||||||
|
case 'running':
|
||||||
|
return StudyTimerState.running;
|
||||||
|
case 'paused':
|
||||||
|
return StudyTimerState.paused;
|
||||||
|
default:
|
||||||
|
return StudyTimerState.idle;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _restartTickerIfNeeded() {
|
||||||
|
_ticker?.cancel();
|
||||||
|
if (_state == StudyTimerState.running) {
|
||||||
|
_ticker = Timer.periodic(
|
||||||
|
const Duration(seconds: 1),
|
||||||
|
(_) => _safeNotify(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<(bool success, String message)> start() async {
|
||||||
|
final result = await _action(
|
||||||
|
'/api/study/start',
|
||||||
|
body: {"studentId": studentId, "studentName": studentName},
|
||||||
|
onSuccess: (_) async {
|
||||||
|
await _fetchStatus();
|
||||||
|
return '공부를 시작했습니다.';
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (result.$1 && pomodoroEnabled) {
|
||||||
|
completedCycles = 0;
|
||||||
|
_phase = PomodoroPhase.study;
|
||||||
|
_phaseEndsAt = DateTime.now().add(Duration(minutes: studyMinutes));
|
||||||
|
_startPhaseTicker();
|
||||||
|
_safeNotify();
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<(bool success, String message)> pause() async {
|
||||||
|
return _action(
|
||||||
|
'/api/study/pause',
|
||||||
|
onSuccess: (_) async {
|
||||||
|
await _fetchStatus();
|
||||||
|
return '일시정지했습니다.';
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<(bool success, String message)> resume() async {
|
||||||
|
return _action(
|
||||||
|
'/api/study/resume',
|
||||||
|
onSuccess: (_) async {
|
||||||
|
await _fetchStatus();
|
||||||
|
return '다시 시작했습니다.';
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<(bool success, String message)> stop() async {
|
||||||
|
final result = await _action(
|
||||||
|
'/api/study/stop',
|
||||||
|
onSuccess: (result) async {
|
||||||
|
await _fetchStatus(); // 오늘/이번주/전체 합계를 서버 값으로 다시 맞춘다.
|
||||||
|
final minutes =
|
||||||
|
((result['durationSeconds'] as num?)?.toInt() ?? 0) ~/ 60;
|
||||||
|
return '이번 공부 시간: $minutes분 기록 완료!';
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (result.$1) _stopPhaseTicker();
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
void _stopPhaseTicker() {
|
||||||
|
_phaseTicker?.cancel();
|
||||||
|
_phaseTicker = null;
|
||||||
|
_phase = null;
|
||||||
|
_phaseEndsAt = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 🍅 1초마다 지금이 끝나야 할 시각(_phaseEndsAt)을 지났는지 확인해서, 지났으면
|
||||||
|
/// 공부↔휴식을 자동으로 전환한다. 휴식 구간에서도 계속 흘러야 해서, running일 때만
|
||||||
|
/// 도는 _ticker와는 별도로 관리한다.
|
||||||
|
void _startPhaseTicker() {
|
||||||
|
_phaseTicker?.cancel();
|
||||||
|
_phaseTicker = Timer.periodic(
|
||||||
|
const Duration(seconds: 1),
|
||||||
|
(_) => _checkPhaseDeadline(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 탭이 백그라운드에 있는 동안 브라우저가 setInterval을 느리게 돌리면 위 1초 tick도
|
||||||
|
/// 늦게 온다. 탭이 다시 활성화될 때(앱이 resumed 될 때) 화면에서 이 메서드를 직접
|
||||||
|
/// 호출해서, 밀린 tick을 기다리지 않고 바로 지난 시각을 따라잡는다.
|
||||||
|
void checkPhaseDeadlineNow() => _checkPhaseDeadline();
|
||||||
|
|
||||||
|
void _checkPhaseDeadline() {
|
||||||
|
if (_phase == null || _phaseTransitioning || _phaseEndsAt == null) return;
|
||||||
|
if (DateTime.now().isAfter(_phaseEndsAt!)) {
|
||||||
|
_advancePhase();
|
||||||
|
} else {
|
||||||
|
_safeNotify();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _advancePhase() async {
|
||||||
|
_phaseTransitioning = true;
|
||||||
|
if (_phase == PomodoroPhase.study) {
|
||||||
|
final (success, _) = await pause();
|
||||||
|
if (success) {
|
||||||
|
completedCycles += 1;
|
||||||
|
_phase = PomodoroPhase.breakTime;
|
||||||
|
_phaseEndsAt = DateTime.now().add(Duration(minutes: breakMinutes));
|
||||||
|
onPhaseChanged?.call('공부 끝! $breakMinutes분간 쉬어가요.');
|
||||||
|
} else {
|
||||||
|
_phaseEndsAt = DateTime.now().add(
|
||||||
|
const Duration(seconds: 1),
|
||||||
|
); // 네트워크 문제 등으로 실패했으면 잠시 후 다시 시도.
|
||||||
|
}
|
||||||
|
} else if (_phase == PomodoroPhase.breakTime) {
|
||||||
|
final (success, _) = await resume();
|
||||||
|
if (success) {
|
||||||
|
_phase = PomodoroPhase.study;
|
||||||
|
_phaseEndsAt = DateTime.now().add(Duration(minutes: studyMinutes));
|
||||||
|
onPhaseChanged?.call('휴식 끝! 다시 공부를 시작해요.');
|
||||||
|
} else {
|
||||||
|
_phaseEndsAt = DateTime.now().add(const Duration(seconds: 1));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_phaseTransitioning = false;
|
||||||
|
_safeNotify();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<(bool success, String message)> _action(
|
||||||
|
String path, {
|
||||||
|
Map<String, dynamic> body = const {},
|
||||||
|
required Future<String> Function(Map<String, dynamic> result) onSuccess,
|
||||||
|
}) async {
|
||||||
|
_isBusy = true;
|
||||||
|
_safeNotify();
|
||||||
|
try {
|
||||||
|
final response = await http.post(
|
||||||
|
Uri.parse('$baseUrl$path'),
|
||||||
|
headers: {"Content-Type": "application/json"},
|
||||||
|
body: jsonEncode({"studentId": studentId, ...body}),
|
||||||
|
);
|
||||||
|
final result = jsonDecode(utf8.decode(response.bodyBytes));
|
||||||
|
if (response.statusCode == 200 && result['status'] == 'success') {
|
||||||
|
final message = await onSuccess(result);
|
||||||
|
return (true, message);
|
||||||
|
}
|
||||||
|
return (false, '${result['message'] ?? '처리 실패'}');
|
||||||
|
} catch (e) {
|
||||||
|
return (false, '네트워크 에러: $e');
|
||||||
|
} finally {
|
||||||
|
_isBusy = false;
|
||||||
|
_safeNotify();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,124 @@
|
|||||||
|
// 📍 선생님 호출(학생용) 화면의 기능(서버 통신/상태) 담당 컨트롤러.
|
||||||
|
import 'dart:convert';
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:http/http.dart' as http;
|
||||||
|
import '../config.dart';
|
||||||
|
import '../data/teacher_call_schedule.dart';
|
||||||
|
|
||||||
|
class TeacherCallController extends ChangeNotifier {
|
||||||
|
final String studentId;
|
||||||
|
final String studentName;
|
||||||
|
|
||||||
|
TeacherCallController({required this.studentId, required this.studentName});
|
||||||
|
|
||||||
|
bool _isLoading = true;
|
||||||
|
bool _isCalling = false;
|
||||||
|
List<dynamic> _teachers = [];
|
||||||
|
List<dynamic> _myCalls = [];
|
||||||
|
List<dynamic> _ranking = [];
|
||||||
|
bool _disposed = false;
|
||||||
|
|
||||||
|
bool get isLoading => _isLoading;
|
||||||
|
bool get isCalling => _isCalling;
|
||||||
|
List<dynamic> get teachers => _teachers;
|
||||||
|
List<dynamic> get myCalls => _myCalls;
|
||||||
|
List<dynamic> get ranking => _ranking;
|
||||||
|
|
||||||
|
void _safeNotify() {
|
||||||
|
if (!_disposed) notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_disposed = true;
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
bool isInClassNow(String teacherName) {
|
||||||
|
final now = DateTime.now();
|
||||||
|
final hhmm =
|
||||||
|
'${now.hour.toString().padLeft(2, '0')}:${now.minute.toString().padLeft(2, '0')}';
|
||||||
|
return teachersInClassNow(now.weekday, hhmm).contains(teacherName);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> init() async {
|
||||||
|
await Future.wait([_fetchTeachers(), _fetchMyCalls(), _fetchRanking()]);
|
||||||
|
_isLoading = false;
|
||||||
|
_safeNotify();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> refresh() => init();
|
||||||
|
|
||||||
|
Future<void> _fetchTeachers() async {
|
||||||
|
try {
|
||||||
|
final response = await http.get(
|
||||||
|
Uri.parse('$baseUrl/api/teacher-call/teachers'),
|
||||||
|
);
|
||||||
|
if (response.statusCode == 200) {
|
||||||
|
final data = jsonDecode(utf8.decode(response.bodyBytes));
|
||||||
|
_teachers = data['teachers'] ?? [];
|
||||||
|
}
|
||||||
|
} catch (_) {
|
||||||
|
// 조용히 무시 - 마지막으로 받아온 목록을 유지한다.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _fetchMyCalls() async {
|
||||||
|
try {
|
||||||
|
final response = await http.get(
|
||||||
|
Uri.parse('$baseUrl/api/teacher-call/my-calls?studentId=$studentId'),
|
||||||
|
);
|
||||||
|
if (response.statusCode == 200) {
|
||||||
|
final data = jsonDecode(utf8.decode(response.bodyBytes));
|
||||||
|
_myCalls = data['calls'] ?? [];
|
||||||
|
}
|
||||||
|
} catch (_) {
|
||||||
|
// 조용히 무시
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _fetchRanking() async {
|
||||||
|
try {
|
||||||
|
final response = await http.get(
|
||||||
|
Uri.parse('$baseUrl/api/teacher-call/ranking'),
|
||||||
|
);
|
||||||
|
if (response.statusCode == 200) {
|
||||||
|
final data = jsonDecode(utf8.decode(response.bodyBytes));
|
||||||
|
_ranking = data['ranking'] ?? [];
|
||||||
|
}
|
||||||
|
} catch (_) {
|
||||||
|
// 조용히 무시
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<(bool success, String message)> callTeacher({
|
||||||
|
required String teacherId,
|
||||||
|
required String purpose,
|
||||||
|
}) async {
|
||||||
|
_isCalling = true;
|
||||||
|
_safeNotify();
|
||||||
|
try {
|
||||||
|
final response = await http.post(
|
||||||
|
Uri.parse('$baseUrl/api/teacher-call/call'),
|
||||||
|
headers: {"Content-Type": "application/json"},
|
||||||
|
body: jsonEncode({
|
||||||
|
"studentId": studentId,
|
||||||
|
"studentName": studentName,
|
||||||
|
"teacherId": teacherId,
|
||||||
|
"purpose": purpose,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
final result = jsonDecode(utf8.decode(response.bodyBytes));
|
||||||
|
if (response.statusCode == 200 && result['status'] == 'success') {
|
||||||
|
await Future.wait([_fetchMyCalls(), _fetchRanking()]);
|
||||||
|
return (true, '${result['message'] ?? '호출했습니다.'}');
|
||||||
|
}
|
||||||
|
return (false, '${result['message'] ?? '호출 실패'}');
|
||||||
|
} catch (e) {
|
||||||
|
return (false, '네트워크 에러: $e');
|
||||||
|
} finally {
|
||||||
|
_isCalling = false;
|
||||||
|
_safeNotify();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
// 📍 선생님 위치 등록(교사용) 화면의 기능(서버 통신/상태) 담당 컨트롤러.
|
||||||
|
import 'dart:convert';
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:http/http.dart' as http;
|
||||||
|
import '../config.dart';
|
||||||
|
|
||||||
|
class TeacherLocationController extends ChangeNotifier {
|
||||||
|
final String teacherId;
|
||||||
|
|
||||||
|
TeacherLocationController({required this.teacherId});
|
||||||
|
|
||||||
|
bool _isLoading = true;
|
||||||
|
bool _isSaving = false;
|
||||||
|
String _currentLocation = "교무실";
|
||||||
|
List<dynamic> _receivedCalls = [];
|
||||||
|
bool _disposed = false;
|
||||||
|
|
||||||
|
bool get isLoading => _isLoading;
|
||||||
|
bool get isSaving => _isSaving;
|
||||||
|
String get currentLocation => _currentLocation;
|
||||||
|
List<dynamic> get receivedCalls => _receivedCalls;
|
||||||
|
|
||||||
|
void _safeNotify() {
|
||||||
|
if (!_disposed) notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_disposed = true;
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> init() async {
|
||||||
|
await Future.wait([_fetchMyLocation(), fetchReceivedCalls()]);
|
||||||
|
_isLoading = false;
|
||||||
|
_safeNotify();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _fetchMyLocation() async {
|
||||||
|
try {
|
||||||
|
final response = await http.get(
|
||||||
|
Uri.parse('$baseUrl/api/teacher-call/teachers'),
|
||||||
|
);
|
||||||
|
if (response.statusCode == 200) {
|
||||||
|
final data = jsonDecode(utf8.decode(response.bodyBytes));
|
||||||
|
final List<dynamic> list = data['teachers'] ?? [];
|
||||||
|
final mine = list.firstWhere(
|
||||||
|
(t) => t['teacherId'] == teacherId,
|
||||||
|
orElse: () => null,
|
||||||
|
);
|
||||||
|
if (mine != null) _currentLocation = mine['location'] ?? '교무실';
|
||||||
|
}
|
||||||
|
} catch (_) {
|
||||||
|
// 조용히 무시
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> fetchReceivedCalls() async {
|
||||||
|
try {
|
||||||
|
final response = await http.get(
|
||||||
|
Uri.parse(
|
||||||
|
'$baseUrl/api/teacher-call/received-calls?teacherId=$teacherId',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (response.statusCode == 200) {
|
||||||
|
final data = jsonDecode(utf8.decode(response.bodyBytes));
|
||||||
|
_receivedCalls = data['calls'] ?? [];
|
||||||
|
_safeNotify();
|
||||||
|
}
|
||||||
|
} catch (_) {
|
||||||
|
// 조용히 무시
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<(bool success, String message)> updateLocation(String location) async {
|
||||||
|
_isSaving = true;
|
||||||
|
_safeNotify();
|
||||||
|
try {
|
||||||
|
final response = await http.post(
|
||||||
|
Uri.parse('$baseUrl/api/teacher-call/location'),
|
||||||
|
headers: {"Content-Type": "application/json"},
|
||||||
|
body: jsonEncode({"teacherId": teacherId, "location": location}),
|
||||||
|
);
|
||||||
|
final result = jsonDecode(utf8.decode(response.bodyBytes));
|
||||||
|
if (response.statusCode == 200 && result['status'] == 'success') {
|
||||||
|
_currentLocation = location;
|
||||||
|
return (true, '${result['message'] ?? '위치가 업데이트되었습니다.'}');
|
||||||
|
}
|
||||||
|
return (false, '${result['message'] ?? '업데이트 실패'}');
|
||||||
|
} catch (e) {
|
||||||
|
return (false, '네트워크 에러: $e');
|
||||||
|
} finally {
|
||||||
|
_isSaving = false;
|
||||||
|
_safeNotify();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,8 +6,11 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:firebase_core/firebase_core.dart';
|
import 'package:firebase_core/firebase_core.dart';
|
||||||
import 'firebase_options.dart';
|
import 'firebase_options.dart';
|
||||||
import 'config.dart';
|
import 'config.dart';
|
||||||
|
import 'function/session_store.dart';
|
||||||
import 'pocket_watch_service.dart';
|
import 'pocket_watch_service.dart';
|
||||||
import 'screens/login_screen.dart';
|
import 'theme/app_palette.dart';
|
||||||
|
import 'ui/login_screen.dart';
|
||||||
|
import 'ui/main_dashboard.dart';
|
||||||
|
|
||||||
void main() async {
|
void main() async {
|
||||||
WidgetsFlutterBinding.ensureInitialized();
|
WidgetsFlutterBinding.ensureInitialized();
|
||||||
@@ -19,6 +22,18 @@ void main() async {
|
|||||||
runApp(const SchoolAttendanceApp());
|
runApp(const SchoolAttendanceApp());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 🌊 스크롤이 끝에 닿을 때 나오는 파란색 오버스크롤 글로우 효과를 없앤다.
|
||||||
|
class _NoGlowScrollBehavior extends MaterialScrollBehavior {
|
||||||
|
@override
|
||||||
|
Widget buildOverscrollIndicator(
|
||||||
|
BuildContext context,
|
||||||
|
Widget child,
|
||||||
|
ScrollableDetails details,
|
||||||
|
) {
|
||||||
|
return child;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
class SchoolAttendanceApp extends StatelessWidget {
|
class SchoolAttendanceApp extends StatelessWidget {
|
||||||
const SchoolAttendanceApp({super.key});
|
const SchoolAttendanceApp({super.key});
|
||||||
|
|
||||||
@@ -26,9 +41,75 @@ class SchoolAttendanceApp extends StatelessWidget {
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return MaterialApp(
|
return MaterialApp(
|
||||||
debugShowCheckedModeBanner: false,
|
debugShowCheckedModeBanner: false,
|
||||||
title: '백산고등학교 학생 도우미',
|
title: '$schoolName 학생 도우미',
|
||||||
theme: ThemeData(primarySwatch: Colors.indigo, useMaterial3: true),
|
scrollBehavior: _NoGlowScrollBehavior(),
|
||||||
home: const LoginScreen(), // 🚪 앱을 켜면 무조건 로그인 화면이 먼저 등장합니다.
|
theme: ThemeData(
|
||||||
|
useMaterial3: true,
|
||||||
|
scaffoldBackgroundColor: AppPalette.mist,
|
||||||
|
colorScheme:
|
||||||
|
ColorScheme.fromSeed(
|
||||||
|
seedColor: AppPalette.ink,
|
||||||
|
brightness: Brightness.light,
|
||||||
|
).copyWith(
|
||||||
|
primary: AppPalette.ink,
|
||||||
|
onPrimary: AppPalette.paper,
|
||||||
|
secondary: AppPalette.ink,
|
||||||
|
onSecondary: AppPalette.paper,
|
||||||
|
surface: AppPalette.paper,
|
||||||
|
onSurface: AppPalette.ink,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
home: const _StartupGate(), // 🚪 저장된 로그인이 있으면 대시보드로, 없으면 로그인 화면으로.
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 🔐 "로그인 유지" 진입점. 기기에 저장된 세션이 있는지 확인하는 동안 잠깐 로딩을 보여주고,
|
||||||
|
/// 있으면 로그인 화면 없이 바로 대시보드로, 없으면 로그인 화면으로 보낸다.
|
||||||
|
class _StartupGate extends StatefulWidget {
|
||||||
|
const _StartupGate();
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<_StartupGate> createState() => _StartupGateState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _StartupGateState extends State<_StartupGate> {
|
||||||
|
SavedSession? _session;
|
||||||
|
bool _checked = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_checkSession();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _checkSession() async {
|
||||||
|
final session = await SessionStore.load();
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_session = session;
|
||||||
|
_checked = true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
if (!_checked) {
|
||||||
|
return Scaffold(
|
||||||
|
backgroundColor: AppPalette.mist,
|
||||||
|
body: const Center(child: CircularProgressIndicator()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
final session = _session;
|
||||||
|
if (session != null) {
|
||||||
|
return MainDashboard(
|
||||||
|
userId: session.userId,
|
||||||
|
userName: session.userName,
|
||||||
|
role: session.role,
|
||||||
|
isDeviceMatched: session.isDeviceMatched,
|
||||||
|
grade: session.grade,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return const LoginScreen();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
// 🧩 대시보드 타일의 격자 위치/크기(1x1 ~ 4x4)를 표현하는 값 객체.
|
||||||
|
// 전체 배치 캔버스는 가로 6칸 × 세로 4칸으로 고정한다.
|
||||||
|
|
||||||
|
// 📱 [기능 보류] 데스크탑에서는 잘 나오지만, 폰처럼 화면이 작은 기기에서는
|
||||||
|
// 6칸 고정 격자 때문에 타일이 너무 작아져 레이아웃이 깨진다.
|
||||||
|
// 작은 화면 대응 방안을 마련하기 전까지 편집 기능을 임시로 꺼둔다.
|
||||||
|
// (다시 켜려면 이 값만 true로 바꾸면 됨 — 관련 코드는 그대로 남겨둠)
|
||||||
|
const bool kTileEditingEnabled = false;
|
||||||
|
|
||||||
|
const int kGridCols = 6;
|
||||||
|
const int kGridRows = 4;
|
||||||
|
const int kMinTileSize = 1;
|
||||||
|
const int kMaxTileSize = 4;
|
||||||
|
|
||||||
|
class TileRect {
|
||||||
|
final int x;
|
||||||
|
final int y;
|
||||||
|
final int w;
|
||||||
|
final int h;
|
||||||
|
|
||||||
|
const TileRect({
|
||||||
|
required this.x,
|
||||||
|
required this.y,
|
||||||
|
required this.w,
|
||||||
|
required this.h,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory TileRect.fromJson(Map<String, dynamic> json) => TileRect(
|
||||||
|
x: (json['x'] as num).toInt(),
|
||||||
|
y: (json['y'] as num).toInt(),
|
||||||
|
w: (json['w'] as num).toInt(),
|
||||||
|
h: (json['h'] as num).toInt(),
|
||||||
|
);
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson(String id) => {
|
||||||
|
'id': id,
|
||||||
|
'x': x,
|
||||||
|
'y': y,
|
||||||
|
'w': w,
|
||||||
|
'h': h,
|
||||||
|
};
|
||||||
|
|
||||||
|
TileRect copyWith({int? x, int? y, int? w, int? h}) =>
|
||||||
|
TileRect(x: x ?? this.x, y: y ?? this.y, w: w ?? this.w, h: h ?? this.h);
|
||||||
|
|
||||||
|
bool overlaps(TileRect other) {
|
||||||
|
return x < other.x + other.w &&
|
||||||
|
x + w > other.x &&
|
||||||
|
y < other.y + other.h &&
|
||||||
|
y + h > other.y;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool get isWithinCanvas =>
|
||||||
|
x >= 0 &&
|
||||||
|
y >= 0 &&
|
||||||
|
x + w <= kGridCols &&
|
||||||
|
y + h <= kGridRows &&
|
||||||
|
w >= kMinTileSize &&
|
||||||
|
h >= kMinTileSize &&
|
||||||
|
w <= kMaxTileSize &&
|
||||||
|
h <= kMaxTileSize;
|
||||||
|
}
|
||||||
@@ -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),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -44,7 +44,7 @@ Future<void> initializePocketWatchService() async {
|
|||||||
autoStartOnBoot: false,
|
autoStartOnBoot: false,
|
||||||
isForegroundMode: true,
|
isForegroundMode: true,
|
||||||
notificationChannelId: pocketWatchNotificationChannelId,
|
notificationChannelId: pocketWatchNotificationChannelId,
|
||||||
initialNotificationTitle: '🛡️ 주머니 감시 대기 중',
|
initialNotificationTitle: '주머니 감시 대기 중',
|
||||||
initialNotificationContent: '출석 태깅을 기다리는 중입니다.',
|
initialNotificationContent: '출석 태깅을 기다리는 중입니다.',
|
||||||
foregroundServiceNotificationId: pocketWatchNotificationId,
|
foregroundServiceNotificationId: pocketWatchNotificationId,
|
||||||
foregroundServiceTypes: [AndroidForegroundType.specialUse],
|
foregroundServiceTypes: [AndroidForegroundType.specialUse],
|
||||||
@@ -88,7 +88,7 @@ void onPocketWatchServiceStart(ServiceInstance service) {
|
|||||||
Future<void> reportViolation() async {
|
Future<void> reportViolation() async {
|
||||||
// 진동 3연타로 강하게 경고 (백그라운드 서비스에서는 HapticFeedback이 아닌 vibration 패키지를 써야 동작함)
|
// 진동 3연타로 강하게 경고 (백그라운드 서비스에서는 HapticFeedback이 아닌 vibration 패키지를 써야 동작함)
|
||||||
Vibration.vibrate(pattern: [0, 400, 150, 400, 150, 400]);
|
Vibration.vibrate(pattern: [0, 400, 150, 400, 150, 400]);
|
||||||
updateNotification("🚨 무단 반출 감지!", "[$pocketNumber] 주머니에서 폰이 감지되지 않습니다.");
|
updateNotification("무단 반출 감지!", "[$pocketNumber] 주머니에서 폰이 감지되지 않습니다.");
|
||||||
service.invoke('violation_detected', {
|
service.invoke('violation_detected', {
|
||||||
"pocketNumber": pocketNumber,
|
"pocketNumber": pocketNumber,
|
||||||
"timestamp": DateTime.now().toIso8601String(),
|
"timestamp": DateTime.now().toIso8601String(),
|
||||||
@@ -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) {
|
void onLightReading(int luxValue) {
|
||||||
if (violated) return;
|
if (violated) return;
|
||||||
|
|
||||||
@@ -121,7 +145,7 @@ void onPocketWatchServiceStart(ServiceInstance service) {
|
|||||||
baselineLux =
|
baselineLux =
|
||||||
calibrationSamples.reduce((a, b) => a + b) ~/
|
calibrationSamples.reduce((a, b) => a + b) ~/
|
||||||
calibrationSamples.length;
|
calibrationSamples.length;
|
||||||
updateNotification("🛡️ 감시 중", "[$pocketNumber] 폰이 주머니 안에 있는지 감시 중");
|
updateNotification("감시 중", "[$pocketNumber] 폰이 주머니 안에 있는지 감시 중");
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -129,7 +153,7 @@ void onPocketWatchServiceStart(ServiceInstance service) {
|
|||||||
if (luxValue > baselineLux! + _luxJumpThreshold) {
|
if (luxValue > baselineLux! + _luxJumpThreshold) {
|
||||||
violated = true;
|
violated = true;
|
||||||
lightSub?.cancel();
|
lightSub?.cancel();
|
||||||
reportViolation();
|
handlePhoneRemoved();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -139,22 +163,22 @@ void onPocketWatchServiceStart(ServiceInstance service) {
|
|||||||
baselineLux = null;
|
baselineLux = null;
|
||||||
violated = false;
|
violated = false;
|
||||||
|
|
||||||
updateNotification("📥 주머니에 넣는 중...", "잠시 후 감시가 시작됩니다.");
|
updateNotification("주머니에 넣는 중...", "잠시 후 감시가 시작됩니다.");
|
||||||
|
|
||||||
await Future.delayed(_placePhoneDelay);
|
await Future.delayed(_placePhoneDelay);
|
||||||
|
|
||||||
updateNotification("🔎 밝기 측정 중...", "주머니 속 밝기를 기준값으로 설정하는 중입니다.");
|
updateNotification("밝기 측정 중...", "주머니 속 밝기를 기준값으로 설정하는 중입니다.");
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await Light().requestAuthorization();
|
await Light().requestAuthorization();
|
||||||
lightSub = Light().lightSensorStream.listen(
|
lightSub = Light().lightSensorStream.listen(
|
||||||
onLightReading,
|
onLightReading,
|
||||||
onError: (Object error) {
|
onError: (Object error) {
|
||||||
updateNotification("❌ 조도 센서 오류", "$error");
|
updateNotification("조도 센서 오류", "$error");
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
updateNotification("❌ 조도 센서 시작 실패", "$e");
|
updateNotification("조도 센서 시작 실패", "$e");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -3,7 +3,9 @@ import 'dart:convert';
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:http/http.dart' as http;
|
import 'package:http/http.dart' as http;
|
||||||
import '../config.dart';
|
import '../config.dart';
|
||||||
import 'login_screen.dart';
|
import '../theme/app_palette.dart';
|
||||||
|
import '../ui/app_notice.dart';
|
||||||
|
import '../ui/login_screen.dart';
|
||||||
|
|
||||||
// 💡 main.dart 파일의 최하단(다른 클래스 중괄호 밖)에 붙여넣으세요.
|
// 💡 main.dart 파일의 최하단(다른 클래스 중괄호 밖)에 붙여넣으세요.
|
||||||
class ChangePasswordScreen extends StatefulWidget {
|
class ChangePasswordScreen extends StatefulWidget {
|
||||||
@@ -21,9 +23,7 @@ class _ChangePasswordScreenState extends State<ChangePasswordScreen> {
|
|||||||
Future<void> _updatePassword() async {
|
Future<void> _updatePassword() async {
|
||||||
String newPw = _pwController.text.trim();
|
String newPw = _pwController.text.trim();
|
||||||
if (newPw.isEmpty || newPw == "1234") {
|
if (newPw.isEmpty || newPw == "1234") {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
AppNotice.show(context, '초기 비밀번호와 다른 안전한 비밀번호를 입력하세요.');
|
||||||
const SnackBar(content: Text('⚠️ 초기 비밀번호와 다른 안전한 비밀번호를 입력하세요.')),
|
|
||||||
);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -37,9 +37,7 @@ class _ChangePasswordScreenState extends State<ChangePasswordScreen> {
|
|||||||
|
|
||||||
final res = jsonDecode(response.body);
|
final res = jsonDecode(response.body);
|
||||||
if (response.statusCode == 200 && res['status'] == 'success') {
|
if (response.statusCode == 200 && res['status'] == 'success') {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
AppNotice.show(context, '비밀번호 변경 완료! 다시 로그인해 주세요.');
|
||||||
const SnackBar(content: Text('🔒 비밀번호 변경 완료! 다시 로그인해 주세요.')),
|
|
||||||
);
|
|
||||||
// 비밀번호를 바꿨으니 다시 로그인 화면으로 강제 이동
|
// 비밀번호를 바꿨으니 다시 로그인 화면으로 강제 이동
|
||||||
Navigator.pushReplacement(
|
Navigator.pushReplacement(
|
||||||
context,
|
context,
|
||||||
@@ -47,9 +45,7 @@ class _ChangePasswordScreenState extends State<ChangePasswordScreen> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
ScaffoldMessenger.of(
|
AppNotice.show(context, '통신 실패');
|
||||||
context,
|
|
||||||
).showSnackBar(const SnackBar(content: Text('❌ 통신 실패')));
|
|
||||||
} finally {
|
} finally {
|
||||||
setState(() => _isLoading = false);
|
setState(() => _isLoading = false);
|
||||||
}
|
}
|
||||||
@@ -58,6 +54,7 @@ class _ChangePasswordScreenState extends State<ChangePasswordScreen> {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
|
backgroundColor: AppPalette.mist,
|
||||||
body: Padding(
|
body: Padding(
|
||||||
padding: const EdgeInsets.all(32.0),
|
padding: const EdgeInsets.all(32.0),
|
||||||
child: Column(
|
child: Column(
|
||||||
@@ -65,7 +62,7 @@ class _ChangePasswordScreenState extends State<ChangePasswordScreen> {
|
|||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
const Text(
|
const Text(
|
||||||
'🔒 보안을 위해\n비밀번호를 변경해 주세요',
|
'보안을 위해\n비밀번호를 변경해 주세요',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 24,
|
fontSize: 24,
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
@@ -92,8 +89,8 @@ class _ChangePasswordScreenState extends State<ChangePasswordScreen> {
|
|||||||
height: 50,
|
height: 50,
|
||||||
child: ElevatedButton(
|
child: ElevatedButton(
|
||||||
style: ElevatedButton.styleFrom(
|
style: ElevatedButton.styleFrom(
|
||||||
backgroundColor: Colors.indigo,
|
backgroundColor: AppPalette.ink,
|
||||||
foregroundColor: Colors.white,
|
foregroundColor: AppPalette.paper,
|
||||||
),
|
),
|
||||||
onPressed: _isLoading ? null : _updatePassword,
|
onPressed: _isLoading ? null : _updatePassword,
|
||||||
child: _isLoading
|
child: _isLoading
|
||||||
|
|||||||
@@ -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(),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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']),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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"),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,267 +0,0 @@
|
|||||||
// 👨🏫 교사 대시보드. 실시간 출석 확인, 학생 계정 관리 화면으로 가는 메뉴만 담당한다.
|
|
||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'login_screen.dart';
|
|
||||||
import 'teacher_attendance_page.dart';
|
|
||||||
import 'teacher_student_management_page.dart';
|
|
||||||
|
|
||||||
// ==========================================
|
|
||||||
// 👨🏫 4. 선생님 대시보드 (기존 3초 타이머 완벽 유지)
|
|
||||||
// ==========================================
|
|
||||||
class TeacherDashboard extends StatefulWidget {
|
|
||||||
final String? teacherId;
|
|
||||||
final String? teacherName;
|
|
||||||
|
|
||||||
const TeacherDashboard({super.key, this.teacherId, this.teacherName});
|
|
||||||
|
|
||||||
@override
|
|
||||||
State<TeacherDashboard> createState() => _TeacherDashboardState();
|
|
||||||
}
|
|
||||||
|
|
||||||
class _TeacherDashboardState extends State<TeacherDashboard> {
|
|
||||||
// 🧼 [수정] 사용하지 않던 _isLoading 변수를 삭제하여 경고를 완벽히 해결했습니다!
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
// 다른 화면에서 null이 넘어왔을 때를 대비한 안전망 방탄 코드
|
|
||||||
final String displayName = widget.teacherName ?? "간편인증 선생";
|
|
||||||
final String displayId = widget.teacherId ?? "간편인증";
|
|
||||||
|
|
||||||
return Scaffold(
|
|
||||||
backgroundColor: Colors.grey[100],
|
|
||||||
appBar: AppBar(
|
|
||||||
title: const Text(
|
|
||||||
'👨🏫 TEACHER PORTAL',
|
|
||||||
style: TextStyle(fontWeight: FontWeight.bold, letterSpacing: 1.2),
|
|
||||||
),
|
|
||||||
centerTitle: true,
|
|
||||||
backgroundColor: Colors.green[700], // 교사 전용 그린 테마 컬러
|
|
||||||
foregroundColor: Colors.white,
|
|
||||||
elevation: 0,
|
|
||||||
actions: [
|
|
||||||
IconButton(
|
|
||||||
icon: const Icon(Icons.logout_rounded),
|
|
||||||
onPressed: () => Navigator.pushReplacement(
|
|
||||||
context,
|
|
||||||
MaterialPageRoute(builder: (context) => const LoginScreen()),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
body: SingleChildScrollView(
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
// 💳 [상단 배너 가이드] StudentDashboard와 100% 일치하는 프로필 카드 레이아웃
|
|
||||||
Container(
|
|
||||||
width: double.infinity,
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 28),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: Colors.green[700],
|
|
||||||
borderRadius: const BorderRadius.only(
|
|
||||||
bottomLeft: Radius.circular(32),
|
|
||||||
bottomRight: Radius.circular(32),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
child: Row(
|
|
||||||
children: [
|
|
||||||
Container(
|
|
||||||
padding: const EdgeInsets.all(4),
|
|
||||||
decoration: const BoxDecoration(
|
|
||||||
color: Colors.white,
|
|
||||||
shape: BoxShape.circle,
|
|
||||||
),
|
|
||||||
child: CircleAvatar(
|
|
||||||
radius: 30,
|
|
||||||
backgroundColor: Colors.green[50],
|
|
||||||
child: Icon(
|
|
||||||
Icons.admin_panel_settings_rounded,
|
|
||||||
size: 32,
|
|
||||||
color: Colors.green[700],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 18),
|
|
||||||
Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Text(
|
|
||||||
'$displayName 님',
|
|
||||||
style: const TextStyle(
|
|
||||||
fontSize: 22,
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
color: Colors.white,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 4),
|
|
||||||
Text(
|
|
||||||
'교직원 번호: $displayId | 교사 권한 활성화됨',
|
|
||||||
style: TextStyle(
|
|
||||||
color: Colors.white.withValues(alpha: 0.8),
|
|
||||||
fontSize: 14,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 32),
|
|
||||||
|
|
||||||
// 🏷️ [세로 바 타이틀 인디케이터] 구조 일치화
|
|
||||||
Padding(
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 24.0),
|
|
||||||
child: Row(
|
|
||||||
children: [
|
|
||||||
Container(
|
|
||||||
width: 4,
|
|
||||||
height: 18,
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: Colors.green[700],
|
|
||||||
borderRadius: BorderRadius.circular(2),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 10),
|
|
||||||
const Text(
|
|
||||||
'스마트 교사용 관리 메뉴',
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 18,
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
color: Colors.black87,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
|
|
||||||
// 📊 [그리드 레이아웃 메뉴] 시후의 카드 컴포넌트 스타일 적용
|
|
||||||
Padding(
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 24.0),
|
|
||||||
child: GridView.count(
|
|
||||||
shrinkWrap: true,
|
|
||||||
physics: const NeverScrollableScrollPhysics(),
|
|
||||||
crossAxisCount: 2,
|
|
||||||
crossAxisSpacing: 16,
|
|
||||||
mainAxisSpacing: 16,
|
|
||||||
childAspectRatio: 0.95,
|
|
||||||
children: [
|
|
||||||
_buildModernCard(
|
|
||||||
icon: Icons.assignment_turned_in_rounded,
|
|
||||||
title: '실시간 출석 확인',
|
|
||||||
subtitle: '학생 제출 로그 모니터링',
|
|
||||||
color: Colors.blue,
|
|
||||||
onTap: () => Navigator.push(
|
|
||||||
context,
|
|
||||||
MaterialPageRoute(
|
|
||||||
builder: (context) => const TeacherAttendancePage(),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
_buildModernCard(
|
|
||||||
icon: Icons.manage_accounts_rounded,
|
|
||||||
title: '학생 계정 관리',
|
|
||||||
subtitle: '계정 추가 및 강제 리셋',
|
|
||||||
color: Colors.orange,
|
|
||||||
onTap: () => Navigator.push(
|
|
||||||
context,
|
|
||||||
MaterialPageRoute(
|
|
||||||
builder: (context) =>
|
|
||||||
const TeacherStudentManagementPage(),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 32),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 💎 [시후 대시보드 전용 카드 위젯 이식 완료]
|
|
||||||
Widget _buildModernCard({
|
|
||||||
required IconData icon,
|
|
||||||
required String title,
|
|
||||||
required String subtitle,
|
|
||||||
required Color color,
|
|
||||||
required VoidCallback onTap,
|
|
||||||
bool isActionButton = false,
|
|
||||||
bool isLoading = false,
|
|
||||||
}) {
|
|
||||||
return InkWell(
|
|
||||||
onTap: isLoading ? null : onTap,
|
|
||||||
borderRadius: BorderRadius.circular(24),
|
|
||||||
child: Ink(
|
|
||||||
padding: const EdgeInsets.all(20),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: Colors.white,
|
|
||||||
borderRadius: BorderRadius.circular(24),
|
|
||||||
boxShadow: [
|
|
||||||
BoxShadow(
|
|
||||||
color: Colors.black.withValues(alpha: 0.04),
|
|
||||||
blurRadius: 16,
|
|
||||||
offset: const Offset(0, 4),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
||||||
children: [
|
|
||||||
Container(
|
|
||||||
padding: const EdgeInsets.all(12),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: color.withValues(alpha: 0.1),
|
|
||||||
borderRadius: BorderRadius.circular(16),
|
|
||||||
),
|
|
||||||
child: Icon(icon, color: color, size: 28),
|
|
||||||
),
|
|
||||||
Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Row(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
||||||
children: [
|
|
||||||
Expanded(
|
|
||||||
child: Text(
|
|
||||||
title,
|
|
||||||
style: const TextStyle(
|
|
||||||
fontSize: 15,
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
color: Colors.black87,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
if (isLoading)
|
|
||||||
const SizedBox(
|
|
||||||
width: 16,
|
|
||||||
height: 16,
|
|
||||||
child: CircularProgressIndicator(strokeWidth: 2),
|
|
||||||
)
|
|
||||||
else if (isActionButton)
|
|
||||||
Icon(
|
|
||||||
Icons.touch_app_rounded,
|
|
||||||
size: 16,
|
|
||||||
color: color.withValues(alpha: 0.5),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
const SizedBox(height: 4),
|
|
||||||
Text(
|
|
||||||
subtitle,
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 12,
|
|
||||||
color: Colors.grey[500],
|
|
||||||
height: 1.2,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
// 🎨 앱 전역 무채색 팔레트 (coolors.co: 1c1c1c-daddd8-ecebe4-eef0f2-fafaff).
|
||||||
|
// 배경/카드/배너 같은 구조적 색상은 전부 이 5색으로 통일한다.
|
||||||
|
// 삭제/위반/거절처럼 사용자가 즉시 알아채야 하는 경고성 색상(빨강 등)만 예외로 유지한다.
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
class AppPalette {
|
||||||
|
AppPalette._();
|
||||||
|
|
||||||
|
static const Color ink = Color(0xFF1C1C1C); // 가장 어두운 톤 — 배너/주요 텍스트/버튼
|
||||||
|
static const Color sage = Color(0xFFDADDD8); // 보더/구분선/보조 표면
|
||||||
|
static const Color linen = Color(0xFFECEBE4); // 은은한 카드/배지 배경
|
||||||
|
static const Color mist = Color(0xFFEEF0F2); // 페이지 배경
|
||||||
|
static const Color paper = Color(0xFFFAFAFF); // 카드 표면(거의 흰색)
|
||||||
|
}
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
// 🛠️ 시스템 관리자 대시보드 (UI 전용). 출석 DB 전체 초기화 버튼을 그린다.
|
||||||
|
// 서버 통신/상태는 lib/function/admin_controller.dart가 담당한다.
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import '../function/admin_controller.dart';
|
||||||
|
import '../theme/app_palette.dart';
|
||||||
|
import 'app_notice.dart';
|
||||||
|
import 'login_screen.dart';
|
||||||
|
import 'title_pill.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;
|
||||||
|
AppNotice.show(context, 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(
|
||||||
|
backgroundColor: AppPalette.mist,
|
||||||
|
appBar: AppBar(
|
||||||
|
backgroundColor: Colors.transparent,
|
||||||
|
foregroundColor: AppPalette.ink,
|
||||||
|
elevation: 0,
|
||||||
|
centerTitle: true,
|
||||||
|
title: const TitlePill('관리자 시스템'),
|
||||||
|
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: ConstrainedBox(
|
||||||
|
constraints: const BoxConstraints(maxWidth: 420),
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
const Icon(
|
||||||
|
Icons.admin_panel_settings,
|
||||||
|
size: 100,
|
||||||
|
color: AppPalette.ink,
|
||||||
|
),
|
||||||
|
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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
// 🔔 스낵바 대신 쓰는 알약(pill) 모양 알림. 기능 타일을 누를 때 뜨는
|
||||||
|
// "OO로 이동합니다" 같은 짧은 안내용. 푸시 알림(FCM)과는 무관 — 그건 그대로 동작한다.
|
||||||
|
// 웹에서는 왼쪽 위(알림창 자리)에, 폰 앱에서는 기존 스낵바처럼 아래쪽에 뜬다.
|
||||||
|
import 'package:flutter/foundation.dart' show kIsWeb;
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import '../theme/app_palette.dart';
|
||||||
|
|
||||||
|
class AppNotice {
|
||||||
|
static final List<_QueuedNotice> _queue = [];
|
||||||
|
static bool _showing = false;
|
||||||
|
|
||||||
|
static void show(
|
||||||
|
BuildContext context,
|
||||||
|
String message, {
|
||||||
|
IconData? icon,
|
||||||
|
Color? color,
|
||||||
|
}) {
|
||||||
|
_queue.add(_QueuedNotice(context, message, icon, color));
|
||||||
|
_tryShowNext();
|
||||||
|
}
|
||||||
|
|
||||||
|
static void _tryShowNext() {
|
||||||
|
if (_showing || _queue.isEmpty) return;
|
||||||
|
final next = _queue.removeAt(0);
|
||||||
|
if (!next.context.mounted) {
|
||||||
|
_tryShowNext();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_showing = true;
|
||||||
|
final overlay = Overlay.of(next.context);
|
||||||
|
late OverlayEntry entry;
|
||||||
|
entry = OverlayEntry(
|
||||||
|
builder: (context) => _NoticeBanner(
|
||||||
|
message: next.message,
|
||||||
|
icon: next.icon,
|
||||||
|
color: next.color,
|
||||||
|
onDone: () {
|
||||||
|
entry.remove();
|
||||||
|
_showing = false;
|
||||||
|
_tryShowNext();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
overlay.insert(entry);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _QueuedNotice {
|
||||||
|
final BuildContext context;
|
||||||
|
final String message;
|
||||||
|
final IconData? icon;
|
||||||
|
final Color? color;
|
||||||
|
_QueuedNotice(this.context, this.message, this.icon, this.color);
|
||||||
|
}
|
||||||
|
|
||||||
|
class _NoticeBanner extends StatefulWidget {
|
||||||
|
final String message;
|
||||||
|
final IconData? icon;
|
||||||
|
final Color? color;
|
||||||
|
final VoidCallback onDone;
|
||||||
|
|
||||||
|
const _NoticeBanner({
|
||||||
|
required this.message,
|
||||||
|
required this.icon,
|
||||||
|
required this.color,
|
||||||
|
required this.onDone,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<_NoticeBanner> createState() => _NoticeBannerState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _NoticeBannerState extends State<_NoticeBanner>
|
||||||
|
with SingleTickerProviderStateMixin {
|
||||||
|
late final AnimationController _controller;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_controller = AnimationController(
|
||||||
|
vsync: this,
|
||||||
|
duration: const Duration(milliseconds: 220),
|
||||||
|
reverseDuration: const Duration(milliseconds: 180),
|
||||||
|
);
|
||||||
|
_controller.forward();
|
||||||
|
Future.delayed(const Duration(milliseconds: 2600), () async {
|
||||||
|
if (!mounted) return;
|
||||||
|
await _controller.reverse();
|
||||||
|
widget.onDone();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_controller.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final curved = CurvedAnimation(
|
||||||
|
parent: _controller,
|
||||||
|
curve: Curves.easeOutCubic,
|
||||||
|
reverseCurve: Curves.easeInCubic,
|
||||||
|
);
|
||||||
|
final media = MediaQuery.of(context);
|
||||||
|
|
||||||
|
final pill = Material(
|
||||||
|
color: Colors.transparent,
|
||||||
|
child: Container(
|
||||||
|
constraints: const BoxConstraints(maxWidth: 360),
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 14),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: widget.color ?? AppPalette.ink,
|
||||||
|
borderRadius: BorderRadius.circular(999),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: Colors.black.withValues(alpha: 0.25),
|
||||||
|
blurRadius: 16,
|
||||||
|
offset: const Offset(0, 6),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
if (widget.icon != null) ...[
|
||||||
|
Icon(widget.icon, color: AppPalette.paper, size: 18),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
],
|
||||||
|
Flexible(
|
||||||
|
child: Text(
|
||||||
|
widget.message,
|
||||||
|
style: const TextStyle(
|
||||||
|
color: AppPalette.paper,
|
||||||
|
fontSize: 13.5,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
final animatedPill = FadeTransition(
|
||||||
|
opacity: curved,
|
||||||
|
child: SlideTransition(
|
||||||
|
position: Tween<Offset>(
|
||||||
|
begin: kIsWeb ? const Offset(0, -0.3) : const Offset(0, 0.3),
|
||||||
|
end: Offset.zero,
|
||||||
|
).animate(curved),
|
||||||
|
child: pill,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (kIsWeb) {
|
||||||
|
return Positioned(
|
||||||
|
top: media.padding.top + 16,
|
||||||
|
left: 16,
|
||||||
|
child: IgnorePointer(child: animatedPill),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return Positioned(
|
||||||
|
left: 16,
|
||||||
|
right: 16,
|
||||||
|
bottom: media.padding.bottom + 24,
|
||||||
|
child: IgnorePointer(child: Center(child: animatedPill)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,303 @@
|
|||||||
|
// 🗣️ 커뮤니티 게시판(백판) 홈 화면 (UI 전용). 카테고리 탭 + 글 목록 + 글쓰기.
|
||||||
|
// 서버 통신/상태는 lib/function/board_list_controller.dart, board_write_controller.dart가 담당한다.
|
||||||
|
import 'dart:convert';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:http/http.dart' as http;
|
||||||
|
import '../config.dart';
|
||||||
|
import '../function/board_list_controller.dart';
|
||||||
|
import '../theme/app_palette.dart';
|
||||||
|
import 'board_post_detail_screen.dart';
|
||||||
|
import 'board_report_screen.dart';
|
||||||
|
import 'board_write_screen.dart';
|
||||||
|
import 'launchpad_transition.dart';
|
||||||
|
import 'title_pill.dart';
|
||||||
|
|
||||||
|
class BoardHomeScreen extends StatefulWidget {
|
||||||
|
final String studentId;
|
||||||
|
final bool canModerate;
|
||||||
|
|
||||||
|
const BoardHomeScreen({
|
||||||
|
super.key,
|
||||||
|
required this.studentId,
|
||||||
|
this.canModerate = false,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<BoardHomeScreen> createState() => _BoardHomeScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _BoardHomeScreenState extends State<BoardHomeScreen>
|
||||||
|
with SingleTickerProviderStateMixin {
|
||||||
|
List<dynamic> _categories = [];
|
||||||
|
bool _isLoadingCategories = true;
|
||||||
|
TabController? _tabController;
|
||||||
|
final Map<String, BoardListController> _controllers = {};
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_fetchCategories();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_tabController?.dispose();
|
||||||
|
for (final c in _controllers.values) {
|
||||||
|
c.dispose();
|
||||||
|
}
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _fetchCategories() async {
|
||||||
|
try {
|
||||||
|
final response = await http.get(
|
||||||
|
Uri.parse('$baseUrl/api/board/categories'),
|
||||||
|
);
|
||||||
|
if (response.statusCode == 200) {
|
||||||
|
final data = jsonDecode(utf8.decode(response.bodyBytes));
|
||||||
|
_categories = data['categories'] ?? [];
|
||||||
|
}
|
||||||
|
} catch (_) {
|
||||||
|
// 조용히 무시 - 아래에서 목록이 비어있으면 에러로 처리한다.
|
||||||
|
} finally {
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_isLoadingCategories = false;
|
||||||
|
if (_categories.isNotEmpty) {
|
||||||
|
_tabController = TabController(
|
||||||
|
length: _categories.length,
|
||||||
|
vsync: this,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
BoardListController _controllerFor(String category) {
|
||||||
|
return _controllers.putIfAbsent(
|
||||||
|
category,
|
||||||
|
() =>
|
||||||
|
BoardListController(category: category, studentId: widget.studentId)
|
||||||
|
..init(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _openWrite() async {
|
||||||
|
final tabController = _tabController;
|
||||||
|
if (tabController == null) return;
|
||||||
|
final category = _categories[tabController.index];
|
||||||
|
final changed = await pushLaunchpad<bool>(
|
||||||
|
context,
|
||||||
|
(context) => BoardWriteScreen(
|
||||||
|
studentId: widget.studentId,
|
||||||
|
category: category['key'],
|
||||||
|
categoryName: category['name'],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (changed == true) {
|
||||||
|
_controllerFor(category['key']).refresh();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _openModeration() {
|
||||||
|
pushLaunchpad(
|
||||||
|
context,
|
||||||
|
(context) => BoardReportScreen(reviewerId: widget.studentId),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _openPost(int postId, String category) async {
|
||||||
|
final changed = await pushLaunchpad<bool>(
|
||||||
|
context,
|
||||||
|
(context) =>
|
||||||
|
BoardPostDetailScreen(postId: postId, studentId: widget.studentId),
|
||||||
|
);
|
||||||
|
if (changed == true) {
|
||||||
|
_controllerFor(category).refresh();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Scaffold(
|
||||||
|
backgroundColor: AppPalette.mist,
|
||||||
|
appBar: AppBar(
|
||||||
|
backgroundColor: Colors.transparent,
|
||||||
|
foregroundColor: AppPalette.ink,
|
||||||
|
elevation: 0,
|
||||||
|
centerTitle: true,
|
||||||
|
title: const TitlePill('백판'),
|
||||||
|
actions: [
|
||||||
|
if (widget.canModerate)
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.shield_outlined),
|
||||||
|
tooltip: '신고 검토',
|
||||||
|
onPressed: _openModeration,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
bottom: _tabController == null
|
||||||
|
? null
|
||||||
|
: TabBar(
|
||||||
|
controller: _tabController,
|
||||||
|
labelColor: AppPalette.ink,
|
||||||
|
unselectedLabelColor: Colors.grey,
|
||||||
|
indicatorColor: AppPalette.ink,
|
||||||
|
tabs: [for (final c in _categories) Tab(text: c['name'])],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
floatingActionButton: _tabController == null
|
||||||
|
? null
|
||||||
|
: FloatingActionButton(
|
||||||
|
backgroundColor: AppPalette.ink,
|
||||||
|
foregroundColor: AppPalette.paper,
|
||||||
|
onPressed: _openWrite,
|
||||||
|
child: const Icon(Icons.edit_rounded),
|
||||||
|
),
|
||||||
|
body: _isLoadingCategories
|
||||||
|
? const Center(child: CircularProgressIndicator())
|
||||||
|
: _tabController == null
|
||||||
|
? Center(
|
||||||
|
child: TextButton(
|
||||||
|
onPressed: () {
|
||||||
|
setState(() => _isLoadingCategories = true);
|
||||||
|
_fetchCategories();
|
||||||
|
},
|
||||||
|
child: const Text('게시판을 불러오지 못했습니다. 다시 시도'),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: TabBarView(
|
||||||
|
controller: _tabController,
|
||||||
|
children: [
|
||||||
|
for (final c in _categories)
|
||||||
|
_CategoryBoardList(
|
||||||
|
controller: _controllerFor(c['key']),
|
||||||
|
onOpenPost: (postId) => _openPost(postId, c['key']),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _CategoryBoardList extends StatefulWidget {
|
||||||
|
final BoardListController controller;
|
||||||
|
final ValueChanged<int> onOpenPost;
|
||||||
|
|
||||||
|
const _CategoryBoardList({
|
||||||
|
required this.controller,
|
||||||
|
required this.onOpenPost,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<_CategoryBoardList> createState() => _CategoryBoardListState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _CategoryBoardListState extends State<_CategoryBoardList> {
|
||||||
|
final _scrollController = ScrollController();
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_scrollController.addListener(() {
|
||||||
|
if (_scrollController.position.pixels >=
|
||||||
|
_scrollController.position.maxScrollExtent - 200) {
|
||||||
|
widget.controller.loadMore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_scrollController.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return ListenableBuilder(
|
||||||
|
listenable: widget.controller,
|
||||||
|
builder: (context, _) {
|
||||||
|
if (widget.controller.isLoading) {
|
||||||
|
return const Center(child: CircularProgressIndicator());
|
||||||
|
}
|
||||||
|
final posts = widget.controller.posts;
|
||||||
|
if (posts.isEmpty) {
|
||||||
|
return Center(
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
const Text(
|
||||||
|
'아직 글이 없어요.\n첫 글을 남겨보세요.',
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: TextStyle(color: Colors.grey),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
TextButton(
|
||||||
|
onPressed: widget.controller.refresh,
|
||||||
|
child: const Text('새로고침'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return RefreshIndicator(
|
||||||
|
onRefresh: widget.controller.refresh,
|
||||||
|
child: ListView.builder(
|
||||||
|
controller: _scrollController,
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||||
|
itemCount: posts.length + (widget.controller.hasMore ? 1 : 0),
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
if (index >= posts.length) {
|
||||||
|
return const Padding(
|
||||||
|
padding: EdgeInsets.symmetric(vertical: 16),
|
||||||
|
child: Center(
|
||||||
|
child: SizedBox(
|
||||||
|
width: 20,
|
||||||
|
height: 20,
|
||||||
|
child: CircularProgressIndicator(strokeWidth: 2),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
final post = posts[index];
|
||||||
|
return Container(
|
||||||
|
margin: const EdgeInsets.only(bottom: 8),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: AppPalette.paper,
|
||||||
|
borderRadius: BorderRadius.circular(14),
|
||||||
|
border: Border.all(color: AppPalette.sage),
|
||||||
|
),
|
||||||
|
child: ListTile(
|
||||||
|
title: Text(
|
||||||
|
post['title'] ?? '',
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: const TextStyle(fontWeight: FontWeight.bold),
|
||||||
|
),
|
||||||
|
subtitle: Text(
|
||||||
|
'${post['createdAt'] ?? ''}',
|
||||||
|
style: const TextStyle(fontSize: 12),
|
||||||
|
),
|
||||||
|
trailing: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
const Icon(
|
||||||
|
Icons.mode_comment_outlined,
|
||||||
|
size: 15,
|
||||||
|
color: Colors.grey,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 3),
|
||||||
|
Text('${post['commentCount'] ?? 0}'),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
onTap: () => widget.onOpenPost(post['id'] as int),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,399 @@
|
|||||||
|
// 💬 커뮤니티 게시판 - 게시글 상세 + 댓글 화면 (UI 전용).
|
||||||
|
// 서버 통신/상태는 lib/function/board_post_controller.dart가 담당한다.
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import '../function/board_post_controller.dart';
|
||||||
|
import '../theme/app_palette.dart';
|
||||||
|
import 'app_notice.dart';
|
||||||
|
import 'title_pill.dart';
|
||||||
|
|
||||||
|
class BoardPostDetailScreen extends StatefulWidget {
|
||||||
|
final int postId;
|
||||||
|
final String studentId;
|
||||||
|
|
||||||
|
const BoardPostDetailScreen({
|
||||||
|
super.key,
|
||||||
|
required this.postId,
|
||||||
|
required this.studentId,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<BoardPostDetailScreen> createState() => _BoardPostDetailScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _BoardPostDetailScreenState extends State<BoardPostDetailScreen> {
|
||||||
|
late final BoardPostController _controller;
|
||||||
|
final _commentController = TextEditingController();
|
||||||
|
bool _changed = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_controller = BoardPostController(
|
||||||
|
postId: widget.postId,
|
||||||
|
studentId: widget.studentId,
|
||||||
|
);
|
||||||
|
_controller.init();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_controller.dispose();
|
||||||
|
_commentController.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _submitComment() async {
|
||||||
|
final text = _commentController.text;
|
||||||
|
final (success, message) = await _controller.addComment(text);
|
||||||
|
if (!mounted) return;
|
||||||
|
if (success) {
|
||||||
|
_commentController.clear();
|
||||||
|
_changed = true;
|
||||||
|
}
|
||||||
|
AppNotice.show(
|
||||||
|
context,
|
||||||
|
message,
|
||||||
|
icon: success ? Icons.check_circle_rounded : Icons.error_outline_rounded,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _showReportSheet({
|
||||||
|
required String targetType,
|
||||||
|
required int targetId,
|
||||||
|
}) async {
|
||||||
|
final reasonController = TextEditingController();
|
||||||
|
final reason = await showModalBottomSheet<String>(
|
||||||
|
context: context,
|
||||||
|
isScrollControlled: true,
|
||||||
|
backgroundColor: AppPalette.paper,
|
||||||
|
shape: const RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
|
||||||
|
),
|
||||||
|
builder: (sheetContext) => Padding(
|
||||||
|
padding: EdgeInsets.only(
|
||||||
|
left: 20,
|
||||||
|
right: 20,
|
||||||
|
top: 20,
|
||||||
|
bottom: MediaQuery.of(sheetContext).viewInsets.bottom + 20,
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
const Text(
|
||||||
|
'신고 사유 (선택)',
|
||||||
|
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 15),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
TextField(
|
||||||
|
controller: reasonController,
|
||||||
|
maxLines: 3,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
hintText: '어떤 점이 문제인지 알려주세요.',
|
||||||
|
filled: true,
|
||||||
|
fillColor: AppPalette.mist,
|
||||||
|
border: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
borderSide: BorderSide.none,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 14),
|
||||||
|
ElevatedButton(
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
backgroundColor: AppPalette.ink,
|
||||||
|
foregroundColor: AppPalette.paper,
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 14),
|
||||||
|
),
|
||||||
|
onPressed: () =>
|
||||||
|
Navigator.of(sheetContext).pop(reasonController.text),
|
||||||
|
child: const Text('신고하기'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (reason == null || !mounted) return;
|
||||||
|
final (success, message) = await _controller.report(
|
||||||
|
targetType: targetType,
|
||||||
|
targetId: targetId,
|
||||||
|
reason: reason.trim().isEmpty ? null : reason.trim(),
|
||||||
|
);
|
||||||
|
if (!mounted) return;
|
||||||
|
AppNotice.show(
|
||||||
|
context,
|
||||||
|
message,
|
||||||
|
icon: success ? Icons.flag_rounded : Icons.error_outline_rounded,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _deletePost() async {
|
||||||
|
final confirmed = await showDialog<bool>(
|
||||||
|
context: context,
|
||||||
|
builder: (dialogContext) => AlertDialog(
|
||||||
|
title: const Text('게시글 삭제'),
|
||||||
|
content: const Text('이 게시글을 삭제하시겠습니까?'),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(dialogContext, false),
|
||||||
|
child: const Text('취소', style: TextStyle(color: Colors.grey)),
|
||||||
|
),
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(dialogContext, true),
|
||||||
|
child: const Text('삭제', style: TextStyle(color: Colors.red)),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (confirmed != true) return;
|
||||||
|
final (success, message) = await _controller.deletePost();
|
||||||
|
if (!mounted) return;
|
||||||
|
AppNotice.show(context, message);
|
||||||
|
if (success) Navigator.of(context).pop(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _deleteComment(int commentId) async {
|
||||||
|
final (success, message) = await _controller.deleteComment(commentId);
|
||||||
|
if (!mounted) return;
|
||||||
|
if (success) _changed = true;
|
||||||
|
AppNotice.show(context, message);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return PopScope(
|
||||||
|
canPop: false,
|
||||||
|
onPopInvokedWithResult: (didPop, result) {
|
||||||
|
if (!didPop) Navigator.of(context).pop(_changed);
|
||||||
|
},
|
||||||
|
child: ListenableBuilder(
|
||||||
|
listenable: _controller,
|
||||||
|
builder: (context, _) {
|
||||||
|
final post = _controller.post;
|
||||||
|
return Scaffold(
|
||||||
|
backgroundColor: AppPalette.mist,
|
||||||
|
appBar: AppBar(
|
||||||
|
backgroundColor: Colors.transparent,
|
||||||
|
foregroundColor: AppPalette.ink,
|
||||||
|
elevation: 0,
|
||||||
|
centerTitle: true,
|
||||||
|
title: const TitlePill('게시글'),
|
||||||
|
leading: IconButton(
|
||||||
|
icon: const Icon(Icons.arrow_back_ios_new_rounded, size: 18),
|
||||||
|
onPressed: () => Navigator.of(context).pop(_changed),
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
if (post != null && post['isMine'] != true)
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.flag_outlined),
|
||||||
|
tooltip: '신고',
|
||||||
|
onPressed: () => _showReportSheet(
|
||||||
|
targetType: 'post',
|
||||||
|
targetId: widget.postId,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (post != null && post['isMine'] == true)
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.delete_outline_rounded),
|
||||||
|
tooltip: '삭제',
|
||||||
|
onPressed: _deletePost,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
body: _controller.isLoading
|
||||||
|
? const Center(child: CircularProgressIndicator())
|
||||||
|
: _controller.postDeleted || post == null
|
||||||
|
? const Center(
|
||||||
|
child: Text(
|
||||||
|
'삭제되었거나 존재하지 않는 게시글입니다.',
|
||||||
|
style: TextStyle(color: Colors.grey),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: Column(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: ListView(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
children: [
|
||||||
|
_buildPostCard(post),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Text(
|
||||||
|
'댓글 ${_controller.comments.length}개',
|
||||||
|
style: const TextStyle(
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: AppPalette.ink,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
..._controller.comments.map(_buildCommentTile),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
_buildCommentComposer(),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildPostCard(Map<String, dynamic> post) {
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.all(18),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: AppPalette.paper,
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
border: Border.all(color: AppPalette.sage),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
post['title'] ?? '',
|
||||||
|
style: const TextStyle(
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
fontSize: 18,
|
||||||
|
color: AppPalette.ink,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
post['isMine'] == true ? '글쓴이(나)' : '익명',
|
||||||
|
style: const TextStyle(color: Colors.grey, fontSize: 12.5),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Text(
|
||||||
|
'${post['createdAt'] ?? ''}',
|
||||||
|
style: const TextStyle(color: Colors.grey, fontSize: 12.5),
|
||||||
|
),
|
||||||
|
const Spacer(),
|
||||||
|
Icon(Icons.visibility_outlined, size: 14, color: Colors.grey),
|
||||||
|
const SizedBox(width: 3),
|
||||||
|
Text(
|
||||||
|
'${post['viewCount'] ?? 0}',
|
||||||
|
style: const TextStyle(color: Colors.grey, fontSize: 12.5),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const Divider(height: 24),
|
||||||
|
Text(
|
||||||
|
post['content'] ?? '',
|
||||||
|
style: const TextStyle(fontSize: 15, height: 1.5),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildCommentTile(dynamic comment) {
|
||||||
|
final bool isMine = comment['isMine'] == true;
|
||||||
|
return Container(
|
||||||
|
margin: const EdgeInsets.only(bottom: 8),
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: AppPalette.paper,
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'${comment['anonLabel'] ?? '익명'}',
|
||||||
|
style: TextStyle(
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
fontSize: 12.5,
|
||||||
|
color: isMine ? AppPalette.ink : Colors.grey[700],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Text(
|
||||||
|
'${comment['createdAt'] ?? ''}',
|
||||||
|
style: const TextStyle(
|
||||||
|
color: Colors.grey,
|
||||||
|
fontSize: 11.5,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
comment['content'] ?? '',
|
||||||
|
style: const TextStyle(fontSize: 14, height: 1.4),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
padding: EdgeInsets.zero,
|
||||||
|
constraints: const BoxConstraints(),
|
||||||
|
icon: Icon(
|
||||||
|
isMine ? Icons.delete_outline_rounded : Icons.flag_outlined,
|
||||||
|
size: 18,
|
||||||
|
color: Colors.grey,
|
||||||
|
),
|
||||||
|
onPressed: isMine
|
||||||
|
? () => _deleteComment(comment['id'] as int)
|
||||||
|
: () => _showReportSheet(
|
||||||
|
targetType: 'comment',
|
||||||
|
targetId: comment['id'] as int,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildCommentComposer() {
|
||||||
|
return SafeArea(
|
||||||
|
top: false,
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(12, 8, 12, 12),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: TextField(
|
||||||
|
controller: _commentController,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
hintText: '익명으로 댓글 달기',
|
||||||
|
filled: true,
|
||||||
|
fillColor: AppPalette.paper,
|
||||||
|
contentPadding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 16,
|
||||||
|
vertical: 10,
|
||||||
|
),
|
||||||
|
border: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(999),
|
||||||
|
borderSide: BorderSide.none,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
_controller.isBusy
|
||||||
|
? const SizedBox(
|
||||||
|
width: 40,
|
||||||
|
height: 40,
|
||||||
|
child: Padding(
|
||||||
|
padding: EdgeInsets.all(8),
|
||||||
|
child: CircularProgressIndicator(strokeWidth: 2),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: IconButton(
|
||||||
|
icon: const Icon(Icons.send_rounded, color: AppPalette.ink),
|
||||||
|
onPressed: _submitComment,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,169 @@
|
|||||||
|
// 🚩 커뮤니티 게시판 - 신고 검토(운영자용) 화면 (UI 전용).
|
||||||
|
// 서버 통신/상태는 lib/function/board_report_controller.dart가 담당한다.
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import '../function/board_report_controller.dart';
|
||||||
|
import '../theme/app_palette.dart';
|
||||||
|
import 'app_notice.dart';
|
||||||
|
import 'title_pill.dart';
|
||||||
|
|
||||||
|
class BoardReportScreen extends StatefulWidget {
|
||||||
|
final String reviewerId;
|
||||||
|
|
||||||
|
const BoardReportScreen({super.key, required this.reviewerId});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<BoardReportScreen> createState() => _BoardReportScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _BoardReportScreenState extends State<BoardReportScreen> {
|
||||||
|
late final BoardReportController _controller;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_controller = BoardReportController(reviewerId: widget.reviewerId);
|
||||||
|
_controller.init();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_controller.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _resolve(int reportId, String action) async {
|
||||||
|
final (success, message) = await _controller.resolve(reportId, action);
|
||||||
|
if (!mounted) return;
|
||||||
|
AppNotice.show(context, message);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return ListenableBuilder(
|
||||||
|
listenable: _controller,
|
||||||
|
builder: (context, _) {
|
||||||
|
final reports = _controller.reports;
|
||||||
|
return Scaffold(
|
||||||
|
backgroundColor: AppPalette.mist,
|
||||||
|
appBar: AppBar(
|
||||||
|
backgroundColor: Colors.transparent,
|
||||||
|
foregroundColor: AppPalette.ink,
|
||||||
|
elevation: 0,
|
||||||
|
centerTitle: true,
|
||||||
|
title: const TitlePill('게시판 신고 검토'),
|
||||||
|
),
|
||||||
|
body: _controller.isLoading
|
||||||
|
? const Center(child: CircularProgressIndicator())
|
||||||
|
: reports.isEmpty
|
||||||
|
? const Center(
|
||||||
|
child: Text(
|
||||||
|
'처리할 신고가 없습니다.',
|
||||||
|
style: TextStyle(color: Colors.grey),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: RefreshIndicator(
|
||||||
|
onRefresh: _controller.fetchPending,
|
||||||
|
child: ListView.builder(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
itemCount: reports.length,
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final report = reports[index];
|
||||||
|
final reportId = report['id'] as int;
|
||||||
|
final alreadyDeleted = report['alreadyDeleted'] == true;
|
||||||
|
return Container(
|
||||||
|
margin: const EdgeInsets.only(bottom: 12),
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: AppPalette.paper,
|
||||||
|
borderRadius: BorderRadius.circular(14),
|
||||||
|
border: Border.all(color: AppPalette.sage),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Chip(
|
||||||
|
label: Text(
|
||||||
|
report['targetType'] == 'post'
|
||||||
|
? '게시글'
|
||||||
|
: '댓글',
|
||||||
|
),
|
||||||
|
visualDensity: VisualDensity.compact,
|
||||||
|
backgroundColor: AppPalette.mist,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Text(
|
||||||
|
'${report['createdAt'] ?? ''}',
|
||||||
|
style: const TextStyle(
|
||||||
|
color: Colors.grey,
|
||||||
|
fontSize: 12,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
Text(
|
||||||
|
'${report['targetContent'] ?? ''}',
|
||||||
|
style: const TextStyle(fontSize: 14, height: 1.4),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
if (report['reason'] != null &&
|
||||||
|
'${report['reason']}'.isNotEmpty)
|
||||||
|
Text(
|
||||||
|
'신고 사유: ${report['reason']}',
|
||||||
|
style: const TextStyle(
|
||||||
|
color: Colors.redAccent,
|
||||||
|
fontSize: 12.5,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
'신고자 학번: ${report['reporterStudentId'] ?? ''}',
|
||||||
|
style: const TextStyle(
|
||||||
|
color: Colors.grey,
|
||||||
|
fontSize: 12,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
if (alreadyDeleted)
|
||||||
|
const Text(
|
||||||
|
'이미 삭제된 항목입니다.',
|
||||||
|
style: TextStyle(color: Colors.grey),
|
||||||
|
)
|
||||||
|
else
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: OutlinedButton(
|
||||||
|
onPressed: _controller.isBusy
|
||||||
|
? null
|
||||||
|
: () => _resolve(reportId, 'dismiss'),
|
||||||
|
child: const Text('유지 (문제 없음)'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(
|
||||||
|
child: ElevatedButton(
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
backgroundColor: Colors.red[400],
|
||||||
|
foregroundColor: Colors.white,
|
||||||
|
),
|
||||||
|
onPressed: _controller.isBusy
|
||||||
|
? null
|
||||||
|
: () => _resolve(reportId, 'delete'),
|
||||||
|
child: const Text('삭제'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
// ✍️ 커뮤니티 게시판 - 글쓰기 화면 (UI 전용).
|
||||||
|
// 서버 통신/상태는 lib/function/board_write_controller.dart가 담당한다.
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import '../function/board_write_controller.dart';
|
||||||
|
import '../theme/app_palette.dart';
|
||||||
|
import 'app_notice.dart';
|
||||||
|
import 'title_pill.dart';
|
||||||
|
|
||||||
|
class BoardWriteScreen extends StatefulWidget {
|
||||||
|
final String studentId;
|
||||||
|
final String category;
|
||||||
|
final String categoryName;
|
||||||
|
|
||||||
|
const BoardWriteScreen({
|
||||||
|
super.key,
|
||||||
|
required this.studentId,
|
||||||
|
required this.category,
|
||||||
|
required this.categoryName,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<BoardWriteScreen> createState() => _BoardWriteScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _BoardWriteScreenState extends State<BoardWriteScreen> {
|
||||||
|
late final BoardWriteController _controller;
|
||||||
|
final _titleController = TextEditingController();
|
||||||
|
final _contentController = TextEditingController();
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_controller = BoardWriteController(studentId: widget.studentId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_controller.dispose();
|
||||||
|
_titleController.dispose();
|
||||||
|
_contentController.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _submit() async {
|
||||||
|
final (success, message) = await _controller.submit(
|
||||||
|
category: widget.category,
|
||||||
|
title: _titleController.text,
|
||||||
|
content: _contentController.text,
|
||||||
|
);
|
||||||
|
if (!mounted) return;
|
||||||
|
AppNotice.show(
|
||||||
|
context,
|
||||||
|
message,
|
||||||
|
icon: success ? Icons.check_circle_rounded : Icons.error_outline_rounded,
|
||||||
|
);
|
||||||
|
if (success) Navigator.of(context).pop(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return ListenableBuilder(
|
||||||
|
listenable: _controller,
|
||||||
|
builder: (context, _) {
|
||||||
|
return Scaffold(
|
||||||
|
backgroundColor: AppPalette.mist,
|
||||||
|
appBar: AppBar(
|
||||||
|
backgroundColor: Colors.transparent,
|
||||||
|
foregroundColor: AppPalette.ink,
|
||||||
|
elevation: 0,
|
||||||
|
centerTitle: true,
|
||||||
|
title: TitlePill('${widget.categoryName} 글쓰기'),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: _controller.isSubmitting ? null : _submit,
|
||||||
|
child: _controller.isSubmitting
|
||||||
|
? const SizedBox(
|
||||||
|
width: 18,
|
||||||
|
height: 18,
|
||||||
|
child: CircularProgressIndicator(strokeWidth: 2),
|
||||||
|
)
|
||||||
|
: const Text(
|
||||||
|
'등록',
|
||||||
|
style: TextStyle(
|
||||||
|
color: AppPalette.ink,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
body: SingleChildScrollView(
|
||||||
|
padding: const EdgeInsets.all(20),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
const Padding(
|
||||||
|
padding: EdgeInsets.only(bottom: 4, left: 4),
|
||||||
|
child: Text(
|
||||||
|
'글쓴이는 익명으로 표시됩니다.',
|
||||||
|
style: TextStyle(color: Colors.grey, fontSize: 12.5),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
TextField(
|
||||||
|
controller: _titleController,
|
||||||
|
maxLength: 100,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
hintText: '제목',
|
||||||
|
filled: true,
|
||||||
|
fillColor: AppPalette.paper,
|
||||||
|
border: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(14),
|
||||||
|
borderSide: BorderSide(color: AppPalette.sage),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
TextField(
|
||||||
|
controller: _contentController,
|
||||||
|
maxLines: 12,
|
||||||
|
minLines: 8,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
hintText: '내용을 입력하세요',
|
||||||
|
filled: true,
|
||||||
|
fillColor: AppPalette.paper,
|
||||||
|
border: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(14),
|
||||||
|
borderSide: BorderSide(color: AppPalette.sage),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
// ⚙️ 대시보드 설정 화면 (UI 전용). 지금은 "UI 편집" 진입점 하나만 있다.
|
||||||
|
// "UI 편집"을 누르면 이 화면을 pop('edit_ui')로 닫고, 대시보드가 그 결과를 받아 편집 모드로 들어간다.
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import '../models/dashboard_tile_layout.dart';
|
||||||
|
import '../theme/app_palette.dart';
|
||||||
|
import 'title_pill.dart';
|
||||||
|
|
||||||
|
class DashboardSettingsPage extends StatelessWidget {
|
||||||
|
const DashboardSettingsPage({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Scaffold(
|
||||||
|
backgroundColor: AppPalette.mist,
|
||||||
|
appBar: AppBar(
|
||||||
|
backgroundColor: Colors.transparent,
|
||||||
|
foregroundColor: AppPalette.ink,
|
||||||
|
elevation: 0,
|
||||||
|
centerTitle: true,
|
||||||
|
title: const TitlePill('설정'),
|
||||||
|
),
|
||||||
|
body: ListView(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: AppPalette.paper,
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
border: Border.all(color: AppPalette.sage),
|
||||||
|
),
|
||||||
|
child: ListTile(
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
),
|
||||||
|
enabled: kTileEditingEnabled,
|
||||||
|
leading: const Icon(
|
||||||
|
Icons.grid_view_rounded,
|
||||||
|
color: AppPalette.ink,
|
||||||
|
),
|
||||||
|
title: const Text(
|
||||||
|
'UI 편집',
|
||||||
|
style: TextStyle(fontWeight: FontWeight.bold),
|
||||||
|
),
|
||||||
|
// 📱 [기능 보류] 작은 화면에서 레이아웃이 깨지는 문제로 잠시 막아둠.
|
||||||
|
subtitle: Text(
|
||||||
|
kTileEditingEnabled
|
||||||
|
? '타일을 드래그해서 옮기고 크기를 바꿀 수 있어요.'
|
||||||
|
: '작은 화면 대응을 준비 중이라 잠시 사용할 수 없어요.',
|
||||||
|
),
|
||||||
|
trailing: kTileEditingEnabled
|
||||||
|
? const Icon(Icons.chevron_right_rounded)
|
||||||
|
: null,
|
||||||
|
onTap: kTileEditingEnabled
|
||||||
|
? () => Navigator.pop(context, 'edit_ui')
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,507 @@
|
|||||||
|
// 📋 선생님용 "스마트기기 반출 대장" 화면 (UI 전용). 오늘 신청된 반출 요청을 보여주고 승인/거절한다.
|
||||||
|
// 서버 통신/상태는 lib/function/device_checkout_ledger_controller.dart가 담당한다.
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import '../function/device_checkout_ledger_controller.dart';
|
||||||
|
import '../theme/app_palette.dart';
|
||||||
|
import 'app_notice.dart';
|
||||||
|
import 'title_pill.dart';
|
||||||
|
|
||||||
|
class DeviceCheckoutLedgerPage extends StatefulWidget {
|
||||||
|
final String? teacherId;
|
||||||
|
final String? teacherName;
|
||||||
|
|
||||||
|
const DeviceCheckoutLedgerPage({super.key, this.teacherId, this.teacherName});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<DeviceCheckoutLedgerPage> createState() =>
|
||||||
|
_DeviceCheckoutLedgerPageState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _DeviceCheckoutLedgerPageState extends State<DeviceCheckoutLedgerPage> {
|
||||||
|
final DeviceCheckoutLedgerController _controller =
|
||||||
|
DeviceCheckoutLedgerController();
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_controller.init();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_controller.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _approve(int id) async {
|
||||||
|
final (_, message) = await _controller.approve(
|
||||||
|
id,
|
||||||
|
teacherId: widget.teacherId ?? '간편인증',
|
||||||
|
teacherName: widget.teacherName ?? '간편인증 선생',
|
||||||
|
);
|
||||||
|
if (!mounted) return;
|
||||||
|
AppNotice.show(context, message);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _reject(int id) async {
|
||||||
|
final (_, message) = await _controller.reject(id);
|
||||||
|
if (!mounted) return;
|
||||||
|
AppNotice.show(context, message);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 🙌 [전체 학생 허용] 한 명씩 누르기 귀찮을 때, 대기 중인 요청을 한 번에 전부 승인한다.
|
||||||
|
// 1시간 59분 넘는 장시간 요청은 자동으로 제외되어 대기중으로 남는다 - 따로 검토가 필요해서.
|
||||||
|
void _showApproveAllConfirmDialog() {
|
||||||
|
final pendingCount = _controller.requests
|
||||||
|
.where((r) => r['status'] == 'PENDING')
|
||||||
|
.length;
|
||||||
|
if (pendingCount == 0) {
|
||||||
|
AppNotice.show(context, '승인 대기 중인 요청이 없습니다.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final approvableCount = _controller.autoApprovableIds.length;
|
||||||
|
final excludedCount = pendingCount - approvableCount;
|
||||||
|
showDialog(
|
||||||
|
context: context,
|
||||||
|
builder: (dialogContext) => AlertDialog(
|
||||||
|
title: const Text('전체 학생 허용'),
|
||||||
|
content: Text(
|
||||||
|
excludedCount > 0
|
||||||
|
? '대기 중인 요청 $pendingCount건 중 $approvableCount건을 승인합니다.\n'
|
||||||
|
'장시간(1시간 59분 이상) 요청 $excludedCount건은 제외되어 "많은 시간요청 학생"에서 따로 확인해야 합니다.'
|
||||||
|
: '대기 중인 요청 $pendingCount건을 한 번에 전부 승인하시겠습니까?',
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(dialogContext),
|
||||||
|
child: const Text('취소', style: TextStyle(color: Colors.grey)),
|
||||||
|
),
|
||||||
|
ElevatedButton(
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
backgroundColor: AppPalette.ink,
|
||||||
|
foregroundColor: AppPalette.paper,
|
||||||
|
),
|
||||||
|
onPressed: () async {
|
||||||
|
Navigator.pop(dialogContext);
|
||||||
|
final (_, message) = await _controller.approveAllPending(
|
||||||
|
teacherId: widget.teacherId ?? '간편인증',
|
||||||
|
teacherName: widget.teacherName ?? '간편인증 선생',
|
||||||
|
);
|
||||||
|
if (!mounted) return;
|
||||||
|
AppNotice.show(context, message);
|
||||||
|
},
|
||||||
|
child: const Text('전체 승인'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 🚨 [많은 시간요청 학생] 1시간 59분 넘게 신청한 대기 중인 요청만 따로 모아 보여준다.
|
||||||
|
// 학생이 시간을 비정상적으로 길게 적어냈을 수 있으니, 전체 승인 전에 훑어보는 용도.
|
||||||
|
void _showLongRequestsDialog() {
|
||||||
|
showDialog(
|
||||||
|
context: context,
|
||||||
|
builder: (dialogContext) => AlertDialog(
|
||||||
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
|
||||||
|
title: const Row(
|
||||||
|
children: [
|
||||||
|
Icon(Icons.warning_amber_rounded, color: Colors.orange),
|
||||||
|
SizedBox(width: 8),
|
||||||
|
Text('많은 시간요청 학생'),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
content: SizedBox(
|
||||||
|
width: 380,
|
||||||
|
child: ListenableBuilder(
|
||||||
|
listenable: _controller,
|
||||||
|
builder: (context, _) {
|
||||||
|
final longRequests = _controller.longPendingRequests;
|
||||||
|
if (longRequests.isEmpty) {
|
||||||
|
return const Padding(
|
||||||
|
padding: EdgeInsets.symmetric(vertical: 16),
|
||||||
|
child: Text(
|
||||||
|
'1시간 59분 넘게 신청한 대기 중인 요청이 없습니다.',
|
||||||
|
style: TextStyle(color: Colors.grey),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return ConstrainedBox(
|
||||||
|
constraints: const BoxConstraints(maxHeight: 400),
|
||||||
|
child: ListView.separated(
|
||||||
|
shrinkWrap: true,
|
||||||
|
itemCount: longRequests.length,
|
||||||
|
separatorBuilder: (_, _) => const Divider(height: 20),
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final r = longRequests[index];
|
||||||
|
final hours = _controller.durationHours(r);
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'${r['studentName']} (${r['studentId']})',
|
||||||
|
style: const TextStyle(fontWeight: FontWeight.bold),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
'${_timeRange(r['requestedStart'], r['requestedEnd'])} '
|
||||||
|
'(약 ${hours?.toStringAsFixed(1)}시간)',
|
||||||
|
style: const TextStyle(
|
||||||
|
color: Colors.orange,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
fontSize: 13,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
Text(
|
||||||
|
r['purpose'],
|
||||||
|
style: TextStyle(
|
||||||
|
color: Colors.grey[700],
|
||||||
|
fontSize: 13,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: OutlinedButton(
|
||||||
|
onPressed: _controller.isWorking
|
||||||
|
? null
|
||||||
|
: () {
|
||||||
|
Navigator.pop(dialogContext);
|
||||||
|
_reject(r['id']);
|
||||||
|
},
|
||||||
|
style: OutlinedButton.styleFrom(
|
||||||
|
foregroundColor: Colors.red,
|
||||||
|
side: const BorderSide(color: Colors.red),
|
||||||
|
),
|
||||||
|
child: const Text('거절'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(
|
||||||
|
child: ElevatedButton(
|
||||||
|
onPressed: _controller.isWorking
|
||||||
|
? null
|
||||||
|
: () {
|
||||||
|
Navigator.pop(dialogContext);
|
||||||
|
_approve(r['id']);
|
||||||
|
},
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
backgroundColor: AppPalette.ink,
|
||||||
|
foregroundColor: AppPalette.paper,
|
||||||
|
),
|
||||||
|
child: const Text('승인'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(dialogContext),
|
||||||
|
child: const Text('닫기'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 🖐️ [하드웨어 자동 감지 전까지 임시] 기기를 실제로 돌려받았을 때 누르는 버튼.
|
||||||
|
Future<void> _confirmReturn(int id) async {
|
||||||
|
final (_, message) = await _controller.confirmReturn(id);
|
||||||
|
if (!mounted) return;
|
||||||
|
AppNotice.show(context, message);
|
||||||
|
}
|
||||||
|
|
||||||
|
({Color color, String label}) _statusInfo(String status) {
|
||||||
|
switch (status) {
|
||||||
|
case 'PENDING':
|
||||||
|
return (color: Colors.orange, label: '대기중');
|
||||||
|
case 'APPROVED':
|
||||||
|
return (color: Colors.blue, label: '승인됨');
|
||||||
|
case 'RETURNED':
|
||||||
|
return (color: Colors.grey, label: '반납완료');
|
||||||
|
case 'REJECTED':
|
||||||
|
return (color: Colors.red, label: '거절됨');
|
||||||
|
default:
|
||||||
|
return (color: Colors.grey, label: status);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String _timeRange(String start, String end) {
|
||||||
|
// "YYYY-MM-DD HH:MM:SS" -> "HH:MM"
|
||||||
|
String hm(String full) => full.length >= 16 ? full.substring(11, 16) : full;
|
||||||
|
return '${hm(start)} ~ ${hm(end)}';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return ListenableBuilder(
|
||||||
|
listenable: _controller,
|
||||||
|
builder: (context, _) {
|
||||||
|
final requests = _controller.requests;
|
||||||
|
final pendingCount = requests
|
||||||
|
.where((r) => r['status'] == 'PENDING')
|
||||||
|
.length;
|
||||||
|
final longCount = _controller.longPendingRequests.length;
|
||||||
|
|
||||||
|
return Scaffold(
|
||||||
|
backgroundColor: AppPalette.mist,
|
||||||
|
appBar: AppBar(
|
||||||
|
backgroundColor: Colors.transparent,
|
||||||
|
foregroundColor: AppPalette.ink,
|
||||||
|
elevation: 0,
|
||||||
|
centerTitle: true,
|
||||||
|
title: const TitlePill('스마트기기 반출 대장'),
|
||||||
|
actions: [
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.refresh_rounded),
|
||||||
|
onPressed: _controller.fetchAll,
|
||||||
|
tooltip: '새로고침',
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.done_all_rounded),
|
||||||
|
onPressed: _showApproveAllConfirmDialog,
|
||||||
|
tooltip: '전체 학생 허용',
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
icon: Badge(
|
||||||
|
isLabelVisible: longCount > 0,
|
||||||
|
label: Text('$longCount'),
|
||||||
|
backgroundColor: Colors.red,
|
||||||
|
child: const Icon(Icons.warning_amber_rounded),
|
||||||
|
),
|
||||||
|
onPressed: _showLongRequestsDialog,
|
||||||
|
tooltip: '많은 시간요청 학생',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
body: _controller.isLoading
|
||||||
|
? const Center(child: CircularProgressIndicator())
|
||||||
|
: Column(
|
||||||
|
children: [
|
||||||
|
if (pendingCount > 0)
|
||||||
|
Container(
|
||||||
|
width: double.infinity,
|
||||||
|
margin: const EdgeInsets.all(16),
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 16,
|
||||||
|
vertical: 10,
|
||||||
|
),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.orange[100],
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
border: Border.all(color: Colors.orange[400]!),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
'승인 대기 중인 요청이 $pendingCount건 있습니다.',
|
||||||
|
style: TextStyle(
|
||||||
|
color: Colors.orange[900],
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: requests.isEmpty
|
||||||
|
? const Center(
|
||||||
|
child: Text(
|
||||||
|
'오늘 신청된 반출 요청이 없습니다.',
|
||||||
|
style: TextStyle(color: Colors.grey),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: ListView.builder(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 16,
|
||||||
|
vertical: 8,
|
||||||
|
),
|
||||||
|
itemCount: requests.length,
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final r = requests[index];
|
||||||
|
final String status = r['status'];
|
||||||
|
final info = _statusInfo(status);
|
||||||
|
final bool isPending = status == 'PENDING';
|
||||||
|
// 🚨 1시간 59분 넘게 신청했으면 상태와 무관하게 빨간 테두리로 눈에 띄게.
|
||||||
|
final bool isLong = _controller.isLongRequest(
|
||||||
|
r,
|
||||||
|
);
|
||||||
|
|
||||||
|
return Card(
|
||||||
|
elevation: 0,
|
||||||
|
color: AppPalette.paper,
|
||||||
|
margin: const EdgeInsets.only(bottom: 10),
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
side: isLong
|
||||||
|
? BorderSide(
|
||||||
|
color: Colors.red[400]!,
|
||||||
|
width: 2,
|
||||||
|
)
|
||||||
|
: (isPending
|
||||||
|
? BorderSide(
|
||||||
|
color: Colors.orange[300]!,
|
||||||
|
)
|
||||||
|
: BorderSide(
|
||||||
|
color: AppPalette.sage,
|
||||||
|
)),
|
||||||
|
),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment:
|
||||||
|
CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
'${r['studentName']} (${r['studentId']})',
|
||||||
|
style: const TextStyle(
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
fontSize: 15,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Container(
|
||||||
|
padding:
|
||||||
|
const EdgeInsets.symmetric(
|
||||||
|
horizontal: 10,
|
||||||
|
vertical: 4,
|
||||||
|
),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: info.color.withValues(
|
||||||
|
alpha: 0.12,
|
||||||
|
),
|
||||||
|
borderRadius:
|
||||||
|
BorderRadius.circular(20),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
info.label,
|
||||||
|
style: TextStyle(
|
||||||
|
color: info.color,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
fontSize: 12,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
Text(
|
||||||
|
_timeRange(
|
||||||
|
r['requestedStart'],
|
||||||
|
r['requestedEnd'],
|
||||||
|
),
|
||||||
|
style: TextStyle(
|
||||||
|
color: Colors.grey[700],
|
||||||
|
fontSize: 13,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
Text(
|
||||||
|
r['purpose'],
|
||||||
|
style: TextStyle(
|
||||||
|
color: Colors.grey[700],
|
||||||
|
fontSize: 13,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (status == 'APPROVED' &&
|
||||||
|
r['approvedByTeacherName'] !=
|
||||||
|
null) ...[
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
Text(
|
||||||
|
'${r['approvedByTeacherName']} 선생님이 허용했습니다',
|
||||||
|
style: TextStyle(
|
||||||
|
color: Colors.blue[700],
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
if (isPending) ...[
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: OutlinedButton(
|
||||||
|
onPressed:
|
||||||
|
_controller.isWorking
|
||||||
|
? null
|
||||||
|
: () => _reject(r['id']),
|
||||||
|
style:
|
||||||
|
OutlinedButton.styleFrom(
|
||||||
|
foregroundColor:
|
||||||
|
Colors.red,
|
||||||
|
side: const BorderSide(
|
||||||
|
color: Colors.red,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: const Text('거절'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(
|
||||||
|
child: ElevatedButton(
|
||||||
|
onPressed:
|
||||||
|
_controller.isWorking
|
||||||
|
? null
|
||||||
|
: () => _approve(r['id']),
|
||||||
|
style:
|
||||||
|
ElevatedButton.styleFrom(
|
||||||
|
backgroundColor:
|
||||||
|
AppPalette.ink,
|
||||||
|
foregroundColor:
|
||||||
|
AppPalette.paper,
|
||||||
|
),
|
||||||
|
child: const Text('승인'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
if (status == 'APPROVED') ...[
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
SizedBox(
|
||||||
|
width: double.infinity,
|
||||||
|
child: OutlinedButton.icon(
|
||||||
|
onPressed: _controller.isWorking
|
||||||
|
? null
|
||||||
|
: () =>
|
||||||
|
_confirmReturn(r['id']),
|
||||||
|
style: OutlinedButton.styleFrom(
|
||||||
|
foregroundColor:
|
||||||
|
Colors.grey[800],
|
||||||
|
side: BorderSide(
|
||||||
|
color: Colors.grey[400]!,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
icon: const Icon(
|
||||||
|
Icons
|
||||||
|
.assignment_turned_in_outlined,
|
||||||
|
size: 18,
|
||||||
|
),
|
||||||
|
label: const Text('기기 반납 확인'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,197 @@
|
|||||||
|
// 📱 스마트기기(패드) 반출 신청 화면 (UI 전용). 시작~종료 시각과 사용 목적을 입력받는다.
|
||||||
|
// 서버 통신/상태는 lib/function/device_checkout_controller.dart가 담당한다.
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import '../function/device_checkout_controller.dart';
|
||||||
|
import '../theme/app_palette.dart';
|
||||||
|
import 'app_notice.dart';
|
||||||
|
import 'title_pill.dart';
|
||||||
|
|
||||||
|
class DeviceCheckoutRequestScreen extends StatefulWidget {
|
||||||
|
final String studentId;
|
||||||
|
final String studentName;
|
||||||
|
|
||||||
|
const DeviceCheckoutRequestScreen({
|
||||||
|
super.key,
|
||||||
|
required this.studentId,
|
||||||
|
required this.studentName,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<DeviceCheckoutRequestScreen> createState() =>
|
||||||
|
_DeviceCheckoutRequestScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _DeviceCheckoutRequestScreenState
|
||||||
|
extends State<DeviceCheckoutRequestScreen> {
|
||||||
|
final DeviceCheckoutController _controller = DeviceCheckoutController();
|
||||||
|
final TextEditingController _purposeController = TextEditingController();
|
||||||
|
TimeOfDay? _startTime;
|
||||||
|
TimeOfDay? _endTime;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_controller.dispose();
|
||||||
|
_purposeController.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
String _formatTime(TimeOfDay time) =>
|
||||||
|
'${time.hour.toString().padLeft(2, '0')}:${time.minute.toString().padLeft(2, '0')}';
|
||||||
|
|
||||||
|
Future<void> _pickStartTime() async {
|
||||||
|
final picked = await showTimePicker(
|
||||||
|
context: context,
|
||||||
|
initialTime: _startTime ?? TimeOfDay.now(),
|
||||||
|
helpText: '사용 시작 시각',
|
||||||
|
);
|
||||||
|
if (picked != null) setState(() => _startTime = picked);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _pickEndTime() async {
|
||||||
|
final picked = await showTimePicker(
|
||||||
|
context: context,
|
||||||
|
initialTime: _endTime ?? TimeOfDay.now(),
|
||||||
|
helpText: '사용 종료 시각',
|
||||||
|
);
|
||||||
|
if (picked != null) setState(() => _endTime = picked);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _submit() async {
|
||||||
|
final purpose = _purposeController.text.trim();
|
||||||
|
if (_startTime == null || _endTime == null || purpose.isEmpty) {
|
||||||
|
AppNotice.show(context, '시작/종료 시각과 사용 목적을 모두 입력해주세요.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final (success, message) = await _controller.submitRequest(
|
||||||
|
studentId: widget.studentId,
|
||||||
|
studentName: widget.studentName,
|
||||||
|
purpose: purpose,
|
||||||
|
startTime: _formatTime(_startTime!),
|
||||||
|
endTime: _formatTime(_endTime!),
|
||||||
|
);
|
||||||
|
if (!mounted) return;
|
||||||
|
AppNotice.show(context, message);
|
||||||
|
if (success) Navigator.pop(context);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return ListenableBuilder(
|
||||||
|
listenable: _controller,
|
||||||
|
builder: (context, _) {
|
||||||
|
return Scaffold(
|
||||||
|
backgroundColor: AppPalette.mist,
|
||||||
|
appBar: AppBar(
|
||||||
|
backgroundColor: Colors.transparent,
|
||||||
|
foregroundColor: AppPalette.ink,
|
||||||
|
elevation: 0,
|
||||||
|
centerTitle: true,
|
||||||
|
title: const TitlePill('스마트기기 반출 신청'),
|
||||||
|
),
|
||||||
|
body: SingleChildScrollView(
|
||||||
|
padding: const EdgeInsets.all(24.0),
|
||||||
|
child: Center(
|
||||||
|
child: ConstrainedBox(
|
||||||
|
constraints: const BoxConstraints(maxWidth: 420),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
const Text(
|
||||||
|
'패드는 NFC 태그가 안 되니, 사용 시간과 목적을 적어 신청하면\n'
|
||||||
|
'선생님이 확인하고 승인해줍니다. 승인되면 반납 바구니에서 꺼내 쓰세요.',
|
||||||
|
style: TextStyle(
|
||||||
|
color: Colors.black54,
|
||||||
|
fontSize: 13,
|
||||||
|
height: 1.4,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.all(20),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: AppPalette.paper,
|
||||||
|
borderRadius: BorderRadius.circular(24),
|
||||||
|
border: Border.all(color: AppPalette.sage),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: OutlinedButton.icon(
|
||||||
|
onPressed: _pickStartTime,
|
||||||
|
icon: const Icon(Icons.play_arrow_rounded),
|
||||||
|
label: Text(
|
||||||
|
_startTime == null
|
||||||
|
? '시작 시각'
|
||||||
|
: _formatTime(_startTime!),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: OutlinedButton.icon(
|
||||||
|
onPressed: _pickEndTime,
|
||||||
|
icon: const Icon(Icons.stop_rounded),
|
||||||
|
label: Text(
|
||||||
|
_endTime == null
|
||||||
|
? '종료 시각'
|
||||||
|
: _formatTime(_endTime!),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
TextField(
|
||||||
|
controller: _purposeController,
|
||||||
|
maxLines: 3,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: '사용 목적',
|
||||||
|
hintText: '예: 수행평가 자료조사',
|
||||||
|
alignLabelWithHint: true,
|
||||||
|
border: OutlineInputBorder(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
SizedBox(
|
||||||
|
width: double.infinity,
|
||||||
|
height: 50,
|
||||||
|
child: ElevatedButton(
|
||||||
|
onPressed: _controller.isSubmitting
|
||||||
|
? null
|
||||||
|
: _submit,
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
backgroundColor: AppPalette.ink,
|
||||||
|
foregroundColor: AppPalette.paper,
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: _controller.isSubmitting
|
||||||
|
? const CircularProgressIndicator(
|
||||||
|
color: Colors.white,
|
||||||
|
)
|
||||||
|
: const Text(
|
||||||
|
'반출 신청하기',
|
||||||
|
style: TextStyle(
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
fontSize: 15,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,289 @@
|
|||||||
|
// 🧑🤝🧑 백품타 친구 화면 (UI 전용). "실시간 출석 현황"과 같은 패밀리룩을 따른다.
|
||||||
|
// 서버 통신/상태는 lib/function/friend_controller.dart가 담당한다.
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import '../function/friend_controller.dart';
|
||||||
|
import '../theme/app_palette.dart';
|
||||||
|
import 'app_notice.dart';
|
||||||
|
import 'title_pill.dart';
|
||||||
|
|
||||||
|
class FriendsScreen extends StatefulWidget {
|
||||||
|
final String studentId;
|
||||||
|
final String studentName;
|
||||||
|
|
||||||
|
const FriendsScreen({
|
||||||
|
super.key,
|
||||||
|
required this.studentId,
|
||||||
|
required this.studentName,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<FriendsScreen> createState() => _FriendsScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _FriendsScreenState extends State<FriendsScreen> {
|
||||||
|
late final FriendController _controller;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_controller = FriendController(
|
||||||
|
studentId: widget.studentId,
|
||||||
|
studentName: widget.studentName,
|
||||||
|
);
|
||||||
|
_controller.init();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_controller.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _showAddFriendDialog() async {
|
||||||
|
final idController = TextEditingController();
|
||||||
|
final result = await showDialog<bool>(
|
||||||
|
context: context,
|
||||||
|
builder: (dialogContext) => AlertDialog(
|
||||||
|
title: const Text('친구 추가'),
|
||||||
|
content: TextField(
|
||||||
|
controller: idController,
|
||||||
|
keyboardType: TextInputType.number,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: '친구 학번',
|
||||||
|
border: OutlineInputBorder(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(dialogContext, false),
|
||||||
|
child: const Text('취소', style: TextStyle(color: Colors.grey)),
|
||||||
|
),
|
||||||
|
ElevatedButton(
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
backgroundColor: AppPalette.ink,
|
||||||
|
foregroundColor: AppPalette.paper,
|
||||||
|
),
|
||||||
|
onPressed: () => Navigator.pop(dialogContext, true),
|
||||||
|
child: const Text('신청하기'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (result != true || idController.text.trim().isEmpty) return;
|
||||||
|
final (success, message) = await _controller.sendRequest(
|
||||||
|
idController.text.trim(),
|
||||||
|
);
|
||||||
|
if (!mounted) return;
|
||||||
|
AppNotice.show(
|
||||||
|
context,
|
||||||
|
message,
|
||||||
|
icon: success ? Icons.check_circle_rounded : Icons.error_outline_rounded,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _respond(int requestId, bool accept) async {
|
||||||
|
final (_, message) = await _controller.respondRequest(requestId, accept);
|
||||||
|
if (!mounted) return;
|
||||||
|
AppNotice.show(context, message);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _remove(String friendId) async {
|
||||||
|
final confirmed = await showDialog<bool>(
|
||||||
|
context: context,
|
||||||
|
builder: (dialogContext) => AlertDialog(
|
||||||
|
title: const Text('친구 삭제'),
|
||||||
|
content: const Text('이 친구를 삭제하시겠습니까?'),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(dialogContext, false),
|
||||||
|
child: const Text('취소', style: TextStyle(color: Colors.grey)),
|
||||||
|
),
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(dialogContext, true),
|
||||||
|
child: const Text('삭제', style: TextStyle(color: Colors.red)),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (confirmed != true) return;
|
||||||
|
final (_, message) = await _controller.removeFriend(friendId);
|
||||||
|
if (!mounted) return;
|
||||||
|
AppNotice.show(context, message);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _sectionHeader(String title, {int? count}) {
|
||||||
|
return Row(
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
width: 4,
|
||||||
|
height: 16,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: AppPalette.ink,
|
||||||
|
borderRadius: BorderRadius.circular(2),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Text(
|
||||||
|
title,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: AppPalette.ink,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (count != null) ...[
|
||||||
|
const SizedBox(width: 6),
|
||||||
|
Text(
|
||||||
|
'$count명',
|
||||||
|
style: TextStyle(fontSize: 13, color: Colors.grey[500]),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return ListenableBuilder(
|
||||||
|
listenable: _controller,
|
||||||
|
builder: (context, _) {
|
||||||
|
return Scaffold(
|
||||||
|
backgroundColor: AppPalette.mist,
|
||||||
|
appBar: AppBar(
|
||||||
|
backgroundColor: Colors.transparent,
|
||||||
|
foregroundColor: AppPalette.ink,
|
||||||
|
elevation: 0,
|
||||||
|
),
|
||||||
|
floatingActionButton: FloatingActionButton.extended(
|
||||||
|
backgroundColor: AppPalette.ink,
|
||||||
|
foregroundColor: AppPalette.paper,
|
||||||
|
onPressed: _showAddFriendDialog,
|
||||||
|
icon: const Icon(Icons.person_add_rounded),
|
||||||
|
label: const Text('친구 추가'),
|
||||||
|
),
|
||||||
|
body: _controller.isLoading
|
||||||
|
? const Center(child: CircularProgressIndicator())
|
||||||
|
: RefreshIndicator(
|
||||||
|
onRefresh: _controller.refresh,
|
||||||
|
child: ListView(
|
||||||
|
padding: const EdgeInsets.fromLTRB(16, 0, 16, 90),
|
||||||
|
children: [
|
||||||
|
const Center(child: TitlePill('친구')),
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
if (_controller.requests.isNotEmpty) ...[
|
||||||
|
_sectionHeader(
|
||||||
|
'받은 친구 신청',
|
||||||
|
count: _controller.requests.length,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
for (final r in _controller.requests)
|
||||||
|
Container(
|
||||||
|
margin: const EdgeInsets.only(bottom: 8),
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 16,
|
||||||
|
vertical: 12,
|
||||||
|
),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: AppPalette.paper,
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
border: Border.all(color: AppPalette.sage),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
'${r['name']} (${r['studentId']})',
|
||||||
|
style: const TextStyle(
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
TextButton(
|
||||||
|
onPressed: () =>
|
||||||
|
_respond(r['requestId'] as int, false),
|
||||||
|
child: const Text(
|
||||||
|
'거절',
|
||||||
|
style: TextStyle(color: Colors.grey),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
ElevatedButton(
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
backgroundColor: AppPalette.ink,
|
||||||
|
foregroundColor: AppPalette.paper,
|
||||||
|
),
|
||||||
|
onPressed: () =>
|
||||||
|
_respond(r['requestId'] as int, true),
|
||||||
|
child: const Text('수락'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
],
|
||||||
|
_sectionHeader('내 친구', count: _controller.friends.length),
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
if (_controller.friends.isEmpty)
|
||||||
|
const Padding(
|
||||||
|
padding: EdgeInsets.symmetric(vertical: 12),
|
||||||
|
child: Text(
|
||||||
|
'아직 친구가 없어요. 학번으로 친구를 추가해보세요.',
|
||||||
|
style: TextStyle(color: Colors.grey),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
else
|
||||||
|
for (final f in _controller.friends)
|
||||||
|
Container(
|
||||||
|
margin: const EdgeInsets.only(bottom: 8),
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 16,
|
||||||
|
vertical: 12,
|
||||||
|
),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: AppPalette.paper,
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
border: Border.all(color: AppPalette.sage),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.all(6),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: AppPalette.ink.withValues(
|
||||||
|
alpha: 0.06,
|
||||||
|
),
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
),
|
||||||
|
child: const Icon(
|
||||||
|
Icons.person_rounded,
|
||||||
|
size: 16,
|
||||||
|
color: AppPalette.ink,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 10),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
'${f['name']} (${f['studentId']})',
|
||||||
|
style: const TextStyle(
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(
|
||||||
|
Icons.person_remove_outlined,
|
||||||
|
color: Colors.grey,
|
||||||
|
),
|
||||||
|
onPressed: () =>
|
||||||
|
_remove(f['studentId'] as String),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,527 @@
|
|||||||
|
// 👥 백품타 그룹 상세 화면 (UI 전용) - 멤버 오늘 출석 현황, 친구 초대, 그룹 나가기.
|
||||||
|
// 서버 통신/상태는 lib/function/group_detail_controller.dart, friend_controller.dart가 담당한다.
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import '../function/friend_controller.dart';
|
||||||
|
import '../function/group_detail_controller.dart';
|
||||||
|
import '../theme/app_palette.dart';
|
||||||
|
import 'app_notice.dart';
|
||||||
|
import 'launchpad_transition.dart';
|
||||||
|
import 'study_timer_screen.dart';
|
||||||
|
import 'title_pill.dart';
|
||||||
|
|
||||||
|
class GroupDetailScreen extends StatefulWidget {
|
||||||
|
final int groupId;
|
||||||
|
final String groupName;
|
||||||
|
final String studentId;
|
||||||
|
final String studentName;
|
||||||
|
final int? grade;
|
||||||
|
|
||||||
|
const GroupDetailScreen({
|
||||||
|
super.key,
|
||||||
|
required this.groupId,
|
||||||
|
required this.groupName,
|
||||||
|
required this.studentId,
|
||||||
|
required this.studentName,
|
||||||
|
this.grade,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<GroupDetailScreen> createState() => _GroupDetailScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _GroupDetailScreenState extends State<GroupDetailScreen> {
|
||||||
|
late final GroupDetailController _controller;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_controller = GroupDetailController(
|
||||||
|
groupId: widget.groupId,
|
||||||
|
studentId: widget.studentId,
|
||||||
|
);
|
||||||
|
_controller.init();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_controller.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _showInviteSheet() async {
|
||||||
|
final friendController = FriendController(
|
||||||
|
studentId: widget.studentId,
|
||||||
|
studentName: '',
|
||||||
|
);
|
||||||
|
await friendController.init();
|
||||||
|
if (!mounted) return;
|
||||||
|
|
||||||
|
final memberIds = _controller.members
|
||||||
|
.map((m) => m['studentId'] as String)
|
||||||
|
.toSet();
|
||||||
|
final invitable = friendController.friends
|
||||||
|
.where((f) => !memberIds.contains(f['studentId']))
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
await showModalBottomSheet<void>(
|
||||||
|
context: context,
|
||||||
|
backgroundColor: AppPalette.paper,
|
||||||
|
shape: const RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
|
||||||
|
),
|
||||||
|
builder: (sheetContext) {
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.all(20),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
const Text(
|
||||||
|
'친구 초대',
|
||||||
|
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
if (invitable.isEmpty)
|
||||||
|
const Padding(
|
||||||
|
padding: EdgeInsets.symmetric(vertical: 16),
|
||||||
|
child: Text(
|
||||||
|
'초대할 수 있는 친구가 없어요.\n(이미 그룹에 있거나 친구가 없어요)',
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: TextStyle(color: Colors.grey),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
else
|
||||||
|
ConstrainedBox(
|
||||||
|
constraints: const BoxConstraints(maxHeight: 320),
|
||||||
|
child: ListView(
|
||||||
|
shrinkWrap: true,
|
||||||
|
children: [
|
||||||
|
for (final f in invitable)
|
||||||
|
ListTile(
|
||||||
|
leading: const Icon(
|
||||||
|
Icons.person_rounded,
|
||||||
|
color: AppPalette.ink,
|
||||||
|
),
|
||||||
|
title: Text('${f['name']}'),
|
||||||
|
subtitle: Text('${f['studentId']}'),
|
||||||
|
trailing: ElevatedButton(
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
backgroundColor: AppPalette.ink,
|
||||||
|
foregroundColor: AppPalette.paper,
|
||||||
|
),
|
||||||
|
onPressed: () async {
|
||||||
|
final (success, message) = await _controller
|
||||||
|
.invite(
|
||||||
|
f['studentId'] as String,
|
||||||
|
f['name'] as String,
|
||||||
|
);
|
||||||
|
if (sheetContext.mounted) {
|
||||||
|
Navigator.of(sheetContext).pop();
|
||||||
|
}
|
||||||
|
if (!mounted) return;
|
||||||
|
AppNotice.show(
|
||||||
|
context,
|
||||||
|
message,
|
||||||
|
icon: success
|
||||||
|
? Icons.check_circle_rounded
|
||||||
|
: Icons.error_outline_rounded,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
child: const Text('초대'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
friendController.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _toggleVisibility() async {
|
||||||
|
final makePublic = !_controller.isPublic;
|
||||||
|
final confirmed = await showDialog<bool>(
|
||||||
|
context: context,
|
||||||
|
builder: (dialogContext) => AlertDialog(
|
||||||
|
title: Text(makePublic ? '공개 그룹으로 전환' : '비공개 그룹으로 전환'),
|
||||||
|
content: Text(
|
||||||
|
makePublic
|
||||||
|
? '이제 누구나 이 그룹을 찾아서 참여 신청을 보낼 수 있어요. 참여는 그룹장이 허용해야 완료됩니다.'
|
||||||
|
: '더 이상 공개 목록에 뜨지 않고, 대기 중인 참여 신청은 모두 거절 처리됩니다.',
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(dialogContext, false),
|
||||||
|
child: const Text('취소', style: TextStyle(color: Colors.grey)),
|
||||||
|
),
|
||||||
|
ElevatedButton(
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
backgroundColor: AppPalette.ink,
|
||||||
|
foregroundColor: AppPalette.paper,
|
||||||
|
),
|
||||||
|
onPressed: () => Navigator.pop(dialogContext, true),
|
||||||
|
child: const Text('전환'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (confirmed != true) return;
|
||||||
|
final (_, message) = await _controller.setVisibility(makePublic);
|
||||||
|
if (!mounted) return;
|
||||||
|
AppNotice.show(context, message);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _respondJoinRequest(int requestId, bool accept) async {
|
||||||
|
final (_, message) = await _controller.respondJoinRequest(
|
||||||
|
requestId,
|
||||||
|
accept,
|
||||||
|
);
|
||||||
|
if (!mounted) return;
|
||||||
|
AppNotice.show(context, message);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _leave() async {
|
||||||
|
final confirmed = await showDialog<bool>(
|
||||||
|
context: context,
|
||||||
|
builder: (dialogContext) => AlertDialog(
|
||||||
|
title: const Text('그룹 나가기'),
|
||||||
|
content: Text('${widget.groupName} 그룹에서 나가시겠습니까?'),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(dialogContext, false),
|
||||||
|
child: const Text('취소', style: TextStyle(color: Colors.grey)),
|
||||||
|
),
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(dialogContext, true),
|
||||||
|
child: const Text('나가기', style: TextStyle(color: Colors.red)),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (confirmed != true) return;
|
||||||
|
final (success, message) = await _controller.leave();
|
||||||
|
if (!mounted) return;
|
||||||
|
AppNotice.show(context, message);
|
||||||
|
if (success) Navigator.of(context).pop();
|
||||||
|
}
|
||||||
|
|
||||||
|
String _formatMinutes(int seconds) {
|
||||||
|
final m = seconds ~/ 60;
|
||||||
|
if (m < 60) return '$m분';
|
||||||
|
return '${m ~/ 60}시간 ${m % 60}분';
|
||||||
|
}
|
||||||
|
|
||||||
|
void _openTimer() {
|
||||||
|
pushLaunchpad(
|
||||||
|
context,
|
||||||
|
(context) => StudyTimerScreen(
|
||||||
|
studentId: widget.studentId,
|
||||||
|
studentName: widget.studentName,
|
||||||
|
grade: widget.grade,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 넓은 화면(웹 데스크톱)에서 내용이 양옆으로 늘어지지 않게 가운데 최대 560 폭으로 맞춘다.
|
||||||
|
double _sidePad(BuildContext context) {
|
||||||
|
final width = MediaQuery.of(context).size.width;
|
||||||
|
return ((width - 560) / 2).clamp(16.0, double.infinity);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return ListenableBuilder(
|
||||||
|
listenable: _controller,
|
||||||
|
builder: (context, _) {
|
||||||
|
final members = _controller.members;
|
||||||
|
final attendedCount = members
|
||||||
|
.where((m) => m['attendedToday'] == true)
|
||||||
|
.length;
|
||||||
|
|
||||||
|
return Scaffold(
|
||||||
|
backgroundColor: AppPalette.mist,
|
||||||
|
appBar: AppBar(
|
||||||
|
backgroundColor: Colors.transparent,
|
||||||
|
foregroundColor: AppPalette.ink,
|
||||||
|
elevation: 0,
|
||||||
|
actions: [
|
||||||
|
if (_controller.isOwner)
|
||||||
|
IconButton(
|
||||||
|
icon: Icon(
|
||||||
|
_controller.isPublic
|
||||||
|
? Icons.public_rounded
|
||||||
|
: Icons.lock_rounded,
|
||||||
|
),
|
||||||
|
tooltip: _controller.isPublic ? '공개 그룹' : '비공개 그룹',
|
||||||
|
onPressed: _toggleVisibility,
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.exit_to_app_rounded),
|
||||||
|
tooltip: '그룹 나가기',
|
||||||
|
onPressed: _leave,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
floatingActionButton: FloatingActionButton.extended(
|
||||||
|
backgroundColor: AppPalette.ink,
|
||||||
|
foregroundColor: AppPalette.paper,
|
||||||
|
onPressed: _showInviteSheet,
|
||||||
|
icon: const Icon(Icons.person_add_rounded),
|
||||||
|
label: const Text('친구 초대'),
|
||||||
|
),
|
||||||
|
body: _controller.isLoading
|
||||||
|
? const Center(child: CircularProgressIndicator())
|
||||||
|
: RefreshIndicator(
|
||||||
|
onRefresh: _controller.refresh,
|
||||||
|
child: ListView(
|
||||||
|
padding: EdgeInsets.fromLTRB(
|
||||||
|
_sidePad(context),
|
||||||
|
0,
|
||||||
|
_sidePad(context),
|
||||||
|
90,
|
||||||
|
),
|
||||||
|
children: [
|
||||||
|
Center(child: TitlePill(widget.groupName)),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(child: _statPill('전체 멤버', members.length)),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(child: _statPill('오늘 공부함', attendedCount)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
SizedBox(
|
||||||
|
width: double.infinity,
|
||||||
|
child: OutlinedButton.icon(
|
||||||
|
onPressed: _openTimer,
|
||||||
|
icon: const Icon(Icons.timer_rounded),
|
||||||
|
label: const Text('백품타 바로가기'),
|
||||||
|
style: OutlinedButton.styleFrom(
|
||||||
|
foregroundColor: AppPalette.ink,
|
||||||
|
side: const BorderSide(color: AppPalette.sage),
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 14),
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (_controller.isOwner &&
|
||||||
|
_controller.joinRequests.isNotEmpty) ...[
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
width: 4,
|
||||||
|
height: 16,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: AppPalette.ink,
|
||||||
|
borderRadius: BorderRadius.circular(2),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
const Text(
|
||||||
|
'대기 중인 참여 신청',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: AppPalette.ink,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 6),
|
||||||
|
Text(
|
||||||
|
'${_controller.joinRequests.length}명',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 13,
|
||||||
|
color: Colors.grey[500],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
for (final r in _controller.joinRequests)
|
||||||
|
Container(
|
||||||
|
margin: const EdgeInsets.only(bottom: 8),
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 16,
|
||||||
|
vertical: 12,
|
||||||
|
),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: AppPalette.paper,
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
border: Border.all(color: AppPalette.sage),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
'${r['name']} (${r['studentId']})',
|
||||||
|
style: const TextStyle(
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => _respondJoinRequest(
|
||||||
|
r['requestId'] as int,
|
||||||
|
false,
|
||||||
|
),
|
||||||
|
child: const Text(
|
||||||
|
'거절',
|
||||||
|
style: TextStyle(color: Colors.grey),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
ElevatedButton(
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
backgroundColor: AppPalette.ink,
|
||||||
|
foregroundColor: AppPalette.paper,
|
||||||
|
),
|
||||||
|
onPressed: () => _respondJoinRequest(
|
||||||
|
r['requestId'] as int,
|
||||||
|
true,
|
||||||
|
),
|
||||||
|
child: const Text('허용'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
width: 4,
|
||||||
|
height: 16,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: AppPalette.ink,
|
||||||
|
borderRadius: BorderRadius.circular(2),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
const Text(
|
||||||
|
'오늘 출석 현황',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: AppPalette.ink,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
GridView.builder(
|
||||||
|
shrinkWrap: true,
|
||||||
|
physics: const NeverScrollableScrollPhysics(),
|
||||||
|
gridDelegate:
|
||||||
|
const SliverGridDelegateWithMaxCrossAxisExtent(
|
||||||
|
maxCrossAxisExtent: 150,
|
||||||
|
mainAxisSpacing: 10,
|
||||||
|
crossAxisSpacing: 10,
|
||||||
|
mainAxisExtent: 96,
|
||||||
|
),
|
||||||
|
itemCount: members.length,
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final m = members[index];
|
||||||
|
final attended = m['attendedToday'] == true;
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.all(10),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: attended
|
||||||
|
? AppPalette.ink
|
||||||
|
: AppPalette.paper,
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
border: Border.all(
|
||||||
|
color: attended
|
||||||
|
? AppPalette.ink
|
||||||
|
: AppPalette.sage,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
attended
|
||||||
|
? Icons.check_circle_rounded
|
||||||
|
: Icons.radio_button_unchecked_rounded,
|
||||||
|
color: attended
|
||||||
|
? Colors.white
|
||||||
|
: Colors.grey[350],
|
||||||
|
size: 18,
|
||||||
|
),
|
||||||
|
const Spacer(),
|
||||||
|
Text(
|
||||||
|
'${m['name']}',
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: TextStyle(
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
fontSize: 13,
|
||||||
|
color: attended
|
||||||
|
? Colors.white
|
||||||
|
: AppPalette.ink,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (m['role'] == 'owner')
|
||||||
|
Text(
|
||||||
|
'그룹장',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 10,
|
||||||
|
color: attended
|
||||||
|
? Colors.white70
|
||||||
|
: Colors.grey[500],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
Text(
|
||||||
|
_formatMinutes(
|
||||||
|
(m['todaySeconds'] as num?)?.toInt() ?? 0,
|
||||||
|
),
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 11,
|
||||||
|
color: attended
|
||||||
|
? Colors.white70
|
||||||
|
: Colors.grey[500],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _statPill(String label, int count) {
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 16),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: AppPalette.paper,
|
||||||
|
borderRadius: BorderRadius.circular(28),
|
||||||
|
border: Border.all(color: AppPalette.sage),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
Text(label, style: TextStyle(fontSize: 12, color: Colors.grey[600])),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
'$count명',
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: AppPalette.ink,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,448 @@
|
|||||||
|
// 👥 백품타 그룹 스터디 목록 화면 (UI 전용). "실시간 출석 현황"과 같은 패밀리룩을 따른다.
|
||||||
|
// 서버 통신/상태는 lib/function/group_controller.dart가 담당한다.
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import '../function/group_controller.dart';
|
||||||
|
import '../theme/app_palette.dart';
|
||||||
|
import 'app_notice.dart';
|
||||||
|
import 'group_detail_screen.dart';
|
||||||
|
import 'launchpad_transition.dart';
|
||||||
|
import 'title_pill.dart';
|
||||||
|
|
||||||
|
class GroupListScreen extends StatefulWidget {
|
||||||
|
final String studentId;
|
||||||
|
final String studentName;
|
||||||
|
final int? grade;
|
||||||
|
|
||||||
|
const GroupListScreen({
|
||||||
|
super.key,
|
||||||
|
required this.studentId,
|
||||||
|
required this.studentName,
|
||||||
|
this.grade,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<GroupListScreen> createState() => _GroupListScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _GroupListScreenState extends State<GroupListScreen> {
|
||||||
|
late final GroupController _controller;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_controller = GroupController(
|
||||||
|
studentId: widget.studentId,
|
||||||
|
studentName: widget.studentName,
|
||||||
|
);
|
||||||
|
_controller.init();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_controller.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _showCreateGroupDialog() async {
|
||||||
|
final nameController = TextEditingController();
|
||||||
|
bool isPublic = false;
|
||||||
|
final result = await showDialog<bool>(
|
||||||
|
context: context,
|
||||||
|
builder: (dialogContext) => StatefulBuilder(
|
||||||
|
builder: (dialogContext, setDialogState) => AlertDialog(
|
||||||
|
title: const Text('그룹 만들기'),
|
||||||
|
content: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
TextField(
|
||||||
|
controller: nameController,
|
||||||
|
maxLength: 20,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: '그룹 이름',
|
||||||
|
border: OutlineInputBorder(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
SwitchListTile(
|
||||||
|
contentPadding: EdgeInsets.zero,
|
||||||
|
title: const Text('공개 그룹으로 만들기'),
|
||||||
|
subtitle: const Text(
|
||||||
|
'누구나 목록에서 보고 참여 신청할 수 있어요\n(그룹장이 허용해야 들어옵니다)',
|
||||||
|
style: TextStyle(fontSize: 12),
|
||||||
|
),
|
||||||
|
value: isPublic,
|
||||||
|
activeThumbColor: AppPalette.ink,
|
||||||
|
onChanged: (v) => setDialogState(() => isPublic = v),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(dialogContext, false),
|
||||||
|
child: const Text('취소', style: TextStyle(color: Colors.grey)),
|
||||||
|
),
|
||||||
|
ElevatedButton(
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
backgroundColor: AppPalette.ink,
|
||||||
|
foregroundColor: AppPalette.paper,
|
||||||
|
),
|
||||||
|
onPressed: () => Navigator.pop(dialogContext, true),
|
||||||
|
child: const Text('만들기'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (result != true || nameController.text.trim().isEmpty) return;
|
||||||
|
final (success, message) = await _controller.createGroup(
|
||||||
|
nameController.text.trim(),
|
||||||
|
isPublic: isPublic,
|
||||||
|
);
|
||||||
|
if (!mounted) return;
|
||||||
|
AppNotice.show(
|
||||||
|
context,
|
||||||
|
message,
|
||||||
|
icon: success ? Icons.check_circle_rounded : Icons.error_outline_rounded,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _respondInvite(int inviteId, bool accept) async {
|
||||||
|
final (_, message) = await _controller.respondInvite(inviteId, accept);
|
||||||
|
if (!mounted) return;
|
||||||
|
AppNotice.show(context, message);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _requestJoin(int groupId) async {
|
||||||
|
final (success, message) = await _controller.requestJoin(groupId);
|
||||||
|
if (!mounted) return;
|
||||||
|
AppNotice.show(
|
||||||
|
context,
|
||||||
|
message,
|
||||||
|
icon: success ? Icons.check_circle_rounded : Icons.error_outline_rounded,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _openGroup(dynamic group) async {
|
||||||
|
await pushLaunchpad(
|
||||||
|
context,
|
||||||
|
(context) => GroupDetailScreen(
|
||||||
|
groupId: group['groupId'] as int,
|
||||||
|
groupName: group['name'] as String,
|
||||||
|
studentId: widget.studentId,
|
||||||
|
studentName: widget.studentName,
|
||||||
|
grade: widget.grade,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
_controller.refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _sectionHeader(String title, {int? count}) {
|
||||||
|
return Row(
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
width: 4,
|
||||||
|
height: 16,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: AppPalette.ink,
|
||||||
|
borderRadius: BorderRadius.circular(2),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Text(
|
||||||
|
title,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: AppPalette.ink,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (count != null) ...[
|
||||||
|
const SizedBox(width: 6),
|
||||||
|
Text(
|
||||||
|
'$count개',
|
||||||
|
style: TextStyle(fontSize: 13, color: Colors.grey[500]),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return ListenableBuilder(
|
||||||
|
listenable: _controller,
|
||||||
|
builder: (context, _) {
|
||||||
|
return Scaffold(
|
||||||
|
backgroundColor: AppPalette.mist,
|
||||||
|
appBar: AppBar(
|
||||||
|
backgroundColor: Colors.transparent,
|
||||||
|
foregroundColor: AppPalette.ink,
|
||||||
|
elevation: 0,
|
||||||
|
),
|
||||||
|
floatingActionButton: FloatingActionButton.extended(
|
||||||
|
backgroundColor: AppPalette.ink,
|
||||||
|
foregroundColor: AppPalette.paper,
|
||||||
|
onPressed: _showCreateGroupDialog,
|
||||||
|
icon: const Icon(Icons.add_rounded),
|
||||||
|
label: const Text('그룹 만들기'),
|
||||||
|
),
|
||||||
|
body: _controller.isLoading
|
||||||
|
? const Center(child: CircularProgressIndicator())
|
||||||
|
: RefreshIndicator(
|
||||||
|
onRefresh: _controller.refresh,
|
||||||
|
child: ListView(
|
||||||
|
padding: const EdgeInsets.fromLTRB(16, 0, 16, 90),
|
||||||
|
children: [
|
||||||
|
const Center(child: TitlePill('그룹 스터디')),
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
if (_controller.invites.isNotEmpty) ...[
|
||||||
|
_sectionHeader(
|
||||||
|
'받은 그룹 초대',
|
||||||
|
count: _controller.invites.length,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
for (final inv in _controller.invites)
|
||||||
|
Container(
|
||||||
|
margin: const EdgeInsets.only(bottom: 8),
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 16,
|
||||||
|
vertical: 12,
|
||||||
|
),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: AppPalette.paper,
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
border: Border.all(color: AppPalette.sage),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
'${inv['groupName']}',
|
||||||
|
style: const TextStyle(
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => _respondInvite(
|
||||||
|
inv['inviteId'] as int,
|
||||||
|
false,
|
||||||
|
),
|
||||||
|
child: const Text(
|
||||||
|
'거절',
|
||||||
|
style: TextStyle(color: Colors.grey),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
ElevatedButton(
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
backgroundColor: AppPalette.ink,
|
||||||
|
foregroundColor: AppPalette.paper,
|
||||||
|
),
|
||||||
|
onPressed: () => _respondInvite(
|
||||||
|
inv['inviteId'] as int,
|
||||||
|
true,
|
||||||
|
),
|
||||||
|
child: const Text('참여'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
],
|
||||||
|
_sectionHeader('내 그룹', count: _controller.groups.length),
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
if (_controller.groups.isEmpty)
|
||||||
|
const Padding(
|
||||||
|
padding: EdgeInsets.symmetric(vertical: 12),
|
||||||
|
child: Text(
|
||||||
|
'아직 그룹이 없어요. 새 그룹을 만들거나 초대를 기다려보세요.',
|
||||||
|
style: TextStyle(color: Colors.grey),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
else
|
||||||
|
for (final g in _controller.groups)
|
||||||
|
GestureDetector(
|
||||||
|
onTap: () => _openGroup(g),
|
||||||
|
child: Container(
|
||||||
|
margin: const EdgeInsets.only(bottom: 8),
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 16,
|
||||||
|
vertical: 14,
|
||||||
|
),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: AppPalette.paper,
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
border: Border.all(color: AppPalette.sage),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: Colors.black.withValues(alpha: 0.03),
|
||||||
|
blurRadius: 12,
|
||||||
|
offset: const Offset(0, 4),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.all(8),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: AppPalette.ink.withValues(
|
||||||
|
alpha: 0.06,
|
||||||
|
),
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
),
|
||||||
|
child: const Icon(
|
||||||
|
Icons.groups_rounded,
|
||||||
|
color: AppPalette.ink,
|
||||||
|
size: 18,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment:
|
||||||
|
CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Flexible(
|
||||||
|
child: Text(
|
||||||
|
'${g['name']}',
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
fontSize: 15,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (g['isPublic'] == true) ...[
|
||||||
|
const SizedBox(width: 6),
|
||||||
|
const Icon(
|
||||||
|
Icons.public_rounded,
|
||||||
|
size: 14,
|
||||||
|
color: Colors.grey,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
if ((g['pendingRequestCount'] ??
|
||||||
|
0) >
|
||||||
|
0) ...[
|
||||||
|
const SizedBox(width: 6),
|
||||||
|
Container(
|
||||||
|
padding:
|
||||||
|
const EdgeInsets.symmetric(
|
||||||
|
horizontal: 6,
|
||||||
|
vertical: 1,
|
||||||
|
),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.red[50],
|
||||||
|
borderRadius:
|
||||||
|
BorderRadius.circular(20),
|
||||||
|
border: Border.all(
|
||||||
|
color: Colors.red,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
'대기 ${g['pendingRequestCount']}',
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 10,
|
||||||
|
color: Colors.red,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
'멤버 ${g['memberCount']}명'
|
||||||
|
'${g['isOwner'] == true ? ' · 그룹장' : ''}',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
color: Colors.grey[500],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const Icon(
|
||||||
|
Icons.chevron_right_rounded,
|
||||||
|
color: Colors.grey,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (_controller.publicGroups.isNotEmpty) ...[
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
_sectionHeader(
|
||||||
|
'공개 그룹',
|
||||||
|
count: _controller.publicGroups.length,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
for (final g in _controller.publicGroups)
|
||||||
|
Container(
|
||||||
|
margin: const EdgeInsets.only(bottom: 8),
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 16,
|
||||||
|
vertical: 12,
|
||||||
|
),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: AppPalette.paper,
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
border: Border.all(color: AppPalette.sage),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
const Icon(
|
||||||
|
Icons.public_rounded,
|
||||||
|
color: AppPalette.ink,
|
||||||
|
size: 18,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 10),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment:
|
||||||
|
CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'${g['name']}',
|
||||||
|
style: const TextStyle(
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
'${g['ownerName']} · 멤버 ${g['memberCount']}명',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
color: Colors.grey[500],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (g['requested'] == true)
|
||||||
|
Text(
|
||||||
|
'신청됨',
|
||||||
|
style: TextStyle(color: Colors.grey[500]),
|
||||||
|
)
|
||||||
|
else
|
||||||
|
ElevatedButton(
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
backgroundColor: AppPalette.ink,
|
||||||
|
foregroundColor: AppPalette.paper,
|
||||||
|
),
|
||||||
|
onPressed: () =>
|
||||||
|
_requestJoin(g['groupId'] as int),
|
||||||
|
child: const Text('참여 신청'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
// 🚀 맥북 런치패드처럼 "앞에서 확대되며 나타나고, 뒤는 블러 처리되는" 전환 효과.
|
||||||
|
// 기존의 좌우 슬라이드 전환을 대체한다. 두 가지 쓰임새가 있다:
|
||||||
|
// 1) launchpadPageRoute — 전체 화면 이동(다른 기능 타일 진입)용. 전환 중에만 블러가 보이고,
|
||||||
|
// 화면이 다 뜨면 원래 화면(대시보드)은 완전히 가려진다.
|
||||||
|
// 2) showLaunchpadMenu — 설정/로그아웃 같은 작은 메뉴 오버레이용. 메뉴가 떠 있는 동안
|
||||||
|
// 뒤에 있는 대시보드가 계속 뿌옇게 보인다. 바깥을 탭하면 닫힌다.
|
||||||
|
import 'dart:ui';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
class LaunchpadPageRoute<T> extends PageRouteBuilder<T> {
|
||||||
|
LaunchpadPageRoute({required WidgetBuilder builder})
|
||||||
|
: super(
|
||||||
|
transitionDuration: const Duration(milliseconds: 320),
|
||||||
|
reverseTransitionDuration: const Duration(milliseconds: 240),
|
||||||
|
pageBuilder: (context, animation, secondaryAnimation) =>
|
||||||
|
builder(context),
|
||||||
|
transitionsBuilder: (context, animation, secondaryAnimation, child) {
|
||||||
|
final curved = CurvedAnimation(
|
||||||
|
parent: animation,
|
||||||
|
curve: Curves.easeOutCubic,
|
||||||
|
reverseCurve: Curves.easeInCubic,
|
||||||
|
);
|
||||||
|
return AnimatedBuilder(
|
||||||
|
animation: curved,
|
||||||
|
child: child,
|
||||||
|
builder: (context, child) {
|
||||||
|
final t = curved.value;
|
||||||
|
return Stack(
|
||||||
|
children: [
|
||||||
|
// 뒤에 있던 화면(대시보드)을 전환하는 동안만 뿌옇게 보여준다.
|
||||||
|
Positioned.fill(
|
||||||
|
child: IgnorePointer(
|
||||||
|
child: BackdropFilter(
|
||||||
|
filter: ImageFilter.blur(
|
||||||
|
sigmaX: 22 * t,
|
||||||
|
sigmaY: 22 * t,
|
||||||
|
),
|
||||||
|
child: Container(
|
||||||
|
color: Colors.black.withValues(alpha: 0.12 * t),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Opacity(
|
||||||
|
opacity: t,
|
||||||
|
child: Transform.scale(
|
||||||
|
scale: 0.94 + 0.06 * t,
|
||||||
|
child: child,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 화면 전체 이동에 런치패드 전환 효과를 적용한다. MaterialPageRoute 대신 이걸 쓰면 됨.
|
||||||
|
Future<T?> pushLaunchpad<T>(BuildContext context, WidgetBuilder builder) {
|
||||||
|
return Navigator.push<T>(context, LaunchpadPageRoute<T>(builder: builder));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 설정/로그아웃 같은 작은 메뉴를 런치패드 스타일(확대+페이드, 배경은 계속 블러 유지)로 띄운다.
|
||||||
|
/// 바깥을 탭하면 닫힌다. builder는 화면 전체를 채우는 Align 등으로 메뉴 위치를 직접 잡아야 한다.
|
||||||
|
Future<T?> showLaunchpadMenu<T>({
|
||||||
|
required BuildContext context,
|
||||||
|
required WidgetBuilder builder,
|
||||||
|
}) {
|
||||||
|
return showGeneralDialog<T>(
|
||||||
|
context: context,
|
||||||
|
barrierDismissible: true,
|
||||||
|
barrierLabel: 'menu',
|
||||||
|
barrierColor: Colors.transparent,
|
||||||
|
transitionDuration: const Duration(milliseconds: 260),
|
||||||
|
pageBuilder: (context, animation, secondaryAnimation) => builder(context),
|
||||||
|
transitionBuilder: (context, animation, secondaryAnimation, child) {
|
||||||
|
final curved = CurvedAnimation(
|
||||||
|
parent: animation,
|
||||||
|
curve: Curves.easeOutCubic,
|
||||||
|
reverseCurve: Curves.easeInCubic,
|
||||||
|
);
|
||||||
|
return AnimatedBuilder(
|
||||||
|
animation: curved,
|
||||||
|
child: child,
|
||||||
|
builder: (context, child) {
|
||||||
|
final t = curved.value;
|
||||||
|
return Stack(
|
||||||
|
children: [
|
||||||
|
// 메뉴가 떠 있는 동안 계속 뿌옇게 — 기능 화면이랑 헷갈리지 않게 진하게.
|
||||||
|
Positioned.fill(
|
||||||
|
child: IgnorePointer(
|
||||||
|
child: BackdropFilter(
|
||||||
|
filter: ImageFilter.blur(sigmaX: 32 * t, sigmaY: 32 * t),
|
||||||
|
child: Container(
|
||||||
|
color: Colors.black.withValues(alpha: 0.28 * t),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Opacity(
|
||||||
|
opacity: t,
|
||||||
|
child: Transform.scale(
|
||||||
|
alignment: Alignment.topRight,
|
||||||
|
scale: 0.85 + 0.15 * t,
|
||||||
|
child: child,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,466 @@
|
|||||||
|
// 🔑 로그인 화면 (UI 전용). 입력폼/다이얼로그/화면 이동만 담당한다.
|
||||||
|
// 인증 로직/서버 통신은 lib/function/login_controller.dart가 담당한다.
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import '../config.dart' show schoolName;
|
||||||
|
import '../function/login_controller.dart';
|
||||||
|
import '../function/session_store.dart';
|
||||||
|
import '../theme/app_palette.dart';
|
||||||
|
import 'app_notice.dart';
|
||||||
|
import 'main_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();
|
||||||
|
bool _keepLoggedIn = true;
|
||||||
|
|
||||||
|
@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:
|
||||||
|
if (_keepLoggedIn) {
|
||||||
|
await SessionStore.save(
|
||||||
|
SavedSession(
|
||||||
|
userId: result.studentId!,
|
||||||
|
userName: result.name!,
|
||||||
|
role: 'student',
|
||||||
|
isDeviceMatched: true,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
await SessionStore.clear();
|
||||||
|
}
|
||||||
|
if (!mounted) return;
|
||||||
|
AppNotice.show(context, '개발자 최고 권한으로 로그인되었습니다.');
|
||||||
|
Navigator.pushReplacement(
|
||||||
|
context,
|
||||||
|
MaterialPageRoute(
|
||||||
|
builder: (context) => MainDashboard(
|
||||||
|
userId: result.studentId!,
|
||||||
|
userName: result.name!,
|
||||||
|
role: 'student',
|
||||||
|
isDeviceMatched: true,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case LoginOutcome.needsPasswordChange:
|
||||||
|
_showFirstLoginPasswordDialog(
|
||||||
|
result.studentId!,
|
||||||
|
result.name!,
|
||||||
|
result.role!,
|
||||||
|
result.isDeviceMatched,
|
||||||
|
result.grade,
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case LoginOutcome.success:
|
||||||
|
AppNotice.show(context, '${result.name}님 환영합니다!');
|
||||||
|
_navigateBasedOnRole(
|
||||||
|
result.role!,
|
||||||
|
result.studentId!,
|
||||||
|
result.name!,
|
||||||
|
result.isDeviceMatched,
|
||||||
|
result.grade,
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 🛠️ 초기 비밀번호 변경 팝업창 (기기 매칭 데이터 파라미터 추가)
|
||||||
|
void _showFirstLoginPasswordDialog(
|
||||||
|
String studentId,
|
||||||
|
String name,
|
||||||
|
String role,
|
||||||
|
bool isDeviceMatched,
|
||||||
|
int? grade,
|
||||||
|
) {
|
||||||
|
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: AppPalette.ink,
|
||||||
|
foregroundColor: Colors.white,
|
||||||
|
),
|
||||||
|
onPressed: () async {
|
||||||
|
String newPassword = newPwController.text.trim();
|
||||||
|
if (newPassword.isEmpty) {
|
||||||
|
AppNotice.show(context, '새 비밀번호를 입력해 주세요.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setDialogState(() => isUpdating = true);
|
||||||
|
|
||||||
|
final (success, message) = await _controller
|
||||||
|
.changePassword(
|
||||||
|
studentId: studentId,
|
||||||
|
newPassword: newPassword,
|
||||||
|
);
|
||||||
|
setDialogState(() => isUpdating = false);
|
||||||
|
|
||||||
|
if (success) {
|
||||||
|
Navigator.pop(dialogContext);
|
||||||
|
AppNotice.show(context, message);
|
||||||
|
_navigateBasedOnRole(
|
||||||
|
role,
|
||||||
|
studentId,
|
||||||
|
name,
|
||||||
|
isDeviceMatched,
|
||||||
|
grade,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
AppNotice.show(context, message);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
child: const Text('변경하고 시작하기'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 🆕 권한별 화면 이동시 기기 일치 여부 파라미터(`isDeviceMatched`) 수신 및 대시보드 전달
|
||||||
|
Future<void> _navigateBasedOnRole(
|
||||||
|
String role,
|
||||||
|
String studentId,
|
||||||
|
String name,
|
||||||
|
bool isDeviceMatched,
|
||||||
|
int? grade,
|
||||||
|
) async {
|
||||||
|
if (_keepLoggedIn) {
|
||||||
|
await SessionStore.save(
|
||||||
|
SavedSession(
|
||||||
|
userId: studentId,
|
||||||
|
userName: name,
|
||||||
|
role: role,
|
||||||
|
isDeviceMatched: isDeviceMatched,
|
||||||
|
grade: grade,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
await SessionStore.clear();
|
||||||
|
}
|
||||||
|
if (!mounted) return;
|
||||||
|
Navigator.pushReplacement(
|
||||||
|
context,
|
||||||
|
MaterialPageRoute(
|
||||||
|
builder: (context) => MainDashboard(
|
||||||
|
userId: studentId,
|
||||||
|
userName: name,
|
||||||
|
role: role,
|
||||||
|
isDeviceMatched: isDeviceMatched,
|
||||||
|
grade: grade,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
||||||
|
AppNotice.show(context, '비밀번호가 올바르지 않습니다.');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
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 {
|
||||||
|
AppNotice.show(context, '비밀번호가 올바르지 않습니다.');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
child: const Text('인증하기'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return ListenableBuilder(
|
||||||
|
listenable: _controller,
|
||||||
|
builder: (context, _) {
|
||||||
|
return Scaffold(
|
||||||
|
backgroundColor: AppPalette.mist,
|
||||||
|
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: AppPalette.ink),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Text(
|
||||||
|
'$schoolName 모니터',
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 26,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: AppPalette.ink,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
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: 8),
|
||||||
|
InkWell(
|
||||||
|
onTap: () =>
|
||||||
|
setState(() => _keepLoggedIn = !_keepLoggedIn),
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Checkbox(
|
||||||
|
value: _keepLoggedIn,
|
||||||
|
activeColor: AppPalette.ink,
|
||||||
|
onChanged: (value) =>
|
||||||
|
setState(() => _keepLoggedIn = value ?? true),
|
||||||
|
),
|
||||||
|
const Text(
|
||||||
|
'로그인 상태 유지',
|
||||||
|
style: TextStyle(color: AppPalette.ink),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
_controller.isLoading
|
||||||
|
? const CircularProgressIndicator()
|
||||||
|
: SizedBox(
|
||||||
|
width: double.infinity,
|
||||||
|
height: 55,
|
||||||
|
child: ElevatedButton(
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
backgroundColor: AppPalette.ink,
|
||||||
|
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: AppPalette.ink,
|
||||||
|
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 MainDashboard(role: 'teacher'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
TextButton.icon(
|
||||||
|
icon: const Icon(
|
||||||
|
Icons.settings,
|
||||||
|
size: 18,
|
||||||
|
color: Colors.grey,
|
||||||
|
),
|
||||||
|
label: const Text(
|
||||||
|
'관리자 간편인증',
|
||||||
|
style: TextStyle(color: Colors.grey),
|
||||||
|
),
|
||||||
|
onPressed: () => _showLegacyPasswordDialog(
|
||||||
|
'시스템 관리자',
|
||||||
|
'4936',
|
||||||
|
const MainDashboard(role: 'admin'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,273 @@
|
|||||||
|
// 📲 자습실 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';
|
||||||
|
import '../theme/app_palette.dart';
|
||||||
|
import 'app_notice.dart';
|
||||||
|
import 'title_pill.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;
|
||||||
|
AppNotice.show(context, text, color: color);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return ListenableBuilder(
|
||||||
|
listenable: _controller,
|
||||||
|
builder: (context, _) {
|
||||||
|
if (_controller.isWatching) {
|
||||||
|
return _buildPocketWatchScreen();
|
||||||
|
}
|
||||||
|
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(
|
||||||
|
backgroundColor: Colors.transparent,
|
||||||
|
foregroundColor: AppPalette.ink,
|
||||||
|
elevation: 0,
|
||||||
|
centerTitle: true,
|
||||||
|
title: const TitlePill("자습실 NFC 출석체크"),
|
||||||
|
),
|
||||||
|
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),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
// 🏷️ (관리자용) 주머니 NFC 스티커 초기 설정 화면 (UI 전용). 빈 태그에 "POCKET_번호"를 쓰는
|
||||||
|
// 버튼/입력폼을 그린다. NFC 세션/서버 통신은 lib/function/nfc_tag_writer_controller.dart가 담당한다.
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import '../function/nfc_tag_writer_controller.dart';
|
||||||
|
import '../theme/app_palette.dart';
|
||||||
|
import 'app_notice.dart';
|
||||||
|
import 'title_pill.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;
|
||||||
|
AppNotice.show(
|
||||||
|
context,
|
||||||
|
text,
|
||||||
|
color: isError ? Colors.red[700] : Colors.green[700],
|
||||||
|
icon: isError ? Icons.error_outline_rounded : Icons.check_circle_rounded,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return ListenableBuilder(
|
||||||
|
listenable: _controller,
|
||||||
|
builder: (context, _) {
|
||||||
|
final bool isWriting = _controller.isWriting;
|
||||||
|
return Scaffold(
|
||||||
|
backgroundColor: AppPalette.mist,
|
||||||
|
appBar: AppBar(
|
||||||
|
backgroundColor: Colors.transparent,
|
||||||
|
foregroundColor: AppPalette.ink,
|
||||||
|
elevation: 0,
|
||||||
|
centerTitle: true,
|
||||||
|
title: const TitlePill("NFC 주머니 태그 쓰기"),
|
||||||
|
),
|
||||||
|
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 : AppPalette.ink,
|
||||||
|
),
|
||||||
|
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: AppPalette.ink,
|
||||||
|
foregroundColor: AppPalette.paper,
|
||||||
|
),
|
||||||
|
onPressed: isWriting
|
||||||
|
? null
|
||||||
|
: () => _controller.startWriteSession(
|
||||||
|
_numberController.text.trim(),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
isWriting ? "태그를 기다리는 중..." : "쓰기 시작",
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,415 @@
|
|||||||
|
// 📅 백품타 출석 달력 화면 (UI 전용) - 개인 연속출석(스트릭) + 관리자 이벤트 달력.
|
||||||
|
// "실시간 출석 현황"과 같은 패밀리룩(제목 알약 + 필 통계 + 둥근 카드)을 따른다.
|
||||||
|
// 서버 통신/상태는 lib/function/study_calendar_controller.dart가 담당한다.
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import '../function/study_calendar_controller.dart';
|
||||||
|
import '../theme/app_palette.dart';
|
||||||
|
import 'app_notice.dart';
|
||||||
|
import 'title_pill.dart';
|
||||||
|
|
||||||
|
class StudyCalendarScreen extends StatefulWidget {
|
||||||
|
final String studentId;
|
||||||
|
final bool canManageEvents;
|
||||||
|
|
||||||
|
const StudyCalendarScreen({
|
||||||
|
super.key,
|
||||||
|
required this.studentId,
|
||||||
|
this.canManageEvents = false,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<StudyCalendarScreen> createState() => _StudyCalendarScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _StudyCalendarScreenState extends State<StudyCalendarScreen> {
|
||||||
|
late final StudyCalendarController _controller;
|
||||||
|
static const _weekdayLabels = ['일', '월', '화', '수', '목', '금', '토'];
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_controller = StudyCalendarController(studentId: widget.studentId);
|
||||||
|
_controller.init();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_controller.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
String _dateKey(DateTime d) =>
|
||||||
|
'${d.year.toString().padLeft(4, '0')}-${d.month.toString().padLeft(2, '0')}-${d.day.toString().padLeft(2, '0')}';
|
||||||
|
|
||||||
|
Future<void> _onDayTap(DateTime day) async {
|
||||||
|
final key = _dateKey(day);
|
||||||
|
final event = _controller.events[key];
|
||||||
|
|
||||||
|
if (!widget.canManageEvents) {
|
||||||
|
if (event != null) {
|
||||||
|
await showDialog<void>(
|
||||||
|
context: context,
|
||||||
|
builder: (dialogContext) => AlertDialog(
|
||||||
|
title: Text(event['title'] ?? ''),
|
||||||
|
content: Text(
|
||||||
|
(event['description'] ?? '').toString().isEmpty
|
||||||
|
? '등록된 설명이 없어요.'
|
||||||
|
: event['description'],
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(dialogContext),
|
||||||
|
child: const Text('닫기'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final titleController = TextEditingController(text: event?['title'] ?? '');
|
||||||
|
final descController = TextEditingController(
|
||||||
|
text: event?['description'] ?? '',
|
||||||
|
);
|
||||||
|
|
||||||
|
await showModalBottomSheet<void>(
|
||||||
|
context: context,
|
||||||
|
isScrollControlled: true,
|
||||||
|
backgroundColor: AppPalette.paper,
|
||||||
|
shape: const RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
|
||||||
|
),
|
||||||
|
builder: (sheetContext) {
|
||||||
|
return Padding(
|
||||||
|
padding: EdgeInsets.only(
|
||||||
|
left: 20,
|
||||||
|
right: 20,
|
||||||
|
top: 20,
|
||||||
|
bottom: MediaQuery.of(sheetContext).viewInsets.bottom + 20,
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'$key 이벤트',
|
||||||
|
style: const TextStyle(
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
fontSize: 16,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
TextField(
|
||||||
|
controller: titleController,
|
||||||
|
maxLength: 30,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
hintText: '제목 (예: 기프티콘 이벤트)',
|
||||||
|
filled: true,
|
||||||
|
fillColor: AppPalette.mist,
|
||||||
|
border: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
borderSide: BorderSide.none,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
TextField(
|
||||||
|
controller: descController,
|
||||||
|
maxLength: 100,
|
||||||
|
maxLines: 3,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
hintText: '설명 (선택)',
|
||||||
|
filled: true,
|
||||||
|
fillColor: AppPalette.mist,
|
||||||
|
border: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
borderSide: BorderSide.none,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
if (event != null)
|
||||||
|
Expanded(
|
||||||
|
child: OutlinedButton(
|
||||||
|
onPressed: () async {
|
||||||
|
final (success, message) = await _controller
|
||||||
|
.deleteEvent(key);
|
||||||
|
if (sheetContext.mounted) {
|
||||||
|
Navigator.of(sheetContext).pop();
|
||||||
|
}
|
||||||
|
if (!mounted) return;
|
||||||
|
AppNotice.show(context, message);
|
||||||
|
},
|
||||||
|
child: const Text(
|
||||||
|
'삭제',
|
||||||
|
style: TextStyle(color: Colors.red),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (event != null) const SizedBox(width: 8),
|
||||||
|
Expanded(
|
||||||
|
child: ElevatedButton(
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
backgroundColor: AppPalette.ink,
|
||||||
|
foregroundColor: AppPalette.paper,
|
||||||
|
),
|
||||||
|
onPressed: () async {
|
||||||
|
if (titleController.text.trim().isEmpty) return;
|
||||||
|
final (success, message) = await _controller.saveEvent(
|
||||||
|
date: key,
|
||||||
|
title: titleController.text,
|
||||||
|
description: descController.text,
|
||||||
|
createdBy: widget.studentId,
|
||||||
|
);
|
||||||
|
if (sheetContext.mounted) {
|
||||||
|
Navigator.of(sheetContext).pop();
|
||||||
|
}
|
||||||
|
if (!mounted) return;
|
||||||
|
AppNotice.show(
|
||||||
|
context,
|
||||||
|
message,
|
||||||
|
icon: success
|
||||||
|
? Icons.check_circle_rounded
|
||||||
|
: Icons.error_outline_rounded,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
child: const Text('저장'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 넓은 화면(웹 데스크톱)에서 내용이 양옆으로 늘어지지 않게 가운데 최대 440 폭으로 맞춘다.
|
||||||
|
double _sidePad(BuildContext context) {
|
||||||
|
final width = MediaQuery.of(context).size.width;
|
||||||
|
return ((width - 440) / 2).clamp(16.0, double.infinity);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return ListenableBuilder(
|
||||||
|
listenable: _controller,
|
||||||
|
builder: (context, _) {
|
||||||
|
final month = _controller.visibleMonth;
|
||||||
|
final firstOfMonth = DateTime(month.year, month.month, 1);
|
||||||
|
final daysInMonth = DateTime(month.year, month.month + 1, 0).day;
|
||||||
|
final leadingBlanks = firstOfMonth.weekday % 7;
|
||||||
|
final today = DateTime.now();
|
||||||
|
|
||||||
|
return Scaffold(
|
||||||
|
backgroundColor: AppPalette.mist,
|
||||||
|
appBar: AppBar(
|
||||||
|
backgroundColor: Colors.transparent,
|
||||||
|
foregroundColor: AppPalette.ink,
|
||||||
|
elevation: 0,
|
||||||
|
),
|
||||||
|
body: _controller.isLoading
|
||||||
|
? const Center(child: CircularProgressIndicator())
|
||||||
|
: ListView(
|
||||||
|
padding: EdgeInsets.fromLTRB(
|
||||||
|
_sidePad(context),
|
||||||
|
0,
|
||||||
|
_sidePad(context),
|
||||||
|
16,
|
||||||
|
),
|
||||||
|
children: [
|
||||||
|
const Center(child: TitlePill('출석 달력')),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: _statPill('연속 출석', _controller.currentStreak),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(
|
||||||
|
child: _statPill('최장 기록', _controller.longestStreak),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
children: [
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.chevron_left_rounded),
|
||||||
|
onPressed: _controller.goToPreviousMonth,
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
'${month.year}년 ${month.month}월',
|
||||||
|
style: const TextStyle(
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
fontSize: 16,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.chevron_right_rounded),
|
||||||
|
onPressed: _controller.goToNextMonth,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
for (final label in _weekdayLabels)
|
||||||
|
Expanded(
|
||||||
|
child: Center(
|
||||||
|
child: Text(
|
||||||
|
label,
|
||||||
|
style: TextStyle(
|
||||||
|
color: Colors.grey[500],
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
GridView.builder(
|
||||||
|
shrinkWrap: true,
|
||||||
|
physics: const NeverScrollableScrollPhysics(),
|
||||||
|
gridDelegate:
|
||||||
|
const SliverGridDelegateWithFixedCrossAxisCount(
|
||||||
|
crossAxisCount: 7,
|
||||||
|
mainAxisSpacing: 4,
|
||||||
|
crossAxisSpacing: 4,
|
||||||
|
childAspectRatio: 1.0,
|
||||||
|
),
|
||||||
|
itemCount: leadingBlanks + daysInMonth,
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
if (index < leadingBlanks) {
|
||||||
|
return const SizedBox.shrink();
|
||||||
|
}
|
||||||
|
final day = index - leadingBlanks + 1;
|
||||||
|
final date = DateTime(month.year, month.month, day);
|
||||||
|
final key = _dateKey(date);
|
||||||
|
final studied = _controller.studiedDates.contains(key);
|
||||||
|
final event = _controller.events[key];
|
||||||
|
final isToday =
|
||||||
|
date.year == today.year &&
|
||||||
|
date.month == today.month &&
|
||||||
|
date.day == today.day;
|
||||||
|
|
||||||
|
return GestureDetector(
|
||||||
|
onTap: () => _onDayTap(date),
|
||||||
|
child: Container(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: studied
|
||||||
|
? AppPalette.ink
|
||||||
|
: AppPalette.paper,
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
border: Border.all(
|
||||||
|
color: isToday
|
||||||
|
? AppPalette.ink
|
||||||
|
: AppPalette.sage,
|
||||||
|
width: isToday ? 2 : 1,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Stack(
|
||||||
|
children: [
|
||||||
|
Center(
|
||||||
|
child: Text(
|
||||||
|
'$day',
|
||||||
|
style: TextStyle(
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: studied
|
||||||
|
? Colors.white
|
||||||
|
: AppPalette.ink,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (event != null)
|
||||||
|
Positioned(
|
||||||
|
top: 3,
|
||||||
|
right: 3,
|
||||||
|
child: Icon(
|
||||||
|
Icons.card_giftcard_rounded,
|
||||||
|
size: 12,
|
||||||
|
color: studied
|
||||||
|
? Colors.white
|
||||||
|
: Colors.orange,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
_legendDot(AppPalette.ink, '공부한 날'),
|
||||||
|
const SizedBox(width: 16),
|
||||||
|
_legendIcon(
|
||||||
|
Icons.card_giftcard_rounded,
|
||||||
|
Colors.orange,
|
||||||
|
'이벤트',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _statPill(String label, int count) {
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 16),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: AppPalette.paper,
|
||||||
|
borderRadius: BorderRadius.circular(28),
|
||||||
|
border: Border.all(color: AppPalette.sage),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
Text(label, style: TextStyle(fontSize: 12, color: Colors.grey[600])),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
'$count일',
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: AppPalette.ink,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _legendDot(Color color, String label) {
|
||||||
|
return Row(
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
width: 12,
|
||||||
|
height: 12,
|
||||||
|
decoration: BoxDecoration(color: color, shape: BoxShape.circle),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 6),
|
||||||
|
Text(label, style: TextStyle(fontSize: 12, color: Colors.grey[600])),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _legendIcon(IconData icon, Color color, String label) {
|
||||||
|
return Row(
|
||||||
|
children: [
|
||||||
|
Icon(icon, size: 14, color: color),
|
||||||
|
const SizedBox(width: 6),
|
||||||
|
Text(label, style: TextStyle(fontSize: 12, color: Colors.grey[600])),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,324 @@
|
|||||||
|
// 🍅 뽀모도로 루틴 공유 화면 (UI 전용). 다른 학생들이 올린 공부/휴식 시간 조합을 보고
|
||||||
|
// 그대로 적용하거나, 내가 쓰는 조합을 이름 붙여 공유할 수 있다.
|
||||||
|
// 서버 통신/상태는 lib/function/study_preset_controller.dart가 담당한다.
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import '../function/study_preset_controller.dart';
|
||||||
|
import '../theme/app_palette.dart';
|
||||||
|
import 'app_notice.dart';
|
||||||
|
import 'title_pill.dart';
|
||||||
|
|
||||||
|
class StudyPresetScreen extends StatefulWidget {
|
||||||
|
final String studentId;
|
||||||
|
final String studentName;
|
||||||
|
final int initialStudyMinutes;
|
||||||
|
final int initialBreakMinutes;
|
||||||
|
|
||||||
|
const StudyPresetScreen({
|
||||||
|
super.key,
|
||||||
|
required this.studentId,
|
||||||
|
required this.studentName,
|
||||||
|
required this.initialStudyMinutes,
|
||||||
|
required this.initialBreakMinutes,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<StudyPresetScreen> createState() => _StudyPresetScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _StudyPresetScreenState extends State<StudyPresetScreen> {
|
||||||
|
late final StudyPresetController _controller;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_controller = StudyPresetController(
|
||||||
|
studentId: widget.studentId,
|
||||||
|
studentName: widget.studentName,
|
||||||
|
);
|
||||||
|
_controller.init();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_controller.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _apply(dynamic preset) async {
|
||||||
|
await _controller.markUsed(preset['id'] as int);
|
||||||
|
if (!mounted) return;
|
||||||
|
Navigator.of(context).pop({
|
||||||
|
'studyMinutes': preset['studyMinutes'] as int,
|
||||||
|
'breakMinutes': preset['breakMinutes'] as int,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _delete(int presetId) async {
|
||||||
|
final (_, message) = await _controller.delete(presetId);
|
||||||
|
if (!mounted) return;
|
||||||
|
AppNotice.show(context, message);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _showShareDialog() async {
|
||||||
|
final titleController = TextEditingController();
|
||||||
|
int studyMinutes = widget.initialStudyMinutes;
|
||||||
|
int breakMinutes = widget.initialBreakMinutes;
|
||||||
|
|
||||||
|
await showModalBottomSheet<void>(
|
||||||
|
context: context,
|
||||||
|
isScrollControlled: true,
|
||||||
|
backgroundColor: AppPalette.paper,
|
||||||
|
shape: const RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
|
||||||
|
),
|
||||||
|
builder: (sheetContext) {
|
||||||
|
return StatefulBuilder(
|
||||||
|
builder: (sheetContext, setSheetState) {
|
||||||
|
return Padding(
|
||||||
|
padding: EdgeInsets.only(
|
||||||
|
left: 20,
|
||||||
|
right: 20,
|
||||||
|
top: 20,
|
||||||
|
bottom: MediaQuery.of(sheetContext).viewInsets.bottom + 20,
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
const Text(
|
||||||
|
'내 루틴 공유하기',
|
||||||
|
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
TextField(
|
||||||
|
controller: titleController,
|
||||||
|
maxLength: 30,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
hintText: '루틴 이름 (예: 불태우는 금요일)',
|
||||||
|
filled: true,
|
||||||
|
fillColor: AppPalette.mist,
|
||||||
|
border: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
borderSide: BorderSide.none,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
_minuteRow(
|
||||||
|
label: '공부 시간',
|
||||||
|
minutes: studyMinutes,
|
||||||
|
onChanged: (v) => setSheetState(() => studyMinutes = v),
|
||||||
|
),
|
||||||
|
_minuteRow(
|
||||||
|
label: '휴식 시간',
|
||||||
|
minutes: breakMinutes,
|
||||||
|
onChanged: (v) => setSheetState(() => breakMinutes = v),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
ElevatedButton(
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
backgroundColor: AppPalette.ink,
|
||||||
|
foregroundColor: AppPalette.paper,
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 14),
|
||||||
|
),
|
||||||
|
onPressed: () async {
|
||||||
|
final (success, message) = await _controller.share(
|
||||||
|
title: titleController.text,
|
||||||
|
studyMinutes: studyMinutes,
|
||||||
|
breakMinutes: breakMinutes,
|
||||||
|
);
|
||||||
|
if (sheetContext.mounted) {
|
||||||
|
Navigator.of(sheetContext).pop();
|
||||||
|
}
|
||||||
|
if (!mounted) return;
|
||||||
|
AppNotice.show(
|
||||||
|
context,
|
||||||
|
message,
|
||||||
|
icon: success
|
||||||
|
? Icons.check_circle_rounded
|
||||||
|
: Icons.error_outline_rounded,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
child: const Text('공유하기'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _minuteRow({
|
||||||
|
required String label,
|
||||||
|
required int minutes,
|
||||||
|
required ValueChanged<int> onChanged,
|
||||||
|
}) {
|
||||||
|
return Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
label,
|
||||||
|
style: const TextStyle(fontWeight: FontWeight.w600),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.remove_circle_outline_rounded),
|
||||||
|
onPressed: minutes > 5 ? () => onChanged(minutes - 5) : null,
|
||||||
|
),
|
||||||
|
SizedBox(
|
||||||
|
width: 48,
|
||||||
|
child: Text(
|
||||||
|
'$minutes분',
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: const TextStyle(fontWeight: FontWeight.bold),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.add_circle_outline_rounded),
|
||||||
|
onPressed: minutes < 120 ? () => onChanged(minutes + 5) : null,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return ListenableBuilder(
|
||||||
|
listenable: _controller,
|
||||||
|
builder: (context, _) {
|
||||||
|
final presets = _controller.presets;
|
||||||
|
return Scaffold(
|
||||||
|
backgroundColor: AppPalette.mist,
|
||||||
|
appBar: AppBar(
|
||||||
|
backgroundColor: Colors.transparent,
|
||||||
|
foregroundColor: AppPalette.ink,
|
||||||
|
elevation: 0,
|
||||||
|
centerTitle: true,
|
||||||
|
title: const TitlePill('뽀모도로 루틴 공유'),
|
||||||
|
),
|
||||||
|
floatingActionButton: FloatingActionButton.extended(
|
||||||
|
backgroundColor: AppPalette.ink,
|
||||||
|
foregroundColor: AppPalette.paper,
|
||||||
|
onPressed: _showShareDialog,
|
||||||
|
icon: const Icon(Icons.ios_share_rounded),
|
||||||
|
label: const Text('내 루틴 공유'),
|
||||||
|
),
|
||||||
|
body: Column(
|
||||||
|
children: [
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
_sortChip('popular', '인기순'),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
_sortChip('recent', '최신순'),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: _controller.isLoading
|
||||||
|
? const Center(child: CircularProgressIndicator())
|
||||||
|
: presets.isEmpty
|
||||||
|
? const Center(
|
||||||
|
child: Text(
|
||||||
|
'아직 공유된 루틴이 없어요.\n첫 루틴을 공유해보세요.',
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: TextStyle(color: Colors.grey),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: ListView.builder(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 16,
|
||||||
|
vertical: 8,
|
||||||
|
),
|
||||||
|
itemCount: presets.length,
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final preset = presets[index];
|
||||||
|
final bool isMine =
|
||||||
|
preset['studentId'] == widget.studentId;
|
||||||
|
return Container(
|
||||||
|
margin: const EdgeInsets.only(bottom: 10),
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: AppPalette.paper,
|
||||||
|
borderRadius: BorderRadius.circular(14),
|
||||||
|
border: Border.all(color: AppPalette.sage),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment:
|
||||||
|
CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
preset['title'] ?? '',
|
||||||
|
style: const TextStyle(
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
fontSize: 15,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
'공부 ${preset['studyMinutes']}분 · 휴식 ${preset['breakMinutes']}분',
|
||||||
|
style: const TextStyle(
|
||||||
|
color: AppPalette.ink,
|
||||||
|
fontSize: 13,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
'${preset['studentName'] ?? ''} · 사용 ${preset['useCount'] ?? 0}회',
|
||||||
|
style: const TextStyle(
|
||||||
|
color: Colors.grey,
|
||||||
|
fontSize: 11.5,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (isMine)
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(
|
||||||
|
Icons.delete_outline_rounded,
|
||||||
|
color: Colors.grey,
|
||||||
|
),
|
||||||
|
onPressed: () =>
|
||||||
|
_delete(preset['id'] as int),
|
||||||
|
),
|
||||||
|
ElevatedButton(
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
backgroundColor: AppPalette.ink,
|
||||||
|
foregroundColor: AppPalette.paper,
|
||||||
|
),
|
||||||
|
onPressed: () => _apply(preset),
|
||||||
|
child: const Text('적용'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _sortChip(String value, String label) {
|
||||||
|
final bool selected = _controller.sort == value;
|
||||||
|
return ChoiceChip(
|
||||||
|
label: Text(label),
|
||||||
|
selected: selected,
|
||||||
|
onSelected: (_) => _controller.setSort(value),
|
||||||
|
selectedColor: AppPalette.ink,
|
||||||
|
labelStyle: TextStyle(
|
||||||
|
color: selected ? Colors.white : AppPalette.ink,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,224 @@
|
|||||||
|
// 🏆 학년별 공부시간 랭킹 화면 (UI 전용).
|
||||||
|
// 서버 통신/상태는 lib/function/study_ranking_controller.dart가 담당한다.
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import '../function/study_ranking_controller.dart';
|
||||||
|
import '../theme/app_palette.dart';
|
||||||
|
import 'title_pill.dart';
|
||||||
|
|
||||||
|
class StudyRankingScreen extends StatefulWidget {
|
||||||
|
final int grade;
|
||||||
|
final String myStudentId;
|
||||||
|
|
||||||
|
const StudyRankingScreen({
|
||||||
|
super.key,
|
||||||
|
required this.grade,
|
||||||
|
required this.myStudentId,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<StudyRankingScreen> createState() => _StudyRankingScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _StudyRankingScreenState extends State<StudyRankingScreen> {
|
||||||
|
late final StudyRankingController _controller;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_controller = StudyRankingController(grade: widget.grade);
|
||||||
|
_controller.init();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_controller.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
String _formatHM(int totalSeconds) {
|
||||||
|
final h = totalSeconds ~/ 3600;
|
||||||
|
final m = (totalSeconds % 3600) ~/ 60;
|
||||||
|
if (h > 0) return '$h시간 $m분';
|
||||||
|
return '$m분';
|
||||||
|
}
|
||||||
|
|
||||||
|
Color? _rankColor(int rank) {
|
||||||
|
switch (rank) {
|
||||||
|
case 1:
|
||||||
|
return const Color(0xFFC9A227); // 금
|
||||||
|
case 2:
|
||||||
|
return const Color(0xFF9AA0A6); // 은
|
||||||
|
case 3:
|
||||||
|
return const Color(0xFFB07A4B); // 동
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return ListenableBuilder(
|
||||||
|
listenable: _controller,
|
||||||
|
builder: (context, _) {
|
||||||
|
final ranking = _controller.ranking;
|
||||||
|
return Scaffold(
|
||||||
|
backgroundColor: AppPalette.mist,
|
||||||
|
appBar: AppBar(
|
||||||
|
backgroundColor: Colors.transparent,
|
||||||
|
foregroundColor: AppPalette.ink,
|
||||||
|
elevation: 0,
|
||||||
|
centerTitle: true,
|
||||||
|
title: TitlePill(
|
||||||
|
_controller.scope == 'grade'
|
||||||
|
? '${widget.grade}학년 공부 랭킹'
|
||||||
|
: '전체 공부 랭킹',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
body: Column(
|
||||||
|
children: [
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(16, 8, 16, 0),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
_scopeChip('grade', '${widget.grade}학년'),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
_scopeChip('all', '전체랭킹'),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
_periodChip('today', '오늘'),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
_periodChip('week', '이번주'),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
_periodChip('total', '전체'),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: _controller.isLoading
|
||||||
|
? const Center(child: CircularProgressIndicator())
|
||||||
|
: ranking.isEmpty
|
||||||
|
? const Center(
|
||||||
|
child: Text(
|
||||||
|
'아직 기록이 없어요.\n공부 타이머를 눌러 1등에 도전해보세요.',
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: TextStyle(color: Colors.grey),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: ListView.builder(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 16,
|
||||||
|
vertical: 8,
|
||||||
|
),
|
||||||
|
itemCount: ranking.length,
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final r = ranking[index];
|
||||||
|
final bool isMe =
|
||||||
|
r['studentId'] == widget.myStudentId;
|
||||||
|
final int rank = (r['rank'] as num).toInt();
|
||||||
|
final medalColor = _rankColor(rank);
|
||||||
|
|
||||||
|
return Container(
|
||||||
|
margin: const EdgeInsets.only(bottom: 8),
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 16,
|
||||||
|
vertical: 12,
|
||||||
|
),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: isMe ? AppPalette.ink : AppPalette.paper,
|
||||||
|
borderRadius: BorderRadius.circular(14),
|
||||||
|
border: Border.all(
|
||||||
|
color: isMe ? AppPalette.ink : AppPalette.sage,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
SizedBox(
|
||||||
|
width: 32,
|
||||||
|
child: medalColor != null
|
||||||
|
? Icon(
|
||||||
|
Icons.emoji_events_rounded,
|
||||||
|
color: medalColor,
|
||||||
|
size: 22,
|
||||||
|
)
|
||||||
|
: Text(
|
||||||
|
'$rank',
|
||||||
|
style: TextStyle(
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: isMe
|
||||||
|
? Colors.white
|
||||||
|
: AppPalette.ink,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
r['name'] ?? '',
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: TextStyle(
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: isMe
|
||||||
|
? Colors.white
|
||||||
|
: AppPalette.ink,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
_formatHM((r['totalSeconds'] as num).toInt()),
|
||||||
|
style: TextStyle(
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: isMe ? Colors.white : AppPalette.ink,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _periodChip(String value, String label) {
|
||||||
|
final bool selected = _controller.period == value;
|
||||||
|
return Expanded(
|
||||||
|
child: ChoiceChip(
|
||||||
|
label: Center(child: Text(label)),
|
||||||
|
selected: selected,
|
||||||
|
onSelected: (_) => _controller.setPeriod(value),
|
||||||
|
selectedColor: AppPalette.ink,
|
||||||
|
labelStyle: TextStyle(
|
||||||
|
color: selected ? Colors.white : AppPalette.ink,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _scopeChip(String value, String label) {
|
||||||
|
final bool selected = _controller.scope == value;
|
||||||
|
return Expanded(
|
||||||
|
child: ChoiceChip(
|
||||||
|
label: Center(child: Text(label, style: const TextStyle(fontSize: 13))),
|
||||||
|
selected: selected,
|
||||||
|
onSelected: (_) => _controller.setScope(value),
|
||||||
|
selectedColor: AppPalette.ink,
|
||||||
|
backgroundColor: AppPalette.mist,
|
||||||
|
labelStyle: TextStyle(
|
||||||
|
color: selected ? Colors.white : Colors.grey[600],
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,527 @@
|
|||||||
|
// ⏱️ 우리 학교 전용 "백품타" 공부 타이머 화면 (UI 전용).
|
||||||
|
// 서버 통신/상태는 lib/function/study_timer_controller.dart가 담당한다.
|
||||||
|
import 'dart:async';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import '../function/browser_notification.dart';
|
||||||
|
import '../function/study_timer_controller.dart';
|
||||||
|
import '../theme/app_palette.dart';
|
||||||
|
import 'app_notice.dart';
|
||||||
|
import 'launchpad_transition.dart';
|
||||||
|
import 'study_preset_screen.dart';
|
||||||
|
import 'study_ranking_screen.dart';
|
||||||
|
import 'title_pill.dart';
|
||||||
|
|
||||||
|
class StudyTimerScreen extends StatefulWidget {
|
||||||
|
final String studentId;
|
||||||
|
final String studentName;
|
||||||
|
final int? grade;
|
||||||
|
|
||||||
|
const StudyTimerScreen({
|
||||||
|
super.key,
|
||||||
|
required this.studentId,
|
||||||
|
required this.studentName,
|
||||||
|
this.grade,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<StudyTimerScreen> createState() => _StudyTimerScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _StudyTimerScreenState extends State<StudyTimerScreen>
|
||||||
|
with WidgetsBindingObserver {
|
||||||
|
late final StudyTimerController _controller;
|
||||||
|
Timer? _awayTimer;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_controller = StudyTimerController(
|
||||||
|
studentId: widget.studentId,
|
||||||
|
studentName: widget.studentName,
|
||||||
|
);
|
||||||
|
_controller.onPhaseChanged = _handlePhaseChanged;
|
||||||
|
_controller.init();
|
||||||
|
WidgetsBinding.instance.addObserver(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _handlePhaseChanged(String message) {
|
||||||
|
if (!mounted) return;
|
||||||
|
AppNotice.show(context, message, icon: Icons.timer_rounded);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void didChangeAppLifecycleState(AppLifecycleState state) {
|
||||||
|
if (state == AppLifecycleState.resumed) {
|
||||||
|
// 🐛 [웹 탭 딜레이 버그 수정] 다른 창/탭에 갔다가 이 탭으로 돌아왔을 때(resumed) 밀린
|
||||||
|
// 시간을 바로 따라잡는다 - 백그라운드 탭에서는 브라우저가 타이머를 느리게 돌리기 때문.
|
||||||
|
_awayTimer?.cancel();
|
||||||
|
_controller.onAppResumed();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// 🔔 타이머가 켜져 있는데 탭이 백그라운드로 가면(다른 탭/창/앱으로 이동), 20초 뒤에도
|
||||||
|
// 계속 딴 데 가있으면 브라우저 알림으로 다시 공부하라고 알려준다.
|
||||||
|
final bool isAway =
|
||||||
|
state == AppLifecycleState.hidden ||
|
||||||
|
state == AppLifecycleState.inactive ||
|
||||||
|
state == AppLifecycleState.paused;
|
||||||
|
if (isAway && _controller.isRunning) {
|
||||||
|
_awayTimer?.cancel();
|
||||||
|
_awayTimer = Timer(const Duration(seconds: 20), () {
|
||||||
|
showBrowserNotification(
|
||||||
|
'백품타 - 공부 중이었잖아요!',
|
||||||
|
'타이머가 켜져 있어요. 다시 돌아와서 집중해볼까요?',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
WidgetsBinding.instance.removeObserver(this);
|
||||||
|
_awayTimer?.cancel();
|
||||||
|
_controller.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
String _formatHMS(int totalSeconds) {
|
||||||
|
final h = totalSeconds ~/ 3600;
|
||||||
|
final m = (totalSeconds % 3600) ~/ 60;
|
||||||
|
final s = totalSeconds % 60;
|
||||||
|
final hh = h.toString().padLeft(2, '0');
|
||||||
|
final mm = m.toString().padLeft(2, '0');
|
||||||
|
final ss = s.toString().padLeft(2, '0');
|
||||||
|
return '$hh:$mm:$ss';
|
||||||
|
}
|
||||||
|
|
||||||
|
String _formatMinSec(int totalSeconds) {
|
||||||
|
final s = totalSeconds < 0 ? 0 : totalSeconds;
|
||||||
|
final m = s ~/ 60;
|
||||||
|
final sec = s % 60;
|
||||||
|
return '$m:${sec.toString().padLeft(2, '0')}';
|
||||||
|
}
|
||||||
|
|
||||||
|
String _statusLabel(bool isRunning, bool isPaused) {
|
||||||
|
if (_controller.pomodoroEnabled && _controller.phase != null) {
|
||||||
|
return _controller.phase == PomodoroPhase.study
|
||||||
|
? '뽀모도로 - 공부 중이에요'
|
||||||
|
: '뽀모도로 - 쉬는 중이에요';
|
||||||
|
}
|
||||||
|
if (isRunning) return '지금 공부 중이에요';
|
||||||
|
if (isPaused) return '일시정지 중이에요';
|
||||||
|
return '공부를 시작해볼까요?';
|
||||||
|
}
|
||||||
|
|
||||||
|
String _formatHM(int totalSeconds) {
|
||||||
|
final h = totalSeconds ~/ 3600;
|
||||||
|
final m = (totalSeconds % 3600) ~/ 60;
|
||||||
|
if (h > 0) return '$h시간 $m분';
|
||||||
|
return '$m분';
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _runAction(
|
||||||
|
Future<(bool success, String message)> Function() action,
|
||||||
|
) async {
|
||||||
|
final (success, message) = await action();
|
||||||
|
if (!mounted) return;
|
||||||
|
AppNotice.show(
|
||||||
|
context,
|
||||||
|
message,
|
||||||
|
icon: success ? Icons.check_circle_rounded : Icons.error_outline_rounded,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _showPomodoroSettings() async {
|
||||||
|
bool enabled = _controller.pomodoroEnabled;
|
||||||
|
int studyMinutes = _controller.studyMinutes;
|
||||||
|
int breakMinutes = _controller.breakMinutes;
|
||||||
|
final bool canEdit = _controller.isIdle;
|
||||||
|
|
||||||
|
await showModalBottomSheet<void>(
|
||||||
|
context: context,
|
||||||
|
isScrollControlled: true,
|
||||||
|
backgroundColor: AppPalette.paper,
|
||||||
|
shape: const RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
|
||||||
|
),
|
||||||
|
builder: (sheetContext) {
|
||||||
|
return StatefulBuilder(
|
||||||
|
builder: (sheetContext, setSheetState) {
|
||||||
|
return Padding(
|
||||||
|
padding: EdgeInsets.only(
|
||||||
|
left: 20,
|
||||||
|
right: 20,
|
||||||
|
top: 20,
|
||||||
|
bottom: MediaQuery.of(sheetContext).viewInsets.bottom + 20,
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
const Expanded(
|
||||||
|
child: Text(
|
||||||
|
'뽀모도로 모드',
|
||||||
|
style: TextStyle(
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
fontSize: 16,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Switch(
|
||||||
|
value: enabled,
|
||||||
|
activeThumbColor: AppPalette.ink,
|
||||||
|
onChanged: canEdit
|
||||||
|
? (v) => setSheetState(() => enabled = v)
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const Text(
|
||||||
|
'공부/휴식 시간을 정해두면, 공부 시간이 끝났을 때 자동으로\n일시정지되고 휴식이 끝나면 자동으로 다시 시작해요.',
|
||||||
|
style: TextStyle(color: Colors.grey, fontSize: 12.5),
|
||||||
|
),
|
||||||
|
if (!canEdit) ...[
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
const Text(
|
||||||
|
'타이머가 멈춰있을 때만 설정을 바꿀 수 있어요.',
|
||||||
|
style: TextStyle(color: Colors.redAccent, fontSize: 12.5),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
const SizedBox(height: 18),
|
||||||
|
_minuteStepper(
|
||||||
|
label: '공부 시간',
|
||||||
|
minutes: studyMinutes,
|
||||||
|
enabled: canEdit,
|
||||||
|
onChanged: (v) => setSheetState(() => studyMinutes = v),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
_minuteStepper(
|
||||||
|
label: '휴식 시간',
|
||||||
|
minutes: breakMinutes,
|
||||||
|
enabled: canEdit,
|
||||||
|
onChanged: (v) => setSheetState(() => breakMinutes = v),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
OutlinedButton.icon(
|
||||||
|
icon: const Icon(Icons.groups_rounded, size: 18),
|
||||||
|
label: const Text('친구들이 공유한 루틴 보기 / 공유하기'),
|
||||||
|
onPressed: () async {
|
||||||
|
final result = await pushLaunchpad<Map<String, int>>(
|
||||||
|
sheetContext,
|
||||||
|
(context) => StudyPresetScreen(
|
||||||
|
studentId: widget.studentId,
|
||||||
|
studentName: widget.studentName,
|
||||||
|
initialStudyMinutes: studyMinutes,
|
||||||
|
initialBreakMinutes: breakMinutes,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (result == null) return;
|
||||||
|
if (!canEdit) {
|
||||||
|
if (sheetContext.mounted) {
|
||||||
|
AppNotice.show(
|
||||||
|
sheetContext,
|
||||||
|
'타이머가 멈춰있을 때만 루틴을 적용할 수 있어요.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSheetState(() {
|
||||||
|
studyMinutes = result['studyMinutes']!;
|
||||||
|
breakMinutes = result['breakMinutes']!;
|
||||||
|
enabled = true;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const SizedBox(height: 18),
|
||||||
|
ElevatedButton(
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
backgroundColor: AppPalette.ink,
|
||||||
|
foregroundColor: AppPalette.paper,
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 14),
|
||||||
|
),
|
||||||
|
onPressed: !canEdit
|
||||||
|
? null
|
||||||
|
: () async {
|
||||||
|
await _controller.setPomodoroSettings(
|
||||||
|
enabled: enabled,
|
||||||
|
studyMinutes: studyMinutes,
|
||||||
|
breakMinutes: breakMinutes,
|
||||||
|
);
|
||||||
|
if (sheetContext.mounted) {
|
||||||
|
Navigator.of(sheetContext).pop();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
child: const Text('저장'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _minuteStepper({
|
||||||
|
required String label,
|
||||||
|
required int minutes,
|
||||||
|
required bool enabled,
|
||||||
|
required ValueChanged<int> onChanged,
|
||||||
|
}) {
|
||||||
|
return Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
label,
|
||||||
|
style: const TextStyle(fontWeight: FontWeight.w600),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.remove_circle_outline_rounded),
|
||||||
|
onPressed: enabled && minutes > 5
|
||||||
|
? () => onChanged(minutes - 5)
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
SizedBox(
|
||||||
|
width: 48,
|
||||||
|
child: Text(
|
||||||
|
'$minutes분',
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: const TextStyle(fontWeight: FontWeight.bold),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.add_circle_outline_rounded),
|
||||||
|
onPressed: enabled && minutes < 120
|
||||||
|
? () => onChanged(minutes + 5)
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _openRanking() {
|
||||||
|
if (widget.grade == null) {
|
||||||
|
AppNotice.show(context, '학년 정보가 없어 랭킹을 볼 수 없어요.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
pushLaunchpad(
|
||||||
|
context,
|
||||||
|
(context) => StudyRankingScreen(
|
||||||
|
grade: widget.grade!,
|
||||||
|
myStudentId: widget.studentId,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return ListenableBuilder(
|
||||||
|
listenable: _controller,
|
||||||
|
builder: (context, _) {
|
||||||
|
final bool isRunning = _controller.isRunning;
|
||||||
|
final bool isPaused = _controller.isPaused;
|
||||||
|
return Scaffold(
|
||||||
|
backgroundColor: AppPalette.mist,
|
||||||
|
appBar: AppBar(
|
||||||
|
backgroundColor: Colors.transparent,
|
||||||
|
foregroundColor: AppPalette.ink,
|
||||||
|
elevation: 0,
|
||||||
|
centerTitle: true,
|
||||||
|
title: const TitlePill('백품타'),
|
||||||
|
actions: [
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.tune_rounded),
|
||||||
|
tooltip: '뽀모도로 설정',
|
||||||
|
onPressed: _showPomodoroSettings,
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.leaderboard_rounded),
|
||||||
|
tooltip: '학년 랭킹',
|
||||||
|
onPressed: _openRanking,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
body: _controller.isLoading
|
||||||
|
? const Center(child: CircularProgressIndicator())
|
||||||
|
: Center(
|
||||||
|
child: SingleChildScrollView(
|
||||||
|
padding: const EdgeInsets.all(24),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
_statusLabel(isRunning, isPaused),
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 15,
|
||||||
|
color: Colors.grey,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (_controller.pomodoroEnabled &&
|
||||||
|
_controller.phase != null) ...[
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
'${_controller.phase == PomodoroPhase.study ? '다음 휴식까지' : '다음 공부까지'} '
|
||||||
|
'${_formatMinSec(_controller.phaseSecondsLeft)} · '
|
||||||
|
'완료 ${_controller.completedCycles}회',
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 12.5,
|
||||||
|
color: Colors.grey,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Text(
|
||||||
|
_formatHMS(_controller.liveElapsedSeconds),
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 52,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: isPaused ? Colors.grey : AppPalette.ink,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 36),
|
||||||
|
_buildActionButtons(isRunning, isPaused),
|
||||||
|
const SizedBox(height: 40),
|
||||||
|
Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
_statBox(
|
||||||
|
'오늘',
|
||||||
|
_formatHM(_controller.todaySecondsLive),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
_statBox(
|
||||||
|
'이번주',
|
||||||
|
_formatHM(_controller.weekSecondsLive),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
_statBox(
|
||||||
|
'전체',
|
||||||
|
_formatHM(_controller.totalSecondsLive),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildActionButtons(bool isRunning, bool isPaused) {
|
||||||
|
if (!isRunning && !isPaused) {
|
||||||
|
return _circleButton(
|
||||||
|
size: 140,
|
||||||
|
icon: Icons.play_arrow_rounded,
|
||||||
|
label: _controller.pomodoroEnabled ? '뽀모도로 시작' : '공부 시작',
|
||||||
|
color: AppPalette.ink,
|
||||||
|
onTap: () {
|
||||||
|
requestNotificationPermission();
|
||||||
|
_runAction(_controller.start);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (_controller.pomodoroEnabled && _controller.phase != null) {
|
||||||
|
// 뽀모도로 자동 전환 중에는 일시정지/재개는 자동으로 일어나니 종료 버튼만 보여준다.
|
||||||
|
return _circleButton(
|
||||||
|
size: 140,
|
||||||
|
icon: Icons.stop_rounded,
|
||||||
|
label: '공부 종료',
|
||||||
|
color: Colors.red[400]!,
|
||||||
|
onTap: () => _runAction(_controller.stop),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
_circleButton(
|
||||||
|
size: 110,
|
||||||
|
icon: isPaused ? Icons.play_arrow_rounded : Icons.pause_rounded,
|
||||||
|
label: isPaused ? '재개' : '일시정지',
|
||||||
|
color: isPaused ? AppPalette.ink : Colors.amber[700]!,
|
||||||
|
onTap: () =>
|
||||||
|
_runAction(isPaused ? _controller.resume : _controller.pause),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 20),
|
||||||
|
_circleButton(
|
||||||
|
size: 110,
|
||||||
|
icon: Icons.stop_rounded,
|
||||||
|
label: '공부 종료',
|
||||||
|
color: Colors.red[400]!,
|
||||||
|
onTap: () => _runAction(_controller.stop),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _circleButton({
|
||||||
|
required double size,
|
||||||
|
required IconData icon,
|
||||||
|
required String label,
|
||||||
|
required Color color,
|
||||||
|
required VoidCallback onTap,
|
||||||
|
}) {
|
||||||
|
return GestureDetector(
|
||||||
|
onTap: _controller.isBusy ? null : onTap,
|
||||||
|
child: Container(
|
||||||
|
width: size,
|
||||||
|
height: size,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
color: color,
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: color.withValues(alpha: 0.3),
|
||||||
|
blurRadius: 20,
|
||||||
|
offset: const Offset(0, 8),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: Center(
|
||||||
|
child: _controller.isBusy
|
||||||
|
? const CircularProgressIndicator(color: Colors.white)
|
||||||
|
: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Icon(icon, color: Colors.white, size: size > 120 ? 36 : 28),
|
||||||
|
Text(
|
||||||
|
label,
|
||||||
|
style: TextStyle(
|
||||||
|
color: Colors.white,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
fontSize: size > 120 ? 14 : 12.5,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _statBox(String label, String value) {
|
||||||
|
return Container(
|
||||||
|
width: 100,
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 14),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: AppPalette.paper,
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
border: Border.all(color: AppPalette.sage),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
Text(label, style: TextStyle(color: Colors.grey[600], fontSize: 12)),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
value,
|
||||||
|
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 15),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,987 @@
|
|||||||
|
// 📋 실시간 출석 현황 화면 (UI 전용). 위젯 빌드/레이아웃/스타일만 담당하고,
|
||||||
|
// 서버 통신·상태·파생 로직은 lib/function/teacher_attendance_controller.dart가 담당한다.
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import '../function/teacher_attendance_controller.dart';
|
||||||
|
import '../theme/app_palette.dart';
|
||||||
|
import 'app_notice.dart';
|
||||||
|
import 'launchpad_transition.dart';
|
||||||
|
import 'title_pill.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;
|
||||||
|
AppNotice.show(context, 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: AppPalette.mist,
|
||||||
|
// 🪶 검정 배너 대신, 제목 알약은 "전체 학생 수" 버튼 바로 위(사이드바/모바일 요약 위)에 둔다.
|
||||||
|
// 나머지 기능은 전부 "자습실 시간 설정 메뉴"(런치패드 스타일 오버레이) 하나로 모은다.
|
||||||
|
// 폰에서는 사이드바가 없으니 여기 아이콘으로도 같은 메뉴를 열 수 있게 둔다.
|
||||||
|
appBar: AppBar(
|
||||||
|
backgroundColor: Colors.transparent,
|
||||||
|
foregroundColor: AppPalette.ink,
|
||||||
|
elevation: 0,
|
||||||
|
actions: [
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.more_horiz_rounded),
|
||||||
|
tooltip: '자습실 시간 설정 메뉴',
|
||||||
|
onPressed: _showTimeSettingsMenu,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
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: [
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
const TitlePill('실시간 출석 현황'),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16.0),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Expanded(child: _sidebarStatPill('전체 학생 수', total, 'ALL')),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(
|
||||||
|
child: _sidebarStatPill('출석 학생 수', checkedIn, 'CHECKED_IN'),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(child: _sidebarStatPill('미출석 학생수', absent, 'ABSENT')),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16.0),
|
||||||
|
child: _sidebarFilterButton(),
|
||||||
|
),
|
||||||
|
_buildPermissionBanner(),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
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: AppPalette.ink,
|
||||||
|
borderRadius: BorderRadius.circular(2),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Text(
|
||||||
|
group.key,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: AppPalette.ink,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
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] : AppPalette.paper,
|
||||||
|
borderRadius: BorderRadius.circular(20),
|
||||||
|
border: Border.all(
|
||||||
|
color: hasViolation ? Colors.red[300]! : AppPalette.sage,
|
||||||
|
width: hasViolation ? 1.5 : 1,
|
||||||
|
),
|
||||||
|
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: AppPalette.linen,
|
||||||
|
borderRadius: BorderRadius.circular(20),
|
||||||
|
border: Border.all(color: AppPalette.sage),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
student['pocketNumber'],
|
||||||
|
style: const TextStyle(
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: AppPalette.ink,
|
||||||
|
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)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// 🖥️ 데스크톱 레이아웃 (선생님이 교실 컴퓨터 브라우저로 접속했을 때)
|
||||||
|
// 왼쪽에 요약/필터 사이드바, 오른쪽에 좌석표처럼 촘촘한 학생 칸 그리드를 둬서
|
||||||
|
// 80명이 넘는 인원도 스크롤을 최소화하고 한눈에 볼 수 있게 한다.
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
Widget _buildDesktopBody(int total, int checkedIn, int absent) {
|
||||||
|
final students = _controller.filteredStudents;
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.all(24.0),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
_buildPermissionBanner(),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Expanded(
|
||||||
|
child: Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
_buildDesktopSidebar(total, checkedIn, absent),
|
||||||
|
const SizedBox(width: 20),
|
||||||
|
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: _buildDenseSeatGrid(students),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 🧾 왼쪽 요약/필터 사이드바. 인원 수 캡슐을 누르면 그 필터가 바로 적용된다.
|
||||||
|
Widget _buildDesktopSidebar(int total, int checkedIn, int absent) {
|
||||||
|
return SizedBox(
|
||||||
|
width: 200,
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
Center(child: const TitlePill('실시간 출석 현황')),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
_sidebarStatPill('전체 학생 수', total, 'ALL'),
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
_sidebarStatPill('출석 학생 수', checkedIn, 'CHECKED_IN'),
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
_sidebarStatPill('미출석 학생수', absent, 'ABSENT'),
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
_sidebarFilterButton(),
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
_sidebarTimeSettingsButton(),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _sidebarTimeSettingsButton() {
|
||||||
|
return InkWell(
|
||||||
|
onTap: _showTimeSettingsMenu,
|
||||||
|
borderRadius: BorderRadius.circular(28),
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 12),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: AppPalette.paper,
|
||||||
|
borderRadius: BorderRadius.circular(28),
|
||||||
|
border: Border.all(color: AppPalette.sage),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
const Icon(
|
||||||
|
Icons.access_time_rounded,
|
||||||
|
size: 18,
|
||||||
|
color: AppPalette.ink,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
const Flexible(
|
||||||
|
child: Text(
|
||||||
|
'자습실 시간 설정 메뉴',
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: TextStyle(
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: AppPalette.ink,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _sidebarStatPill(String label, int count, String filterValue) {
|
||||||
|
final bool selected = _controller.filterType == filterValue;
|
||||||
|
return InkWell(
|
||||||
|
onTap: () => _controller.setFilter(filterValue),
|
||||||
|
borderRadius: BorderRadius.circular(28),
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 16),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: selected ? AppPalette.ink : AppPalette.paper,
|
||||||
|
borderRadius: BorderRadius.circular(28),
|
||||||
|
border: Border.all(
|
||||||
|
color: selected ? AppPalette.ink : AppPalette.sage,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
label,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
color: selected ? Colors.white70 : Colors.grey[600],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
'$count명',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: selected ? Colors.white : AppPalette.ink,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _sidebarFilterButton() {
|
||||||
|
return InkWell(
|
||||||
|
onTap: _showGradeFilterMenu,
|
||||||
|
borderRadius: BorderRadius.circular(28),
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 12),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: AppPalette.paper,
|
||||||
|
borderRadius: BorderRadius.circular(28),
|
||||||
|
border: Border.all(color: AppPalette.sage),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
const Icon(
|
||||||
|
Icons.filter_list_rounded,
|
||||||
|
size: 18,
|
||||||
|
color: AppPalette.ink,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Flexible(
|
||||||
|
child: Text(
|
||||||
|
_gradeFilterLabel(),
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: AppPalette.ink,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
String _gradeFilterLabel() {
|
||||||
|
switch (_controller.gradeFilter) {
|
||||||
|
case '1':
|
||||||
|
return '1학년만';
|
||||||
|
case '2':
|
||||||
|
return '2학년만';
|
||||||
|
case '3':
|
||||||
|
return '3학년만';
|
||||||
|
default:
|
||||||
|
return '학생 나눠보기';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 🚀 "학생 나눠보기" 버튼 — 학년 필터를 런치패드 스타일 메뉴로 고른다.
|
||||||
|
Future<void> _showGradeFilterMenu() async {
|
||||||
|
final grade = await showLaunchpadMenu<String>(
|
||||||
|
context: context,
|
||||||
|
builder: (context) => Center(
|
||||||
|
child: Material(
|
||||||
|
color: AppPalette.paper,
|
||||||
|
borderRadius: BorderRadius.circular(20),
|
||||||
|
elevation: 8,
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(20),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
const Text(
|
||||||
|
'학년 나눠보기',
|
||||||
|
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 14),
|
||||||
|
Wrap(
|
||||||
|
spacing: 8,
|
||||||
|
runSpacing: 8,
|
||||||
|
children: [
|
||||||
|
_gradeChoice(context, 'ALL', '전체'),
|
||||||
|
_gradeChoice(context, '1', '1학년'),
|
||||||
|
_gradeChoice(context, '2', '2학년'),
|
||||||
|
_gradeChoice(context, '3', '3학년'),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (grade != null) _controller.setGradeFilter(grade);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _gradeChoice(BuildContext dialogContext, String value, String label) {
|
||||||
|
return ChoiceChip(
|
||||||
|
label: Text(label),
|
||||||
|
selected: _controller.gradeFilter == value,
|
||||||
|
onSelected: (_) => Navigator.pop(dialogContext, value),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 🚀 "자습실 시간 설정 메뉴" — 새로고침/출석시간/반출허용/하교/테스트리셋을
|
||||||
|
// 전부 여기 하나로 모아서, 메인 대시보드의 로그아웃/설정 메뉴와 똑같은
|
||||||
|
// 런치패드 스타일(배경 블러 유지) 오버레이로 띄운다.
|
||||||
|
Future<void> _showTimeSettingsMenu() async {
|
||||||
|
final action = await showLaunchpadMenu<String>(
|
||||||
|
context: context,
|
||||||
|
builder: (context) => Center(
|
||||||
|
child: _TimeSettingsMenuCard(
|
||||||
|
isRefreshing: _controller.isRefreshing,
|
||||||
|
attendanceTimeLabel: _controller.attendanceTime != null
|
||||||
|
? '출석시간 ${_controller.attendanceTime}'
|
||||||
|
: '자습실 출석시간 설정',
|
||||||
|
onPick: (value) => Navigator.pop(context, value),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (!mounted || action == null) return;
|
||||||
|
switch (action) {
|
||||||
|
case 'refresh':
|
||||||
|
_controller.manualRefresh();
|
||||||
|
break;
|
||||||
|
case 'attendance_time':
|
||||||
|
_showAttendanceTimeDialog();
|
||||||
|
break;
|
||||||
|
case 'permission_window':
|
||||||
|
_showPermissionWindowDialog();
|
||||||
|
break;
|
||||||
|
case 'dismissal':
|
||||||
|
_showDismissalConfirmDialog();
|
||||||
|
break;
|
||||||
|
case 'test_reset':
|
||||||
|
_showTestResetConfirmDialog();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 🪑 좌석표처럼 촘촘하게 학생 한 명씩을 작은 칸에 담는 그리드.
|
||||||
|
Widget _buildDenseSeatGrid(List<Map<String, dynamic>> students) {
|
||||||
|
if (students.isEmpty) {
|
||||||
|
return const Center(
|
||||||
|
child: Text('해당하는 학생이 없습니다.', style: TextStyle(color: Colors.grey)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return GridView.builder(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent(
|
||||||
|
maxCrossAxisExtent: 92,
|
||||||
|
mainAxisExtent: 62,
|
||||||
|
crossAxisSpacing: 6,
|
||||||
|
mainAxisSpacing: 6,
|
||||||
|
),
|
||||||
|
itemCount: students.length,
|
||||||
|
itemBuilder: (context, index) => _buildSeatCell(students[index], index),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildSeatCell(Map<String, dynamic> student, int index) {
|
||||||
|
final bool hasViolation = student['hasActiveViolation'] == true;
|
||||||
|
final bool isPending = student['attendanceStatus'] == 'PENDING';
|
||||||
|
final bool isComplete = student['attendanceStatus'] == 'COMPLETE';
|
||||||
|
final Color background = hasViolation
|
||||||
|
? Colors.red[50]!
|
||||||
|
: isComplete
|
||||||
|
? Colors.blue[50]!
|
||||||
|
: isPending
|
||||||
|
? Colors.orange[50]!
|
||||||
|
: AppPalette.paper;
|
||||||
|
final Color border = hasViolation
|
||||||
|
? Colors.red[300]!
|
||||||
|
: isComplete
|
||||||
|
? Colors.blue[200]!
|
||||||
|
: isPending
|
||||||
|
? Colors.orange[200]!
|
||||||
|
: AppPalette.sage;
|
||||||
|
|
||||||
|
return InkWell(
|
||||||
|
onTap: hasViolation
|
||||||
|
? () => _showAllowDialog(student['studentId'], student['studentName'])
|
||||||
|
: null,
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 4),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: background,
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
border: Border.all(color: border),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'[${index + 1}] ${student['studentId']}',
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: TextStyle(fontSize: 8, color: Colors.grey[500]),
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
'${student['studentName']}',
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: const TextStyle(fontSize: 11, fontWeight: FontWeight.bold),
|
||||||
|
),
|
||||||
|
if (hasViolation)
|
||||||
|
Text(
|
||||||
|
'무단반출',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 7.5,
|
||||||
|
color: Colors.red[700],
|
||||||
|
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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 🚀 "자습실 시간 설정 메뉴" 카드 — 메인 대시보드 로그아웃/설정 메뉴와 같은 톤으로 통일.
|
||||||
|
class _TimeSettingsMenuCard extends StatelessWidget {
|
||||||
|
final bool isRefreshing;
|
||||||
|
final String attendanceTimeLabel;
|
||||||
|
final ValueChanged<String> onPick;
|
||||||
|
|
||||||
|
const _TimeSettingsMenuCard({
|
||||||
|
required this.isRefreshing,
|
||||||
|
required this.attendanceTimeLabel,
|
||||||
|
required this.onPick,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Material(
|
||||||
|
color: AppPalette.paper,
|
||||||
|
elevation: 8,
|
||||||
|
shadowColor: Colors.black.withValues(alpha: 0.3),
|
||||||
|
borderRadius: BorderRadius.circular(20),
|
||||||
|
child: IntrinsicWidth(
|
||||||
|
child: ConstrainedBox(
|
||||||
|
constraints: const BoxConstraints(minWidth: 220),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
_TimeMenuRow(
|
||||||
|
icon: isRefreshing ? null : Icons.refresh_rounded,
|
||||||
|
label: '새로고침',
|
||||||
|
onTap: () => onPick('refresh'),
|
||||||
|
),
|
||||||
|
const Divider(height: 1, color: AppPalette.sage),
|
||||||
|
_TimeMenuRow(
|
||||||
|
icon: Icons.access_time_rounded,
|
||||||
|
label: attendanceTimeLabel,
|
||||||
|
onTap: () => onPick('attendance_time'),
|
||||||
|
),
|
||||||
|
const Divider(height: 1, color: AppPalette.sage),
|
||||||
|
_TimeMenuRow(
|
||||||
|
icon: Icons.timer_outlined,
|
||||||
|
label: '반출 허용 시간 설정',
|
||||||
|
onTap: () => onPick('permission_window'),
|
||||||
|
),
|
||||||
|
const Divider(height: 1, color: AppPalette.sage),
|
||||||
|
_TimeMenuRow(
|
||||||
|
icon: Icons.school_rounded,
|
||||||
|
label: '하교 처리',
|
||||||
|
onTap: () => onPick('dismissal'),
|
||||||
|
),
|
||||||
|
const Divider(height: 1, color: AppPalette.sage),
|
||||||
|
_TimeMenuRow(
|
||||||
|
icon: Icons.bug_report_outlined,
|
||||||
|
label: '테스트용: 허용시간 초기화',
|
||||||
|
dim: true,
|
||||||
|
onTap: () => onPick('test_reset'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _TimeMenuRow extends StatelessWidget {
|
||||||
|
final IconData? icon;
|
||||||
|
final String label;
|
||||||
|
final VoidCallback onTap;
|
||||||
|
final bool dim;
|
||||||
|
|
||||||
|
const _TimeMenuRow({
|
||||||
|
required this.icon,
|
||||||
|
required this.label,
|
||||||
|
required this.onTap,
|
||||||
|
this.dim = false,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return InkWell(
|
||||||
|
onTap: onTap,
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 14),
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
SizedBox(
|
||||||
|
width: 20,
|
||||||
|
height: 20,
|
||||||
|
child: icon == null
|
||||||
|
? const CircularProgressIndicator(strokeWidth: 2)
|
||||||
|
: Icon(
|
||||||
|
icon,
|
||||||
|
size: 20,
|
||||||
|
color: dim ? Colors.grey : AppPalette.ink,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Flexible(
|
||||||
|
child: Text(
|
||||||
|
label,
|
||||||
|
style: TextStyle(
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: dim ? Colors.grey[600] : AppPalette.ink,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,521 @@
|
|||||||
|
// 📍 선생님 호출 화면(학생용) (UI 전용). "실시간 출석 현황" 화면과 같은 패밀리룩
|
||||||
|
// (제목 알약 + 필 모양 통계/필터 + 그림자 있는 둥근 타일)을 따른다.
|
||||||
|
// 서버 통신/상태는 lib/function/teacher_call_controller.dart가 담당한다.
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import '../data/teacher_call_schedule.dart';
|
||||||
|
import '../function/teacher_call_controller.dart';
|
||||||
|
import '../theme/app_palette.dart';
|
||||||
|
import 'app_notice.dart';
|
||||||
|
import 'title_pill.dart';
|
||||||
|
|
||||||
|
class TeacherCallScreen extends StatefulWidget {
|
||||||
|
final String studentId;
|
||||||
|
final String studentName;
|
||||||
|
|
||||||
|
const TeacherCallScreen({
|
||||||
|
super.key,
|
||||||
|
required this.studentId,
|
||||||
|
required this.studentName,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<TeacherCallScreen> createState() => _TeacherCallScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _TeacherCallScreenState extends State<TeacherCallScreen> {
|
||||||
|
late final TeacherCallController _controller;
|
||||||
|
String? _selectedTeacherId;
|
||||||
|
String? _selectedTeacherName;
|
||||||
|
String _selectedPurpose = kCallPurposes.first;
|
||||||
|
final _customPurposeController = TextEditingController();
|
||||||
|
bool _useCustomPurpose = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_controller = TeacherCallController(
|
||||||
|
studentId: widget.studentId,
|
||||||
|
studentName: widget.studentName,
|
||||||
|
);
|
||||||
|
_controller.init();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_controller.dispose();
|
||||||
|
_customPurposeController.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _call() async {
|
||||||
|
if (_selectedTeacherId == null) {
|
||||||
|
AppNotice.show(context, '선생님을 선택해 주세요.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final purpose = _useCustomPurpose
|
||||||
|
? _customPurposeController.text.trim()
|
||||||
|
: _selectedPurpose;
|
||||||
|
if (purpose.isEmpty) {
|
||||||
|
AppNotice.show(context, '방문 목적을 입력해 주세요.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final (success, message) = await _controller.callTeacher(
|
||||||
|
teacherId: _selectedTeacherId!,
|
||||||
|
purpose: purpose,
|
||||||
|
);
|
||||||
|
if (!mounted) return;
|
||||||
|
AppNotice.show(
|
||||||
|
context,
|
||||||
|
message,
|
||||||
|
icon: success ? Icons.check_circle_rounded : Icons.error_outline_rounded,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 넓은 화면(웹 데스크톱)에서 내용이 양옆으로 늘어지지 않게 가운데 최대 560 폭으로 맞춘다.
|
||||||
|
double _sidePad(BuildContext context) {
|
||||||
|
final width = MediaQuery.of(context).size.width;
|
||||||
|
return ((width - 560) / 2).clamp(16.0, double.infinity);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return ListenableBuilder(
|
||||||
|
listenable: _controller,
|
||||||
|
builder: (context, _) {
|
||||||
|
final teachers = _controller.teachers;
|
||||||
|
final inClassCount = teachers
|
||||||
|
.where((t) => _controller.isInClassNow(t['name'] ?? ''))
|
||||||
|
.length;
|
||||||
|
|
||||||
|
return Scaffold(
|
||||||
|
backgroundColor: AppPalette.mist,
|
||||||
|
appBar: AppBar(
|
||||||
|
backgroundColor: Colors.transparent,
|
||||||
|
foregroundColor: AppPalette.ink,
|
||||||
|
elevation: 0,
|
||||||
|
),
|
||||||
|
body: _controller.isLoading
|
||||||
|
? const Center(child: CircularProgressIndicator())
|
||||||
|
: RefreshIndicator(
|
||||||
|
onRefresh: _controller.refresh,
|
||||||
|
child: ListView(
|
||||||
|
padding: EdgeInsets.fromLTRB(
|
||||||
|
_sidePad(context),
|
||||||
|
0,
|
||||||
|
_sidePad(context),
|
||||||
|
16,
|
||||||
|
),
|
||||||
|
children: [
|
||||||
|
const Center(child: TitlePill('선생님 호출')),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(child: _statPill('전체 선생님', teachers.length)),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(child: _statPill('지금 수업 중', inClassCount)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
if (_controller.ranking.isNotEmpty) ...[
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
_buildRanking(),
|
||||||
|
],
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
_sectionHeader('방문 목적'),
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
_buildPurposeChips(),
|
||||||
|
if (_useCustomPurpose) ...[
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
TextField(
|
||||||
|
controller: _customPurposeController,
|
||||||
|
maxLength: 40,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
hintText: '용무를 입력하세요',
|
||||||
|
filled: true,
|
||||||
|
fillColor: AppPalette.paper,
|
||||||
|
border: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
borderSide: BorderSide(color: AppPalette.sage),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
_sectionHeader('선생님 목록', count: teachers.length),
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
_buildTeacherGrid(),
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
SizedBox(
|
||||||
|
width: double.infinity,
|
||||||
|
child: ElevatedButton(
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
backgroundColor: _selectedTeacherId == null
|
||||||
|
? Colors.grey
|
||||||
|
: AppPalette.ink,
|
||||||
|
foregroundColor: AppPalette.paper,
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(28),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
onPressed: _controller.isCalling ? null : _call,
|
||||||
|
child: _controller.isCalling
|
||||||
|
? const SizedBox(
|
||||||
|
width: 20,
|
||||||
|
height: 20,
|
||||||
|
child: CircularProgressIndicator(
|
||||||
|
strokeWidth: 2,
|
||||||
|
color: Colors.white,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: Text(
|
||||||
|
_selectedTeacherName == null
|
||||||
|
? '선생님을 선택해 주세요'
|
||||||
|
: '$_selectedTeacherName 선생님 호출하기',
|
||||||
|
style: const TextStyle(
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
_sectionHeader(
|
||||||
|
'내 호출 기록',
|
||||||
|
count: _controller.myCalls.length,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
_buildMyCalls(),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 🪶 "실시간 출석 현황"의 _sidebarStatPill과 같은 필 모양 통계 표시(선택 불가, 숫자 강조용).
|
||||||
|
Widget _statPill(String label, int count) {
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 16),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: AppPalette.paper,
|
||||||
|
borderRadius: BorderRadius.circular(28),
|
||||||
|
border: Border.all(color: AppPalette.sage),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
Text(label, style: TextStyle(fontSize: 12, color: Colors.grey[600])),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
'$count명',
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: AppPalette.ink,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 🏷️ "학년 그룹" 헤더처럼 작은 강조 바 + 굵은 제목 + (선택) 개수를 붙인 섹션 제목.
|
||||||
|
Widget _sectionHeader(String title, {int? count}) {
|
||||||
|
return Row(
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
width: 4,
|
||||||
|
height: 16,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: AppPalette.ink,
|
||||||
|
borderRadius: BorderRadius.circular(2),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Text(
|
||||||
|
title,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: AppPalette.ink,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (count != null) ...[
|
||||||
|
const SizedBox(width: 6),
|
||||||
|
Text(
|
||||||
|
'$count명',
|
||||||
|
style: TextStyle(fontSize: 13, color: Colors.grey[500]),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
static const List<Color> _medalColors = [
|
||||||
|
Color(0xFFC9A227), // 금
|
||||||
|
Color(0xFF9AA0A6), // 은
|
||||||
|
Color(0xFFB07A4B), // 동
|
||||||
|
];
|
||||||
|
|
||||||
|
Widget _buildRanking() {
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.all(14),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: AppPalette.paper,
|
||||||
|
borderRadius: BorderRadius.circular(20),
|
||||||
|
border: Border.all(color: AppPalette.sage),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: Colors.black.withValues(alpha: 0.03),
|
||||||
|
blurRadius: 12,
|
||||||
|
offset: const Offset(0, 4),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'오늘 인기 선생님',
|
||||||
|
style: TextStyle(
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: Colors.grey[600],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||||
|
children: [
|
||||||
|
for (var i = 0; i < _controller.ranking.length; i++)
|
||||||
|
Column(
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Icons.emoji_events_rounded,
|
||||||
|
color: _medalColors[i],
|
||||||
|
size: 22,
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
_controller.ranking[i]['teacherName'] ?? '',
|
||||||
|
style: const TextStyle(fontWeight: FontWeight.bold),
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
'${_controller.ranking[i]['count']}회',
|
||||||
|
style: TextStyle(color: Colors.grey[500], fontSize: 12),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 🍬 필 모양 선택 버튼(스탯 필과 같은 톤: 선택=ink 배경, 미선택=paper+sage 테두리).
|
||||||
|
Widget _pillChoice(String label, bool selected, VoidCallback onTap) {
|
||||||
|
return InkWell(
|
||||||
|
onTap: onTap,
|
||||||
|
borderRadius: BorderRadius.circular(28),
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 10, horizontal: 16),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: selected ? AppPalette.ink : AppPalette.paper,
|
||||||
|
borderRadius: BorderRadius.circular(28),
|
||||||
|
border: Border.all(
|
||||||
|
color: selected ? AppPalette.ink : AppPalette.sage,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
label,
|
||||||
|
style: TextStyle(
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: selected ? Colors.white : AppPalette.ink,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildPurposeChips() {
|
||||||
|
return Wrap(
|
||||||
|
spacing: 8,
|
||||||
|
runSpacing: 8,
|
||||||
|
children: [
|
||||||
|
for (final p in kCallPurposes)
|
||||||
|
_pillChoice(
|
||||||
|
p,
|
||||||
|
!_useCustomPurpose && _selectedPurpose == p,
|
||||||
|
() => setState(() {
|
||||||
|
_useCustomPurpose = false;
|
||||||
|
_selectedPurpose = p;
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
_pillChoice(
|
||||||
|
'직접 입력',
|
||||||
|
_useCustomPurpose,
|
||||||
|
() => setState(() => _useCustomPurpose = true),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildTeacherGrid() {
|
||||||
|
final teachers = _controller.teachers;
|
||||||
|
return GridView.builder(
|
||||||
|
shrinkWrap: true,
|
||||||
|
physics: const NeverScrollableScrollPhysics(),
|
||||||
|
gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent(
|
||||||
|
maxCrossAxisExtent: 160,
|
||||||
|
mainAxisSpacing: 12,
|
||||||
|
crossAxisSpacing: 12,
|
||||||
|
mainAxisExtent: 110,
|
||||||
|
),
|
||||||
|
itemCount: teachers.length,
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final t = teachers[index];
|
||||||
|
final name = t['name'] ?? '';
|
||||||
|
final location = t['location'] ?? '교무실';
|
||||||
|
final isSelected = _selectedTeacherId == t['teacherId'];
|
||||||
|
final inClass = _controller.isInClassNow(name);
|
||||||
|
final statusColor = inClass ? Colors.orange : AppPalette.ink;
|
||||||
|
|
||||||
|
return GestureDetector(
|
||||||
|
onTap: () => setState(() {
|
||||||
|
_selectedTeacherId = t['teacherId'];
|
||||||
|
_selectedTeacherName = name;
|
||||||
|
}),
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.all(12),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: isSelected ? AppPalette.ink : AppPalette.paper,
|
||||||
|
borderRadius: BorderRadius.circular(20),
|
||||||
|
border: Border.all(
|
||||||
|
color: isSelected ? AppPalette.ink : AppPalette.sage,
|
||||||
|
),
|
||||||
|
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(6),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: isSelected
|
||||||
|
? Colors.white.withValues(alpha: 0.15)
|
||||||
|
: statusColor.withValues(alpha: 0.1),
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
),
|
||||||
|
child: Icon(
|
||||||
|
inClass ? Icons.school_rounded : Icons.person_rounded,
|
||||||
|
color: isSelected ? Colors.white : statusColor,
|
||||||
|
size: 16,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const Spacer(),
|
||||||
|
if (inClass)
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 6,
|
||||||
|
vertical: 2,
|
||||||
|
),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: isSelected
|
||||||
|
? Colors.white.withValues(alpha: 0.15)
|
||||||
|
: Colors.orange[50],
|
||||||
|
borderRadius: BorderRadius.circular(20),
|
||||||
|
border: Border.all(
|
||||||
|
color: isSelected ? Colors.white54 : Colors.orange,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
'수업중',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 9,
|
||||||
|
color: isSelected ? Colors.white : Colors.orange,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const Spacer(),
|
||||||
|
Text(
|
||||||
|
name,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: TextStyle(
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
fontSize: 15,
|
||||||
|
color: isSelected ? Colors.white : AppPalette.ink,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
kLocationIcons[location] ?? Icons.location_on_rounded,
|
||||||
|
size: 12,
|
||||||
|
color: isSelected ? Colors.white70 : Colors.grey[500],
|
||||||
|
),
|
||||||
|
const SizedBox(width: 3),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
location,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 11,
|
||||||
|
color: isSelected ? Colors.white70 : Colors.grey[500],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildMyCalls() {
|
||||||
|
if (_controller.myCalls.isEmpty) {
|
||||||
|
return const Padding(
|
||||||
|
padding: EdgeInsets.symmetric(vertical: 12),
|
||||||
|
child: Text('아직 호출 기록이 없어요.', style: TextStyle(color: Colors.grey)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return Column(
|
||||||
|
children: [
|
||||||
|
for (final c in _controller.myCalls)
|
||||||
|
Container(
|
||||||
|
margin: const EdgeInsets.only(bottom: 8),
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: AppPalette.paper,
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
border: Border.all(color: AppPalette.sage),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Text('${c['teacherName']} 선생님 · ${c['purpose']}'),
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
'${c['createdAt'] ?? ''}'.split(' ').last,
|
||||||
|
style: TextStyle(color: Colors.grey[500], fontSize: 12),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,291 @@
|
|||||||
|
// 📍 선생님 위치 등록 화면(교사용) (UI 전용). "실시간 출석 현황" 화면과 같은 패밀리룩
|
||||||
|
// (제목 알약 + 필 모양 통계 + 그림자 있는 둥근 타일)을 따른다.
|
||||||
|
// 서버 통신/상태는 lib/function/teacher_location_controller.dart가 담당한다.
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import '../data/teacher_call_schedule.dart';
|
||||||
|
import '../function/teacher_location_controller.dart';
|
||||||
|
import '../theme/app_palette.dart';
|
||||||
|
import 'app_notice.dart';
|
||||||
|
import 'title_pill.dart';
|
||||||
|
|
||||||
|
class TeacherLocationScreen extends StatefulWidget {
|
||||||
|
final String teacherId;
|
||||||
|
|
||||||
|
const TeacherLocationScreen({super.key, required this.teacherId});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<TeacherLocationScreen> createState() => _TeacherLocationScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _TeacherLocationScreenState extends State<TeacherLocationScreen> {
|
||||||
|
late final TeacherLocationController _controller;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_controller = TeacherLocationController(teacherId: widget.teacherId);
|
||||||
|
_controller.init();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_controller.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _selectLocation(String location) async {
|
||||||
|
final (success, message) = await _controller.updateLocation(location);
|
||||||
|
if (!mounted) return;
|
||||||
|
AppNotice.show(
|
||||||
|
context,
|
||||||
|
message,
|
||||||
|
icon: success ? Icons.check_circle_rounded : Icons.error_outline_rounded,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _sectionHeader(String title, {int? count}) {
|
||||||
|
return Row(
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
width: 4,
|
||||||
|
height: 16,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: AppPalette.ink,
|
||||||
|
borderRadius: BorderRadius.circular(2),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Text(
|
||||||
|
title,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: AppPalette.ink,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (count != null) ...[
|
||||||
|
const SizedBox(width: 6),
|
||||||
|
Text(
|
||||||
|
'$count건',
|
||||||
|
style: TextStyle(fontSize: 13, color: Colors.grey[500]),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 넓은 화면(웹 데스크톱)에서 내용이 양옆으로 늘어지지 않게 가운데 최대 560 폭으로 맞춘다.
|
||||||
|
double _sidePad(BuildContext context) {
|
||||||
|
final width = MediaQuery.of(context).size.width;
|
||||||
|
return ((width - 560) / 2).clamp(16.0, double.infinity);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return ListenableBuilder(
|
||||||
|
listenable: _controller,
|
||||||
|
builder: (context, _) {
|
||||||
|
return Scaffold(
|
||||||
|
backgroundColor: AppPalette.mist,
|
||||||
|
appBar: AppBar(
|
||||||
|
backgroundColor: Colors.transparent,
|
||||||
|
foregroundColor: AppPalette.ink,
|
||||||
|
elevation: 0,
|
||||||
|
),
|
||||||
|
body: _controller.isLoading
|
||||||
|
? const Center(child: CircularProgressIndicator())
|
||||||
|
: RefreshIndicator(
|
||||||
|
onRefresh: _controller.fetchReceivedCalls,
|
||||||
|
child: ListView(
|
||||||
|
padding: EdgeInsets.fromLTRB(
|
||||||
|
_sidePad(context),
|
||||||
|
0,
|
||||||
|
_sidePad(context),
|
||||||
|
16,
|
||||||
|
),
|
||||||
|
children: [
|
||||||
|
const Center(child: TitlePill('내 위치 알리기')),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Container(
|
||||||
|
width: double.infinity,
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 18),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: AppPalette.ink,
|
||||||
|
borderRadius: BorderRadius.circular(24),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
const Text(
|
||||||
|
'현재 위치',
|
||||||
|
style: TextStyle(
|
||||||
|
color: Colors.white70,
|
||||||
|
fontSize: 12,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
kLocationIcons[_controller.currentLocation] ??
|
||||||
|
Icons.location_on_rounded,
|
||||||
|
color: Colors.white,
|
||||||
|
size: 20,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Text(
|
||||||
|
_controller.currentLocation,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
fontSize: 20,
|
||||||
|
color: Colors.white,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
_sectionHeader('위치 선택'),
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
GridView.builder(
|
||||||
|
shrinkWrap: true,
|
||||||
|
physics: const NeverScrollableScrollPhysics(),
|
||||||
|
gridDelegate:
|
||||||
|
const SliverGridDelegateWithMaxCrossAxisExtent(
|
||||||
|
maxCrossAxisExtent: 160,
|
||||||
|
mainAxisSpacing: 12,
|
||||||
|
crossAxisSpacing: 12,
|
||||||
|
mainAxisExtent: 90,
|
||||||
|
),
|
||||||
|
itemCount: kLocationOptions.length,
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final loc = kLocationOptions[index];
|
||||||
|
final isSelected = _controller.currentLocation == loc;
|
||||||
|
return GestureDetector(
|
||||||
|
onTap: _controller.isSaving
|
||||||
|
? null
|
||||||
|
: () => _selectLocation(loc),
|
||||||
|
child: Container(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: isSelected
|
||||||
|
? AppPalette.ink
|
||||||
|
: AppPalette.paper,
|
||||||
|
borderRadius: BorderRadius.circular(20),
|
||||||
|
border: Border.all(
|
||||||
|
color: isSelected
|
||||||
|
? AppPalette.ink
|
||||||
|
: AppPalette.sage,
|
||||||
|
),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: Colors.black.withValues(alpha: 0.03),
|
||||||
|
blurRadius: 12,
|
||||||
|
offset: const Offset(0, 4),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: Center(
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.all(6),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: isSelected
|
||||||
|
? Colors.white.withValues(
|
||||||
|
alpha: 0.15,
|
||||||
|
)
|
||||||
|
: AppPalette.linen,
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
),
|
||||||
|
child: Icon(
|
||||||
|
kLocationIcons[loc] ??
|
||||||
|
Icons.location_on_rounded,
|
||||||
|
size: 16,
|
||||||
|
color: isSelected
|
||||||
|
? Colors.white
|
||||||
|
: AppPalette.ink,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
Text(
|
||||||
|
loc,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: isSelected
|
||||||
|
? Colors.white
|
||||||
|
: AppPalette.ink,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
_sectionHeader(
|
||||||
|
'나를 찾은 학생 기록',
|
||||||
|
count: _controller.receivedCalls.length,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
_buildReceivedCalls(),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildReceivedCalls() {
|
||||||
|
if (_controller.receivedCalls.isEmpty) {
|
||||||
|
return const Padding(
|
||||||
|
padding: EdgeInsets.symmetric(vertical: 12),
|
||||||
|
child: Text('아직 호출 기록이 없어요.', style: TextStyle(color: Colors.grey)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return Column(
|
||||||
|
children: [
|
||||||
|
for (final c in _controller.receivedCalls)
|
||||||
|
Container(
|
||||||
|
margin: const EdgeInsets.only(bottom: 8),
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: AppPalette.paper,
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
border: Border.all(color: AppPalette.sage),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.all(6),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: AppPalette.ink.withValues(alpha: 0.06),
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
),
|
||||||
|
child: const Icon(
|
||||||
|
Icons.notifications_active_rounded,
|
||||||
|
size: 16,
|
||||||
|
color: AppPalette.ink,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 10),
|
||||||
|
Expanded(
|
||||||
|
child: Text('${c['studentName']} 학생 · ${c['purpose']}'),
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
'${c['createdAt'] ?? ''}',
|
||||||
|
style: TextStyle(color: Colors.grey[500], fontSize: 11),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
// 👨🏫 교사 회원가입 화면 (UI 전용). 서버 통신/상태는
|
||||||
|
// lib/function/teacher_register_controller.dart가 담당한다.
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import '../function/teacher_register_controller.dart';
|
||||||
|
import '../theme/app_palette.dart';
|
||||||
|
import 'app_notice.dart';
|
||||||
|
import 'title_pill.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) {
|
||||||
|
AppNotice.show(context, '모든 빈칸을 입력해 주세요.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final (success, message) = await _controller.registerTeacher(
|
||||||
|
id: id,
|
||||||
|
password: pw,
|
||||||
|
name: name,
|
||||||
|
secretCode: secret,
|
||||||
|
);
|
||||||
|
if (!mounted) return;
|
||||||
|
AppNotice.show(context, message);
|
||||||
|
if (success) {
|
||||||
|
Navigator.pop(context); // 가입 성공 시 로그인 화면으로 복귀
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return ListenableBuilder(
|
||||||
|
listenable: _controller,
|
||||||
|
builder: (context, _) {
|
||||||
|
return Scaffold(
|
||||||
|
backgroundColor: AppPalette.mist,
|
||||||
|
appBar: AppBar(
|
||||||
|
backgroundColor: Colors.transparent,
|
||||||
|
foregroundColor: AppPalette.ink,
|
||||||
|
elevation: 0,
|
||||||
|
centerTitle: true,
|
||||||
|
title: const TitlePill('교사 회원가입'),
|
||||||
|
),
|
||||||
|
body: 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: AppPalette.ink,
|
||||||
|
foregroundColor: AppPalette.paper,
|
||||||
|
),
|
||||||
|
onPressed: _controller.isLoading ? null : _registerTeacher,
|
||||||
|
child: _controller.isLoading
|
||||||
|
? const CircularProgressIndicator(color: Colors.white)
|
||||||
|
: const Text('교사 계정 생성하기'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,802 @@
|
|||||||
|
// 👥 교사용 학생 계정 관리 화면 (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';
|
||||||
|
import '../theme/app_palette.dart';
|
||||||
|
import 'app_notice.dart';
|
||||||
|
import 'title_pill.dart';
|
||||||
|
|
||||||
|
// 📐 "실시간 출석 확인" 목록(teacher_attendance_page.dart)의 타일 규격과 동일하게 맞춘다.
|
||||||
|
const double kAttendanceTileWidth = 220;
|
||||||
|
const double kAttendanceTileHeight = 148;
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
// 👥 [서브 화면 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();
|
||||||
|
int? _selectedGrade;
|
||||||
|
bool _isDragging = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_controller.init();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_controller.dispose();
|
||||||
|
_addIdController.dispose();
|
||||||
|
_addNameController.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ➕ [학생 추가 버튼 동작] 기본 비밀번호는 항상 1234로 고정.
|
||||||
|
Future<void> _addStudentAccount() async {
|
||||||
|
final sId = _addIdController.text.trim();
|
||||||
|
final sName = _addNameController.text.trim();
|
||||||
|
|
||||||
|
if (sId.isEmpty || sName.isEmpty) {
|
||||||
|
AppNotice.show(context, '모든 입력란을 채워주세요.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final (success, message) = await _controller.addStudentAccount(
|
||||||
|
studentId: sId,
|
||||||
|
name: sName,
|
||||||
|
password: '1234',
|
||||||
|
grade: _selectedGrade,
|
||||||
|
);
|
||||||
|
if (!mounted) return;
|
||||||
|
if (success) {
|
||||||
|
_addIdController.clear();
|
||||||
|
_addNameController.clear();
|
||||||
|
setState(() => _selectedGrade = null);
|
||||||
|
}
|
||||||
|
AppNotice.show(context, 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;
|
||||||
|
AppNotice.show(context, '엑셀 파일을 읽는 데 실패했습니다: $e');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (rows.isEmpty) {
|
||||||
|
if (!mounted) return;
|
||||||
|
AppNotice.show(context, '유효한 학생 데이터를 찾지 못했습니다. (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: AppPalette.ink,
|
||||||
|
foregroundColor: AppPalette.paper,
|
||||||
|
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;
|
||||||
|
AppNotice.show(context, message);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 🔓 [목록 행의 "기기 리셋" 버튼] 학생이 폰을 바꿨을 때 등록 초기화.
|
||||||
|
void _confirmResetDevice(String studentId, String studentName) {
|
||||||
|
showDialog(
|
||||||
|
context: context,
|
||||||
|
builder: (ctx) => AlertDialog(
|
||||||
|
title: const Text(
|
||||||
|
'기기 등록 초기화',
|
||||||
|
style: TextStyle(fontWeight: FontWeight.bold, color: AppPalette.ink),
|
||||||
|
),
|
||||||
|
content: Text('$studentName 학생의 스마트폰 기기 등록과 비밀번호(1234)를 초기화하시겠습니까?'),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(ctx),
|
||||||
|
child: const Text('취소', style: TextStyle(color: Colors.grey)),
|
||||||
|
),
|
||||||
|
ElevatedButton(
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
backgroundColor: AppPalette.ink,
|
||||||
|
foregroundColor: AppPalette.paper,
|
||||||
|
),
|
||||||
|
onPressed: () async {
|
||||||
|
Navigator.pop(ctx);
|
||||||
|
final (_, message) = await _controller.resetDevice(
|
||||||
|
studentId,
|
||||||
|
studentName,
|
||||||
|
);
|
||||||
|
if (!mounted) return;
|
||||||
|
AppNotice.show(context, 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;
|
||||||
|
AppNotice.show(context, message);
|
||||||
|
},
|
||||||
|
child: const Text('영구 삭제'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 🏷️ 섹션 제목 (색 인디케이터 바 + 텍스트).
|
||||||
|
Widget _sectionTitle(String text, {Color color = AppPalette.ink}) {
|
||||||
|
return Row(
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
width: 4,
|
||||||
|
height: 16,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: color,
|
||||||
|
borderRadius: BorderRadius.circular(2),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Text(
|
||||||
|
text,
|
||||||
|
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 📝 [정사각형 박스 1] 신규 학생 계정 추가 폼. 박스가 작아도 버튼까지 다 보이도록 촘촘하게 배치.
|
||||||
|
Widget _buildAddAccountBox() {
|
||||||
|
const InputDecoration Function(String, IconData) fieldDecoration =
|
||||||
|
_compactFieldDecoration;
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.all(10),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: AppPalette.paper,
|
||||||
|
borderRadius: BorderRadius.circular(24),
|
||||||
|
border: Border.all(color: AppPalette.sage),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: Colors.black.withValues(alpha: 0.04),
|
||||||
|
blurRadius: 16,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: SingleChildScrollView(
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
TextField(
|
||||||
|
controller: _addIdController,
|
||||||
|
keyboardType: TextInputType.number,
|
||||||
|
style: const TextStyle(fontSize: 12),
|
||||||
|
decoration: fieldDecoration('학번', Icons.badge),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
TextField(
|
||||||
|
controller: _addNameController,
|
||||||
|
style: const TextStyle(fontSize: 12),
|
||||||
|
decoration: fieldDecoration('이름', Icons.person),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
const Padding(
|
||||||
|
padding: EdgeInsets.symmetric(horizontal: 4),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(Icons.lock, size: 14, color: Colors.grey),
|
||||||
|
SizedBox(width: 6),
|
||||||
|
Text(
|
||||||
|
'초기 비밀번호: 1234 (고정)',
|
||||||
|
style: TextStyle(fontSize: 11, color: Colors.grey),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
DropdownButtonFormField<int>(
|
||||||
|
initialValue: _selectedGrade,
|
||||||
|
style: const TextStyle(fontSize: 12, color: Colors.black87),
|
||||||
|
decoration: fieldDecoration('학년 (선택)', 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: 6),
|
||||||
|
SizedBox(
|
||||||
|
width: double.infinity,
|
||||||
|
height: 30,
|
||||||
|
child: ElevatedButton(
|
||||||
|
onPressed: _controller.isWorking ? null : _addStudentAccount,
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
backgroundColor: AppPalette.ink,
|
||||||
|
foregroundColor: AppPalette.paper,
|
||||||
|
padding: EdgeInsets.zero,
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(10),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: _controller.isWorking
|
||||||
|
? const SizedBox(
|
||||||
|
width: 16,
|
||||||
|
height: 16,
|
||||||
|
child: CircularProgressIndicator(
|
||||||
|
color: Colors.white,
|
||||||
|
strokeWidth: 2,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: const Text(
|
||||||
|
'학생 등록 완료',
|
||||||
|
style: TextStyle(
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
fontSize: 12,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
static InputDecoration _compactFieldDecoration(String label, IconData icon) {
|
||||||
|
return InputDecoration(
|
||||||
|
labelText: label,
|
||||||
|
labelStyle: const TextStyle(fontSize: 12),
|
||||||
|
prefixIcon: Icon(icon, size: 16),
|
||||||
|
isDense: true,
|
||||||
|
contentPadding: const EdgeInsets.symmetric(vertical: 4, horizontal: 8),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 📤 [정사각형 박스 2] 엑셀 일괄 등록 드롭존.
|
||||||
|
Widget _buildExcelBox() {
|
||||||
|
return 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,
|
||||||
|
height: double.infinity,
|
||||||
|
padding: const EdgeInsets.all(20),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: _isDragging ? Colors.orange[50] : AppPalette.paper,
|
||||||
|
borderRadius: BorderRadius.circular(24),
|
||||||
|
border: Border.all(
|
||||||
|
color: _isDragging ? Colors.orange : AppPalette.sage,
|
||||||
|
width: _isDragging ? 2 : 1,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Icons.upload_file_rounded,
|
||||||
|
size: 40,
|
||||||
|
color: _isDragging ? Colors.orange : AppPalette.sage,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Text(
|
||||||
|
_isDragging ? '여기에 놓으세요' : '엑셀 파일을 드래그하거나 눌러서 선택',
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: TextStyle(
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: _isDragging ? Colors.orange[800] : AppPalette.ink,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
'.xlsx · 1행은 머리글, A열=학번 B열=이름 C열=학년(선택)',
|
||||||
|
style: TextStyle(fontSize: 12, color: Colors.grey[500]),
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 🧑🎓 [학생 목록 타일] 아이콘/이름/상태/액션을 모두 중앙 정렬한 정사각 타일.
|
||||||
|
Widget _buildStudentTile(Map<String, dynamic> student) {
|
||||||
|
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 Container(
|
||||||
|
padding: const EdgeInsets.all(14),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: AppPalette.paper,
|
||||||
|
borderRadius: BorderRadius.circular(20),
|
||||||
|
border: Border.all(color: AppPalette.sage),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: Colors.black.withValues(alpha: 0.03),
|
||||||
|
blurRadius: 12,
|
||||||
|
offset: const Offset(0, 4),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.all(8),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: needsReset
|
||||||
|
? Colors.red.withValues(alpha: 0.1)
|
||||||
|
: AppPalette.linen,
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
),
|
||||||
|
child: Icon(
|
||||||
|
needsReset ? Icons.lock_reset : Icons.person,
|
||||||
|
size: 18,
|
||||||
|
color: needsReset ? Colors.red : AppPalette.ink,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
'$studentName ($studentId)',
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 15),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
needsReset ? '초기화 대기중' : '정상 등록',
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 11.5,
|
||||||
|
color: needsReset ? Colors.red[700] : Colors.grey[600],
|
||||||
|
fontWeight: needsReset ? FontWeight.bold : FontWeight.normal,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
Wrap(
|
||||||
|
alignment: WrapAlignment.center,
|
||||||
|
spacing: 2,
|
||||||
|
runSpacing: 2,
|
||||||
|
children: [
|
||||||
|
Builder(
|
||||||
|
builder: (chipContext) => ActionChip(
|
||||||
|
visualDensity: VisualDensity.compact,
|
||||||
|
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||||
|
labelPadding: const EdgeInsets.symmetric(horizontal: 6),
|
||||||
|
label: Text(
|
||||||
|
grade != null ? '$grade학년' : '미배정',
|
||||||
|
style: const TextStyle(fontSize: 11),
|
||||||
|
),
|
||||||
|
onPressed: () => _showGradeMenu(
|
||||||
|
chipContext,
|
||||||
|
studentId,
|
||||||
|
studentName,
|
||||||
|
grade,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(
|
||||||
|
Icons.lock_reset,
|
||||||
|
color: AppPalette.ink,
|
||||||
|
size: 18,
|
||||||
|
),
|
||||||
|
tooltip: '기기 리셋',
|
||||||
|
padding: EdgeInsets.zero,
|
||||||
|
constraints: const BoxConstraints(minWidth: 28, minHeight: 28),
|
||||||
|
onPressed: () => _confirmResetDevice(studentId, studentName),
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(
|
||||||
|
Icons.delete_forever_rounded,
|
||||||
|
color: Colors.redAccent,
|
||||||
|
size: 18,
|
||||||
|
),
|
||||||
|
tooltip: '계정 영구 삭제',
|
||||||
|
padding: EdgeInsets.zero,
|
||||||
|
constraints: const BoxConstraints(minWidth: 28, minHeight: 28),
|
||||||
|
onPressed: () => _confirmDeleteStudent(studentId, studentName),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return ListenableBuilder(
|
||||||
|
listenable: _controller,
|
||||||
|
builder: (context, _) {
|
||||||
|
return Scaffold(
|
||||||
|
backgroundColor: AppPalette.mist,
|
||||||
|
appBar: AppBar(
|
||||||
|
backgroundColor: Colors.transparent,
|
||||||
|
foregroundColor: AppPalette.ink,
|
||||||
|
elevation: 0,
|
||||||
|
centerTitle: true,
|
||||||
|
title: const TitlePill('학생 통합 관리 센터'),
|
||||||
|
),
|
||||||
|
body: SingleChildScrollView(
|
||||||
|
padding: const EdgeInsets.all(24.0),
|
||||||
|
child: LayoutBuilder(
|
||||||
|
builder: (context, constraints) {
|
||||||
|
// 🖥️ 웹(넓은 화면)은 계정 추가/엑셀 등록을 정사각형 2칸으로 나란히, 폰은 기존처럼 세로로 쌓는다.
|
||||||
|
// 📐 학생 목록 타일은 "실시간 출석 확인" 목록의 타일(220x148)과 동일하게 맞춘다.
|
||||||
|
final bool isWide = constraints.maxWidth >= 800;
|
||||||
|
final double listWidth = constraints.maxWidth;
|
||||||
|
// 📉 계정 추가 / 엑셀 등록 칸: 정사각형 기본 크기의 25%(+ 버튼까지 다 보이도록 확대).
|
||||||
|
final double formSquareSize =
|
||||||
|
((constraints.maxWidth - 20) / 2) * 0.25 * 1.45;
|
||||||
|
|
||||||
|
final Widget addBoxColumn = Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
_sectionTitle('신규 학생 계정 추가'),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
SizedBox(
|
||||||
|
width: isWide ? formSquareSize : double.infinity,
|
||||||
|
height: isWide ? formSquareSize : null,
|
||||||
|
child: _buildAddAccountBox(),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
final Widget excelBoxColumn = Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
_sectionTitle('엑셀로 한번에 추가 (신입생 등)'),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
SizedBox(
|
||||||
|
width: isWide ? formSquareSize : double.infinity,
|
||||||
|
height: isWide ? formSquareSize : 240,
|
||||||
|
child: _buildExcelBox(),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
isWide
|
||||||
|
? Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
addBoxColumn,
|
||||||
|
const SizedBox(width: 20),
|
||||||
|
excelBoxColumn,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
addBoxColumn,
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
excelBoxColumn,
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 36),
|
||||||
|
|
||||||
|
// 🏷️ 인디케이터 바 (전체 학생 목록) — 위 두 칸을 합친 폭에 맞춘다.
|
||||||
|
SizedBox(
|
||||||
|
width: listWidth,
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
_sectionTitle('전체 학생 목록'),
|
||||||
|
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),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: GridView.builder(
|
||||||
|
shrinkWrap: true,
|
||||||
|
physics: const NeverScrollableScrollPhysics(),
|
||||||
|
gridDelegate:
|
||||||
|
const SliverGridDelegateWithMaxCrossAxisExtent(
|
||||||
|
maxCrossAxisExtent:
|
||||||
|
kAttendanceTileWidth,
|
||||||
|
mainAxisExtent: kAttendanceTileHeight,
|
||||||
|
crossAxisSpacing: 12,
|
||||||
|
mainAxisSpacing: 12,
|
||||||
|
),
|
||||||
|
itemCount: _controller.students.length,
|
||||||
|
itemBuilder: (context, index) =>
|
||||||
|
_buildStudentTile(
|
||||||
|
_controller.students[index],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
// 🪶 검정 AppBar 대신 쓰는 둥근 제목 알약. 모든 화면이 똑같은 폰트/크기를 쓰고,
|
||||||
|
// 화면 맨 위에 딱 붙어 보이지 않도록 위쪽에 살짝 여백을 둔다.
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import '../theme/app_palette.dart';
|
||||||
|
|
||||||
|
class TitlePill extends StatelessWidget {
|
||||||
|
final String text;
|
||||||
|
|
||||||
|
const TitlePill(this.text, {super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 8),
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 10),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: AppPalette.ink,
|
||||||
|
borderRadius: BorderRadius.circular(999),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
text,
|
||||||
|
style: const TextStyle(
|
||||||
|
color: Colors.white,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
fontSize: 15,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,6 +6,10 @@
|
|||||||
|
|
||||||
#include "generated_plugin_registrant.h"
|
#include "generated_plugin_registrant.h"
|
||||||
|
|
||||||
|
#include <desktop_drop/desktop_drop_plugin.h>
|
||||||
|
|
||||||
void fl_register_plugins(FlPluginRegistry* registry) {
|
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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
#
|
#
|
||||||
|
|
||||||
list(APPEND FLUTTER_PLUGIN_LIST
|
list(APPEND FLUTTER_PLUGIN_LIST
|
||||||
|
desktop_drop
|
||||||
)
|
)
|
||||||
|
|
||||||
list(APPEND FLUTTER_FFI_PLUGIN_LIST
|
list(APPEND FLUTTER_FFI_PLUGIN_LIST
|
||||||
|
|||||||
@@ -5,14 +5,20 @@
|
|||||||
import FlutterMacOS
|
import FlutterMacOS
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
|
import desktop_drop
|
||||||
import device_info_plus
|
import device_info_plus
|
||||||
|
import file_picker_darwin
|
||||||
import firebase_core
|
import firebase_core
|
||||||
import firebase_messaging
|
import firebase_messaging
|
||||||
import flutter_local_notifications
|
import flutter_local_notifications
|
||||||
|
import shared_preferences_foundation
|
||||||
|
|
||||||
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
||||||
|
DesktopDropPlugin.register(with: registry.registrar(forPlugin: "DesktopDropPlugin"))
|
||||||
DeviceInfoPlusMacosPlugin.register(with: registry.registrar(forPlugin: "DeviceInfoPlusMacosPlugin"))
|
DeviceInfoPlusMacosPlugin.register(with: registry.registrar(forPlugin: "DeviceInfoPlusMacosPlugin"))
|
||||||
|
FilePickerPlugin.register(with: registry.registrar(forPlugin: "FilePickerPlugin"))
|
||||||
FLTFirebaseCorePlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseCorePlugin"))
|
FLTFirebaseCorePlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseCorePlugin"))
|
||||||
FLTFirebaseMessagingPlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseMessagingPlugin"))
|
FLTFirebaseMessagingPlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseMessagingPlugin"))
|
||||||
FlutterLocalNotificationsPlugin.register(with: registry.registrar(forPlugin: "FlutterLocalNotificationsPlugin"))
|
FlutterLocalNotificationsPlugin.register(with: registry.registrar(forPlugin: "FlutterLocalNotificationsPlugin"))
|
||||||
|
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,22 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.3.75"
|
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:
|
args:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -57,6 +73,22 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.19.1"
|
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:
|
cupertino_icons:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
@@ -73,6 +105,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.7.14"
|
version: "0.7.14"
|
||||||
|
desktop_drop:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: desktop_drop
|
||||||
|
sha256: "4c639b4cb80780d1cb94c3252309772e5e68522372181497bc9cd2fbd973aec1"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.8.3"
|
||||||
device_info_plus:
|
device_info_plus:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
@@ -89,6 +129,22 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "8.1.0"
|
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:
|
fake_async:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -121,6 +177,46 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "7.0.1"
|
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:
|
firebase_core:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
@@ -254,6 +350,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "3.1.1"
|
version: "3.1.1"
|
||||||
|
flutter_staggered_grid_view:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: flutter_staggered_grid_view
|
||||||
|
sha256: "19e7abb550c96fbfeb546b23f3ff356ee7c59a019a651f8f102a4ba9b7349395"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.7.0"
|
||||||
flutter_test:
|
flutter_test:
|
||||||
dependency: "direct dev"
|
dependency: "direct dev"
|
||||||
description: flutter
|
description: flutter
|
||||||
@@ -376,6 +480,30 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.9.1"
|
version: "1.9.1"
|
||||||
|
path_provider_linux:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: path_provider_linux
|
||||||
|
sha256: "58c2005f147315b11e9b4a7bc889cd5203e250cba8e3f012dae259b4972b5c16"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.2.2"
|
||||||
|
path_provider_platform_interface:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: path_provider_platform_interface
|
||||||
|
sha256: "484838772624c3a4b94f1e44a3e19897fee738f2d5c4ce448443b0417f7c9dda"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.1.3"
|
||||||
|
path_provider_windows:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: path_provider_windows
|
||||||
|
sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.3.0"
|
||||||
permission_handler:
|
permission_handler:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
@@ -448,6 +576,62 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.1.8"
|
version: "2.1.8"
|
||||||
|
shared_preferences:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: shared_preferences
|
||||||
|
sha256: c3025c5534b01739267eb7d76959bbc25a6d10f6988e1c2a3036940133dd10bf
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.5.5"
|
||||||
|
shared_preferences_android:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: shared_preferences_android
|
||||||
|
sha256: "1e12aafe408aa50da80edfd679a2a6bf63ba7ab37c7fa98286da459a757b3399"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.4.28"
|
||||||
|
shared_preferences_foundation:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: shared_preferences_foundation
|
||||||
|
sha256: "2ec3934efa51e46117f23031cc141b8fc878e8525b94ec1ea4f7f586cf1b47ea"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.5.7"
|
||||||
|
shared_preferences_linux:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: shared_preferences_linux
|
||||||
|
sha256: "580abfd40f415611503cae30adf626e6656dfb2f0cee8f465ece7b6defb40f2f"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.4.1"
|
||||||
|
shared_preferences_platform_interface:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: shared_preferences_platform_interface
|
||||||
|
sha256: "649dc798a33931919ea356c4305c2d1f81619ea6e92244070b520187b5140ef9"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.4.2"
|
||||||
|
shared_preferences_web:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: shared_preferences_web
|
||||||
|
sha256: c49bd060261c9a3f0ff445892695d6212ff603ef3115edbb448509d407600019
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.4.3"
|
||||||
|
shared_preferences_windows:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: shared_preferences_windows
|
||||||
|
sha256: "94ef0f72b2d71bc3e700e025db3710911bd51a71cefb65cc609dd0d9a982e3c1"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.4.1"
|
||||||
sky_engine:
|
sky_engine:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description: flutter
|
description: flutter
|
||||||
@@ -517,6 +701,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.4.0"
|
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:
|
vector_math:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -550,7 +742,7 @@ packages:
|
|||||||
source: hosted
|
source: hosted
|
||||||
version: "15.2.0"
|
version: "15.2.0"
|
||||||
web:
|
web:
|
||||||
dependency: transitive
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: web
|
name: web
|
||||||
sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a"
|
sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a"
|
||||||
@@ -573,6 +765,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "3.0.3"
|
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:
|
xdg_directories:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -591,4 +791,4 @@ packages:
|
|||||||
version: "6.6.1"
|
version: "6.6.1"
|
||||||
sdks:
|
sdks:
|
||||||
dart: ">=3.12.2 <4.0.0"
|
dart: ">=3.12.2 <4.0.0"
|
||||||
flutter: ">=3.41.0"
|
flutter: ">=3.44.0"
|
||||||
|
|||||||
@@ -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
|
# 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
|
# 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.
|
# of the product and file versions while build-number is used as the build suffix.
|
||||||
version: 1.0.0+1
|
version: 1.3.0+7
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
sdk: ^3.12.2
|
sdk: ^3.12.2
|
||||||
@@ -41,6 +41,7 @@ dependencies:
|
|||||||
nfc_manager: ^4.2.1
|
nfc_manager: ^4.2.1
|
||||||
nfc_manager_ndef: ^1.1.0
|
nfc_manager_ndef: ^1.1.0
|
||||||
http: ^1.1.0
|
http: ^1.1.0
|
||||||
|
shared_preferences: ^2.3.3
|
||||||
|
|
||||||
# 🔦 주머니 제출 모드 무단 반출 감지용 조도 센서 (Android 전용, iOS는 SensorKit 엔타이틀먼트 필요)
|
# 🔦 주머니 제출 모드 무단 반출 감지용 조도 센서 (Android 전용, iOS는 SensorKit 엔타이틀먼트 필요)
|
||||||
light: ^5.0.0
|
light: ^5.0.0
|
||||||
@@ -52,6 +53,11 @@ dependencies:
|
|||||||
|
|
||||||
# 📳 무단 반출 감지 시 진동 (백그라운드 서비스에서도 동작하도록 HapticFeedback 대신 사용)
|
# 📳 무단 반출 감지 시 진동 (백그라운드 서비스에서도 동작하도록 HapticFeedback 대신 사용)
|
||||||
vibration: ^3.2.0
|
vibration: ^3.2.0
|
||||||
|
excel: ^4.0.6
|
||||||
|
file_picker: ^12.1.2
|
||||||
|
desktop_drop: ^0.8.3
|
||||||
|
web: ^1.1.1
|
||||||
|
flutter_staggered_grid_view: ^0.7.0
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
flutter_test:
|
flutter_test:
|
||||||
@@ -63,11 +69,6 @@ dev_dependencies:
|
|||||||
# package. See that file for information about deactivating specific lint
|
# package. See that file for information about deactivating specific lint
|
||||||
# rules and activating additional ones.
|
# rules and activating additional ones.
|
||||||
flutter_lints: ^6.0.0
|
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:
|
flutter:
|
||||||
|
|
||||||
# The following line ensures that the Material Icons font is
|
# The following line ensures that the Material Icons font is
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 917 B After Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 5.2 KiB After Width: | Height: | Size: 21 KiB |
|
Before Width: | Height: | Size: 8.1 KiB After Width: | Height: | Size: 167 KiB |
|
Before Width: | Height: | Size: 5.5 KiB After Width: | Height: | Size: 21 KiB |
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 167 KiB |
@@ -19,6 +19,10 @@
|
|||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta content="IE=Edge" http-equiv="X-UA-Compatible">
|
<meta content="IE=Edge" http-equiv="X-UA-Compatible">
|
||||||
<meta name="description" content="A new Flutter project.">
|
<meta name="description" content="A new Flutter project.">
|
||||||
|
<!-- 🎨 Safari 등 브라우저가 탭/툴바를 이 색으로 물들이는데, 예전 Flutter 기본 파란색
|
||||||
|
(#0175C2)이 그대로 남아있어서 스크롤 시 파랗게 보였다. 앱 색상으로 교체. -->
|
||||||
|
<meta name="theme-color" content="#1C1C1C">
|
||||||
|
<meta name="msapplication-navbutton-color" content="#1C1C1C">
|
||||||
|
|
||||||
<!-- iOS meta tags & icons -->
|
<!-- iOS meta tags & icons -->
|
||||||
<meta name="mobile-web-app-capable" content="yes">
|
<meta name="mobile-web-app-capable" content="yes">
|
||||||
@@ -31,6 +35,22 @@
|
|||||||
|
|
||||||
<title>school_attendance</title>
|
<title>school_attendance</title>
|
||||||
<link rel="manifest" href="manifest.json">
|
<link rel="manifest" href="manifest.json">
|
||||||
|
|
||||||
|
<!-- 🌊 화면 크기에 딱 맞춰서 페이지 자체가 스크롤/바운스되지 않게 고정한다.
|
||||||
|
position: fixed로 문서를 화면에 못박아 버려서, 트랙패드로 스크롤 경계를
|
||||||
|
넘어가도 사파리/크롬이 바운스할 대상 자체가 없다 (안쪽 콘텐츠 스크롤은
|
||||||
|
Flutter가 알아서 처리하므로 영향 없음). -->
|
||||||
|
<style>
|
||||||
|
html, body {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
overscroll-behavior: none;
|
||||||
|
background-color: #EEF0F2;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<!--
|
<!--
|
||||||
|
|||||||
@@ -3,8 +3,8 @@
|
|||||||
"short_name": "school_attendance",
|
"short_name": "school_attendance",
|
||||||
"start_url": ".",
|
"start_url": ".",
|
||||||
"display": "standalone",
|
"display": "standalone",
|
||||||
"background_color": "#0175C2",
|
"background_color": "#EEF0F2",
|
||||||
"theme_color": "#0175C2",
|
"theme_color": "#1C1C1C",
|
||||||
"description": "A new Flutter project.",
|
"description": "A new Flutter project.",
|
||||||
"orientation": "portrait-primary",
|
"orientation": "portrait-primary",
|
||||||
"prefer_related_applications": false,
|
"prefer_related_applications": false,
|
||||||
|
|||||||
@@ -6,10 +6,13 @@
|
|||||||
|
|
||||||
#include "generated_plugin_registrant.h"
|
#include "generated_plugin_registrant.h"
|
||||||
|
|
||||||
|
#include <desktop_drop/desktop_drop_plugin.h>
|
||||||
#include <firebase_core/firebase_core_plugin_c_api.h>
|
#include <firebase_core/firebase_core_plugin_c_api.h>
|
||||||
#include <permission_handler_windows/permission_handler_windows_plugin.h>
|
#include <permission_handler_windows/permission_handler_windows_plugin.h>
|
||||||
|
|
||||||
void RegisterPlugins(flutter::PluginRegistry* registry) {
|
void RegisterPlugins(flutter::PluginRegistry* registry) {
|
||||||
|
DesktopDropPluginRegisterWithRegistrar(
|
||||||
|
registry->GetRegistrarForPlugin("DesktopDropPlugin"));
|
||||||
FirebaseCorePluginCApiRegisterWithRegistrar(
|
FirebaseCorePluginCApiRegisterWithRegistrar(
|
||||||
registry->GetRegistrarForPlugin("FirebaseCorePluginCApi"));
|
registry->GetRegistrarForPlugin("FirebaseCorePluginCApi"));
|
||||||
PermissionHandlerWindowsPluginRegisterWithRegistrar(
|
PermissionHandlerWindowsPluginRegisterWithRegistrar(
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
#
|
#
|
||||||
|
|
||||||
list(APPEND FLUTTER_PLUGIN_LIST
|
list(APPEND FLUTTER_PLUGIN_LIST
|
||||||
|
desktop_drop
|
||||||
firebase_core
|
firebase_core
|
||||||
permission_handler_windows
|
permission_handler_windows
|
||||||
)
|
)
|
||||||
|
|||||||