Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
@@ -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: "디버그테스트",
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,12 +17,12 @@ class AdminController extends ChangeNotifier {
|
|||||||
try {
|
try {
|
||||||
final response = await http.get(url);
|
final response = await http.get(url);
|
||||||
if (response.statusCode == 200) {
|
if (response.statusCode == 200) {
|
||||||
return '💥 DB가 깔끔하게 초기화되었습니다! (출석 번호 1번부터 시작)';
|
return 'DB가 깔끔하게 초기화되었습니다! (출석 번호 1번부터 시작)';
|
||||||
} else {
|
} else {
|
||||||
return '❌ 초기화 실패: 서버 권한 오류 (${response.statusCode})';
|
return '초기화 실패: 서버 권한 오류 (${response.statusCode})';
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return '❌ 네트워크 에러: 서버와 연결할 수 없습니다.';
|
return '네트워크 에러: 서버와 연결할 수 없습니다.';
|
||||||
} finally {
|
} finally {
|
||||||
_isLoading = false;
|
_isLoading = false;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
|
|||||||
@@ -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,100 @@
|
|||||||
|
// 📋 선생님용 "스마트기기 반출 대장" 화면의 기능(서버 통신/상태) 담당 컨트롤러.
|
||||||
|
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;
|
||||||
|
|
||||||
|
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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -17,10 +17,8 @@ class LoginResult {
|
|||||||
final bool isDeviceMatched;
|
final bool isDeviceMatched;
|
||||||
final String? errorMessage;
|
final String? errorMessage;
|
||||||
|
|
||||||
const LoginResult.masterSuccess({
|
const LoginResult.masterSuccess({required this.studentId, required this.name})
|
||||||
required this.studentId,
|
: outcome = LoginOutcome.masterSuccess,
|
||||||
required this.name,
|
|
||||||
}) : outcome = LoginOutcome.masterSuccess,
|
|
||||||
role = null,
|
role = null,
|
||||||
isDeviceMatched = true,
|
isDeviceMatched = true,
|
||||||
errorMessage = null;
|
errorMessage = null;
|
||||||
@@ -55,16 +53,13 @@ class LoginController extends ChangeNotifier {
|
|||||||
|
|
||||||
Future<LoginResult> login(String id, String password) async {
|
Future<LoginResult> login(String id, String password) async {
|
||||||
if (id.isEmpty || password.isEmpty) {
|
if (id.isEmpty || password.isEmpty) {
|
||||||
return const LoginResult.error('⚠️ 학번과 비밀번호를 모두 입력해 주세요.');
|
return const LoginResult.error('학번과 비밀번호를 모두 입력해 주세요.');
|
||||||
}
|
}
|
||||||
|
|
||||||
// 🔥 [개발자 마스터 계정]
|
// 🔥 [개발자 마스터 계정]
|
||||||
if (id == "2061") {
|
if (id == "2061") {
|
||||||
if (password == "happy9642!") {
|
if (password == "happy9642!") {
|
||||||
return const LoginResult.masterSuccess(
|
return const LoginResult.masterSuccess(studentId: "2061", name: "훗춧가룻");
|
||||||
studentId: "2061",
|
|
||||||
name: "훗춧가룻",
|
|
||||||
);
|
|
||||||
} else {
|
} else {
|
||||||
return const LoginResult.error('개발자 계정의 마스터 비밀번호가 올바르지 않습니다.');
|
return const LoginResult.error('개발자 계정의 마스터 비밀번호가 올바르지 않습니다.');
|
||||||
}
|
}
|
||||||
@@ -200,9 +195,9 @@ class LoginController extends ChangeNotifier {
|
|||||||
final resData = jsonDecode(utf8.decode(response.bodyBytes));
|
final resData = jsonDecode(utf8.decode(response.bodyBytes));
|
||||||
|
|
||||||
if (response.statusCode == 200 && resData['status'] == 'success') {
|
if (response.statusCode == 200 && resData['status'] == 'success') {
|
||||||
return (true, '✅ 비밀번호가 변경되었습니다!');
|
return (true, '비밀번호가 변경되었습니다!');
|
||||||
} else {
|
} else {
|
||||||
return (false, '❌ 실패: ${resData['message']}');
|
return (false, '실패: ${resData['message']}');
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return (false, '서버 에러 발생: $e');
|
return (false, '서버 에러 발생: $e');
|
||||||
|
|||||||
@@ -17,8 +17,12 @@ import '../config.dart' show baseUrl;
|
|||||||
// 🛡️ iOS 근접센서 감시용 네이티브 채널 (ios/Runner/ProximityMonitorPlugin.swift 참고).
|
// 🛡️ iOS 근접센서 감시용 네이티브 채널 (ios/Runner/ProximityMonitorPlugin.swift 참고).
|
||||||
// 조도 센서 API가 없는 iOS에서, 통화 중 화면이 꺼지는 것과 같은 원리인
|
// 조도 센서 API가 없는 iOS에서, 통화 중 화면이 꺼지는 것과 같은 원리인
|
||||||
// 근접센서로 "주머니 안/밖" 상태 변화를 감지해 대체한다.
|
// 근접센서로 "주머니 안/밖" 상태 변화를 감지해 대체한다.
|
||||||
const MethodChannel _proximityMethodChannel = MethodChannel('proximity_monitor');
|
const MethodChannel _proximityMethodChannel = MethodChannel(
|
||||||
const EventChannel _proximityEventChannel = EventChannel('proximity_monitor/events');
|
'proximity_monitor',
|
||||||
|
);
|
||||||
|
const EventChannel _proximityEventChannel = EventChannel(
|
||||||
|
'proximity_monitor/events',
|
||||||
|
);
|
||||||
|
|
||||||
enum WatchMessageLevel { info, success, warning, error }
|
enum WatchMessageLevel { info, success, warning, error }
|
||||||
|
|
||||||
@@ -78,9 +82,9 @@ class NfcPocketCheckinController extends ChangeNotifier {
|
|||||||
// 화면이 열려있는 동안은 실시간으로 위반 알림을 받아 팝업을 띄운다.
|
// 화면이 열려있는 동안은 실시간으로 위반 알림을 받아 팝업을 띄운다.
|
||||||
// 무단 반출이 한 번 감지되면 "신뢰된 감시 세션"은 끝난 것으로 보고,
|
// 무단 반출이 한 번 감지되면 "신뢰된 감시 세션"은 끝난 것으로 보고,
|
||||||
// 다음 태깅은 체크아웃이 아니라 새 출석(재출석)으로 처리되도록 감시 상태를 해제한다.
|
// 다음 태깅은 체크아웃이 아니라 새 출석(재출석)으로 처리되도록 감시 상태를 해제한다.
|
||||||
_violationSub = FlutterBackgroundService().on('violation_detected').listen((
|
_violationSub = FlutterBackgroundService()
|
||||||
event,
|
.on('violation_detected')
|
||||||
) {
|
.listen((event) {
|
||||||
_isWatching = false;
|
_isWatching = false;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
onViolationDetected(event?['pocketNumber']?.toString());
|
onViolationDetected(event?['pocketNumber']?.toString());
|
||||||
@@ -91,9 +95,9 @@ class NfcPocketCheckinController extends ChangeNotifier {
|
|||||||
) {
|
) {
|
||||||
_isWatching = false;
|
_isWatching = false;
|
||||||
_activePocketNumber = null;
|
_activePocketNumber = null;
|
||||||
_statusMessage = "✅ 폰을 회수했습니다. 수고하셨습니다!";
|
_statusMessage = "폰을 회수했습니다. 수고하셨습니다!";
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
onMessage("✅ 폰이 정상적으로 회수되었습니다.", WatchMessageLevel.success);
|
onMessage("폰이 정상적으로 회수되었습니다.", WatchMessageLevel.success);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -117,7 +121,7 @@ class NfcPocketCheckinController extends ChangeNotifier {
|
|||||||
void _startNfcSession() async {
|
void _startNfcSession() async {
|
||||||
bool isAvailable = await NfcManager.instance.isAvailable();
|
bool isAvailable = await NfcManager.instance.isAvailable();
|
||||||
if (!isAvailable) {
|
if (!isAvailable) {
|
||||||
_statusMessage = "❌ 이 스마트폰은 NFC 기능이 꺼져있거나 지원되지 않습니다.";
|
_statusMessage = "이 스마트폰은 NFC 기능이 꺼져있거나 지원되지 않습니다.";
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -134,7 +138,7 @@ class NfcPocketCheckinController extends ChangeNotifier {
|
|||||||
}
|
}
|
||||||
|
|
||||||
_isProcessing = true;
|
_isProcessing = true;
|
||||||
_statusMessage = "⏳ 태그 인식 완료! 서버에 출석 전송 중...";
|
_statusMessage = "태그 인식 완료! 서버에 출석 전송 중...";
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -163,7 +167,7 @@ class NfcPocketCheckinController extends ChangeNotifier {
|
|||||||
tagUid: _getTagUid(tag),
|
tagUid: _getTagUid(tag),
|
||||||
);
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
onMessage("❌ 태그 읽기 실패: $e", WatchMessageLevel.error);
|
onMessage("태그 읽기 실패: $e", WatchMessageLevel.error);
|
||||||
} finally {
|
} finally {
|
||||||
// 3초 후 연타 방지 해제 (쿨다운)
|
// 3초 후 연타 방지 해제 (쿨다운)
|
||||||
await Future.delayed(const Duration(seconds: 3));
|
await Future.delayed(const Duration(seconds: 3));
|
||||||
@@ -216,16 +220,16 @@ class NfcPocketCheckinController extends ChangeNotifier {
|
|||||||
onMessage("ℹ️ 이 기기는 백그라운드 감시를 지원하지 않습니다.", WatchMessageLevel.info);
|
onMessage("ℹ️ 이 기기는 백그라운드 감시를 지원하지 않습니다.", WatchMessageLevel.info);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
onMessage("⚠️ 출석 실패: ${result['message']}", WatchMessageLevel.warning);
|
onMessage("출석 실패: ${result['message']}", WatchMessageLevel.warning);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
onMessage(
|
onMessage(
|
||||||
"🚨 서버 에러 (코드: ${response.statusCode})",
|
"서버 에러 (코드: ${response.statusCode})",
|
||||||
WatchMessageLevel.error,
|
WatchMessageLevel.error,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
onMessage("❌ 서버 연결 실패: $e", WatchMessageLevel.error);
|
onMessage("서버 연결 실패: $e", WatchMessageLevel.error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -255,7 +259,7 @@ class NfcPocketCheckinController extends ChangeNotifier {
|
|||||||
|
|
||||||
_isWatching = true;
|
_isWatching = true;
|
||||||
_activePocketNumber = pocketNumber;
|
_activePocketNumber = pocketNumber;
|
||||||
_statusMessage = "🛡️ 백그라운드에서 감시 중입니다. 화면을 꺼도 계속 감시돼요.";
|
_statusMessage = "백그라운드에서 감시 중입니다. 화면을 꺼도 계속 감시돼요.";
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -268,9 +272,9 @@ class NfcPocketCheckinController extends ChangeNotifier {
|
|||||||
}
|
}
|
||||||
_isWatching = false;
|
_isWatching = false;
|
||||||
_activePocketNumber = null;
|
_activePocketNumber = null;
|
||||||
_statusMessage = "✅ 폰을 회수했습니다. 감시가 종료되었습니다.";
|
_statusMessage = "폰을 회수했습니다. 감시가 종료되었습니다.";
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
onMessage("✅ 감시가 종료되었습니다. 수고하셨습니다!", WatchMessageLevel.success);
|
onMessage("감시가 종료되었습니다. 수고하셨습니다!", WatchMessageLevel.success);
|
||||||
}
|
}
|
||||||
|
|
||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
@@ -291,13 +295,13 @@ class NfcPocketCheckinController extends ChangeNotifier {
|
|||||||
|
|
||||||
_activePocketNumber = pocketNumber;
|
_activePocketNumber = pocketNumber;
|
||||||
_isWatching = true;
|
_isWatching = true;
|
||||||
_statusMessage = "📥 주머니에 넣는 중...";
|
_statusMessage = "주머니에 넣는 중...";
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await _proximityMethodChannel.invokeMethod('start');
|
await _proximityMethodChannel.invokeMethod('start');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
onMessage("❌ 근접센서 감시 시작 실패: $e", WatchMessageLevel.error);
|
onMessage("근접센서 감시 시작 실패: $e", WatchMessageLevel.error);
|
||||||
_isWatching = false;
|
_isWatching = false;
|
||||||
_isIosProximityWatch = false;
|
_isIosProximityWatch = false;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
@@ -307,7 +311,7 @@ class NfcPocketCheckinController extends ChangeNotifier {
|
|||||||
// 학생이 폰을 주머니에 넣을 시간을 준 뒤, 그 시점 상태를 기준값으로 삼는다.
|
// 학생이 폰을 주머니에 넣을 시간을 준 뒤, 그 시점 상태를 기준값으로 삼는다.
|
||||||
await Future.delayed(const Duration(seconds: 4));
|
await Future.delayed(const Duration(seconds: 4));
|
||||||
|
|
||||||
_statusMessage = "🛡️ 감시 중입니다. 화면을 잠그지 말고 주머니에 넣어주세요.";
|
_statusMessage = "감시 중입니다. 화면을 잠그지 말고 주머니에 넣어주세요.";
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
|
|
||||||
_proximitySub = _proximityEventChannel.receiveBroadcastStream().listen((
|
_proximitySub = _proximityEventChannel.receiveBroadcastStream().listen((
|
||||||
@@ -359,9 +363,9 @@ class NfcPocketCheckinController extends ChangeNotifier {
|
|||||||
|
|
||||||
if (isPermitted) {
|
if (isPermitted) {
|
||||||
_activePocketNumber = null;
|
_activePocketNumber = null;
|
||||||
_statusMessage = "✅ 폰을 회수했습니다. 수고하셨습니다!";
|
_statusMessage = "폰을 회수했습니다. 수고하셨습니다!";
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
onMessage("✅ 폰이 정상적으로 회수되었습니다.", WatchMessageLevel.success);
|
onMessage("폰이 정상적으로 회수되었습니다.", WatchMessageLevel.success);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -37,19 +37,19 @@ class NfcTagWriterController extends ChangeNotifier {
|
|||||||
|
|
||||||
Future<void> startWriteSession(String number) async {
|
Future<void> startWriteSession(String number) async {
|
||||||
if (number.isEmpty) {
|
if (number.isEmpty) {
|
||||||
onMessage("⚠️ 주머니 번호를 입력해주세요.", true);
|
onMessage("주머니 번호를 입력해주세요.", true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
final String pocketLabel = "POCKET_$number";
|
final String pocketLabel = "POCKET_$number";
|
||||||
|
|
||||||
bool isAvailable = await NfcManager.instance.isAvailable();
|
bool isAvailable = await NfcManager.instance.isAvailable();
|
||||||
if (!isAvailable) {
|
if (!isAvailable) {
|
||||||
onMessage("❌ NFC가 꺼져있거나 지원되지 않습니다.", true);
|
onMessage("NFC가 꺼져있거나 지원되지 않습니다.", true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
_isWriting = true;
|
_isWriting = true;
|
||||||
_statusMessage = "📡 [$pocketLabel] 쓸 준비 완료! 스티커에 폰 뒷면을 대주세요.";
|
_statusMessage = "[$pocketLabel] 쓸 준비 완료! 스티커에 폰 뒷면을 대주세요.";
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
|
|
||||||
NfcManager.instance.startSession(
|
NfcManager.instance.startSession(
|
||||||
@@ -58,11 +58,11 @@ class NfcTagWriterController extends ChangeNotifier {
|
|||||||
try {
|
try {
|
||||||
final ndef = Ndef.from(tag);
|
final ndef = Ndef.from(tag);
|
||||||
if (ndef == null) {
|
if (ndef == null) {
|
||||||
onMessage("❌ 이 태그는 NDEF 쓰기를 지원하지 않는 종류입니다.", true);
|
onMessage("이 태그는 NDEF 쓰기를 지원하지 않는 종류입니다.", true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!ndef.isWritable) {
|
if (!ndef.isWritable) {
|
||||||
onMessage("❌ 이 태그는 쓰기 잠금(read-only) 상태입니다.", true);
|
onMessage("이 태그는 쓰기 잠금(read-only) 상태입니다.", true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -78,8 +78,8 @@ class NfcTagWriterController extends ChangeNotifier {
|
|||||||
|
|
||||||
onMessage(
|
onMessage(
|
||||||
tagUid != null
|
tagUid != null
|
||||||
? "✅ [$pocketLabel] 쓰기 + UID 등록 성공!"
|
? "[$pocketLabel] 쓰기 + UID 등록 성공!"
|
||||||
: "⚠️ [$pocketLabel] 쓰기는 성공했지만 UID를 못 읽어 등록은 안 됐습니다.",
|
: "[$pocketLabel] 쓰기는 성공했지만 UID를 못 읽어 등록은 안 됐습니다.",
|
||||||
tagUid == null,
|
tagUid == null,
|
||||||
);
|
);
|
||||||
// 다음 스티커를 연달아 쓰기 편하도록 번호를 자동으로 1 올려준다.
|
// 다음 스티커를 연달아 쓰기 편하도록 번호를 자동으로 1 올려준다.
|
||||||
@@ -88,7 +88,7 @@ class NfcTagWriterController extends ChangeNotifier {
|
|||||||
_statusMessage = "다음 번호를 확인하고 '쓰기 시작'을 다시 눌러주세요.";
|
_statusMessage = "다음 번호를 확인하고 '쓰기 시작'을 다시 눌러주세요.";
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
onMessage("❌ 쓰기 실패: $e", true);
|
onMessage("쓰기 실패: $e", true);
|
||||||
} finally {
|
} finally {
|
||||||
await NfcManager.instance.stopSession();
|
await NfcManager.instance.stopSession();
|
||||||
_isWriting = false;
|
_isWriting = false;
|
||||||
@@ -117,7 +117,7 @@ class NfcTagWriterController extends ChangeNotifier {
|
|||||||
body: jsonEncode({"tagUid": tagUid, "pocketNumber": pocketLabel}),
|
body: jsonEncode({"tagUid": tagUid, "pocketNumber": pocketLabel}),
|
||||||
);
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
onMessage("❌ 서버에 UID 등록 실패: $e", true);
|
onMessage("서버에 UID 등록 실패: $e", true);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
// 🔐 "로그인 유지" 기능. 로그인 성공 시 계정 정보를 기기에 저장해두고,
|
||||||
|
// 다음 실행 때 로그인 화면을 건너뛰고 바로 대시보드로 들어가게 한다.
|
||||||
|
// (서버에 별도 세션/토큰 개념이 없어서, 비밀번호는 저장하지 않고 신원 정보만 저장한다.)
|
||||||
|
import 'dart:convert';
|
||||||
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
|
class SavedSession {
|
||||||
|
final String userId;
|
||||||
|
final String userName;
|
||||||
|
final String role;
|
||||||
|
final bool isDeviceMatched;
|
||||||
|
|
||||||
|
const SavedSession({
|
||||||
|
required this.userId,
|
||||||
|
required this.userName,
|
||||||
|
required this.role,
|
||||||
|
required this.isDeviceMatched,
|
||||||
|
});
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() => {
|
||||||
|
'userId': userId,
|
||||||
|
'userName': userName,
|
||||||
|
'role': role,
|
||||||
|
'isDeviceMatched': isDeviceMatched,
|
||||||
|
};
|
||||||
|
|
||||||
|
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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (_) {
|
||||||
|
// 무시 - 어차피 로그아웃 화면으로는 이동한다.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -18,13 +18,13 @@ class StudentDashboardController extends ChangeNotifier {
|
|||||||
final response = await http.delete(url);
|
final response = await http.delete(url);
|
||||||
if (response.statusCode == 200) {
|
if (response.statusCode == 200) {
|
||||||
final responseData = jsonDecode(response.body);
|
final responseData = jsonDecode(response.body);
|
||||||
return (true, '✅ ${responseData['message']}');
|
return (true, '${responseData['message']}');
|
||||||
} else {
|
} else {
|
||||||
final errorData = jsonDecode(response.body);
|
final errorData = jsonDecode(response.body);
|
||||||
return (false, '❌ 삭제 실패: ${errorData['detail'] ?? '알 수 없는 오류'}');
|
return (false, '삭제 실패: ${errorData['detail'] ?? '알 수 없는 오류'}');
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return (false, '❌ 서버와 연결할 수 없습니다. (네트워크 에러)');
|
return (false, '서버와 연결할 수 없습니다. (네트워크 에러)');
|
||||||
} finally {
|
} finally {
|
||||||
_isLoading = false;
|
_isLoading = false;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
|
|||||||
@@ -145,7 +145,7 @@ class TeacherAttendanceController extends ChangeNotifier {
|
|||||||
await fetchAll();
|
await fetchAll();
|
||||||
return result['message'] ?? '하교 처리되었습니다.';
|
return result['message'] ?? '하교 처리되었습니다.';
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return '❌ 하교 처리 실패: $e';
|
return '하교 처리 실패: $e';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -161,7 +161,7 @@ class TeacherAttendanceController extends ChangeNotifier {
|
|||||||
await fetchAll();
|
await fetchAll();
|
||||||
return result['message'] ?? '처리되었습니다.';
|
return result['message'] ?? '처리되었습니다.';
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return '❌ 반출 허용 실패: $e';
|
return '반출 허용 실패: $e';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -177,7 +177,7 @@ class TeacherAttendanceController extends ChangeNotifier {
|
|||||||
await fetchAll();
|
await fetchAll();
|
||||||
return result['message'] ?? '처리되었습니다.';
|
return result['message'] ?? '처리되었습니다.';
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return '❌ 설정 실패: $e';
|
return '설정 실패: $e';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -193,7 +193,7 @@ class TeacherAttendanceController extends ChangeNotifier {
|
|||||||
await fetchAll();
|
await fetchAll();
|
||||||
return result['message'] ?? '처리되었습니다.';
|
return result['message'] ?? '처리되었습니다.';
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return '❌ 출석시간 설정 실패: $e';
|
return '출석시간 설정 실패: $e';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -207,7 +207,7 @@ class TeacherAttendanceController extends ChangeNotifier {
|
|||||||
await fetchAll();
|
await fetchAll();
|
||||||
return result['message'] ?? '처리되었습니다.';
|
return result['message'] ?? '처리되었습니다.';
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return '❌ 초기화 실패: $e';
|
return '초기화 실패: $e';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -36,13 +36,13 @@ class TeacherRegisterController extends ChangeNotifier {
|
|||||||
final res = jsonDecode(response.body);
|
final res = jsonDecode(response.body);
|
||||||
|
|
||||||
if (response.statusCode == 200 && res['status'] == 'success') {
|
if (response.statusCode == 200 && res['status'] == 'success') {
|
||||||
return (true, '✅ ${res['message']}');
|
return (true, '${res['message']}');
|
||||||
} else {
|
} else {
|
||||||
String errorMsg = res['detail'] ?? res['message'] ?? '회원가입에 실패했습니다.';
|
String errorMsg = res['detail'] ?? res['message'] ?? '회원가입에 실패했습니다.';
|
||||||
return (false, '❌ $errorMsg');
|
return (false, errorMsg);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return (false, '❌ 서버와 통신에 실패했습니다.');
|
return (false, '서버와 통신에 실패했습니다.');
|
||||||
} finally {
|
} finally {
|
||||||
_isLoading = false;
|
_isLoading = false;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
|
|||||||
@@ -76,11 +76,11 @@ class TeacherStudentManagementController extends ChangeNotifier {
|
|||||||
);
|
);
|
||||||
if (response.statusCode == 200) {
|
if (response.statusCode == 200) {
|
||||||
await fetchStudents();
|
await fetchStudents();
|
||||||
return (true, '✅ $studentName 학생 기기 초기화 완료! (비밀번호도 1234로 초기화됨)');
|
return (true, '$studentName 학생 기기 초기화 완료! (비밀번호도 1234로 초기화됨)');
|
||||||
}
|
}
|
||||||
return (false, '❌ 초기화 통신 실패');
|
return (false, '초기화 통신 실패');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return (false, '❌ 초기화 통신 실패: $e');
|
return (false, '초기화 통신 실패: $e');
|
||||||
} finally {
|
} finally {
|
||||||
_isWorking = false;
|
_isWorking = false;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
@@ -211,12 +211,12 @@ class TeacherStudentManagementController extends ChangeNotifier {
|
|||||||
final result = jsonDecode(utf8.decode(response.bodyBytes));
|
final result = jsonDecode(utf8.decode(response.bodyBytes));
|
||||||
|
|
||||||
if (response.statusCode == 200 && result['status'] == 'success') {
|
if (response.statusCode == 200 && result['status'] == 'success') {
|
||||||
return (true, '✅ ${result['message'] ?? '계정이 생성되었습니다.'}');
|
return (true, '${result['message'] ?? '계정이 생성되었습니다.'}');
|
||||||
} else {
|
} else {
|
||||||
return (false, '❌ 생성 실패: ${result['message'] ?? '오류 발생'}');
|
return (false, '생성 실패: ${result['message'] ?? '오류 발생'}');
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return (false, '🚨 네트워크 에러: $e');
|
return (false, '네트워크 에러: $e');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -238,12 +238,12 @@ class TeacherStudentManagementController extends ChangeNotifier {
|
|||||||
|
|
||||||
if (response.statusCode == 200 && result['status'] == 'success') {
|
if (response.statusCode == 200 && result['status'] == 'success') {
|
||||||
await fetchStudents();
|
await fetchStudents();
|
||||||
return (true, '✅ ${result['message'] ?? '학년이 지정되었습니다.'}');
|
return (true, '${result['message'] ?? '학년이 지정되었습니다.'}');
|
||||||
} else {
|
} else {
|
||||||
return (false, '❌ 지정 실패: ${result['message'] ?? '오류 발생'}');
|
return (false, '지정 실패: ${result['message'] ?? '오류 발생'}');
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return (false, '🚨 네트워크 에러: $e');
|
return (false, '네트워크 에러: $e');
|
||||||
} finally {
|
} finally {
|
||||||
_isWorking = false;
|
_isWorking = false;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
@@ -266,12 +266,12 @@ class TeacherStudentManagementController extends ChangeNotifier {
|
|||||||
if (response.statusCode == 200) {
|
if (response.statusCode == 200) {
|
||||||
final resData = jsonDecode(utf8.decode(response.bodyBytes));
|
final resData = jsonDecode(utf8.decode(response.bodyBytes));
|
||||||
await fetchStudents();
|
await fetchStudents();
|
||||||
return (true, '✅ ${resData['message']}');
|
return (true, '${resData['message']}');
|
||||||
} else {
|
} else {
|
||||||
return (false, '❌ 삭제 실패하였습니다.');
|
return (false, '삭제 실패하였습니다.');
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return (false, '❌ 서버 에러 발생');
|
return (false, '서버 에러 발생');
|
||||||
} finally {
|
} finally {
|
||||||
_isWorking = false;
|
_isWorking = false;
|
||||||
notifyListeners();
|
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 'theme/app_palette.dart';
|
||||||
import 'ui/login_screen.dart';
|
import 'ui/login_screen.dart';
|
||||||
|
import 'ui/main_dashboard.dart';
|
||||||
|
|
||||||
void main() async {
|
void main() async {
|
||||||
WidgetsFlutterBinding.ensureInitialized();
|
WidgetsFlutterBinding.ensureInitialized();
|
||||||
@@ -27,8 +30,72 @@ class SchoolAttendanceApp extends StatelessWidget {
|
|||||||
return MaterialApp(
|
return MaterialApp(
|
||||||
debugShowCheckedModeBanner: false,
|
debugShowCheckedModeBanner: false,
|
||||||
title: '$schoolName 학생 도우미',
|
title: '$schoolName 학생 도우미',
|
||||||
theme: ThemeData(primarySwatch: Colors.indigo, useMaterial3: true),
|
theme: ThemeData(
|
||||||
home: const LoginScreen(), // 🚪 앱을 켜면 무조건 로그인 화면이 먼저 등장합니다.
|
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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -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(),
|
||||||
@@ -128,7 +128,7 @@ void onPocketWatchServiceStart(ServiceInstance service) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (isPermitted) {
|
if (isPermitted) {
|
||||||
updateNotification("✅ 폰 회수 완료", "[$pocketNumber] 주머니에서 정상적으로 회수되었습니다.");
|
updateNotification("폰 회수 완료", "[$pocketNumber] 주머니에서 정상적으로 회수되었습니다.");
|
||||||
service.invoke('checked_out', {"pocketNumber": pocketNumber});
|
service.invoke('checked_out', {"pocketNumber": pocketNumber});
|
||||||
service.stopSelf();
|
service.stopSelf();
|
||||||
} else {
|
} else {
|
||||||
@@ -145,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;
|
||||||
}
|
}
|
||||||
@@ -163,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");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ 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 '../theme/app_palette.dart';
|
||||||
import '../ui/login_screen.dart';
|
import '../ui/login_screen.dart';
|
||||||
|
|
||||||
// 💡 main.dart 파일의 최하단(다른 클래스 중괄호 밖)에 붙여넣으세요.
|
// 💡 main.dart 파일의 최하단(다른 클래스 중괄호 밖)에 붙여넣으세요.
|
||||||
@@ -22,7 +23,7 @@ class _ChangePasswordScreenState extends State<ChangePasswordScreen> {
|
|||||||
String newPw = _pwController.text.trim();
|
String newPw = _pwController.text.trim();
|
||||||
if (newPw.isEmpty || newPw == "1234") {
|
if (newPw.isEmpty || newPw == "1234") {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
const SnackBar(content: Text('⚠️ 초기 비밀번호와 다른 안전한 비밀번호를 입력하세요.')),
|
const SnackBar(content: Text('초기 비밀번호와 다른 안전한 비밀번호를 입력하세요.')),
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -38,7 +39,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(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
const SnackBar(content: Text('🔒 비밀번호 변경 완료! 다시 로그인해 주세요.')),
|
const SnackBar(content: Text('비밀번호 변경 완료! 다시 로그인해 주세요.')),
|
||||||
);
|
);
|
||||||
// 비밀번호를 바꿨으니 다시 로그인 화면으로 강제 이동
|
// 비밀번호를 바꿨으니 다시 로그인 화면으로 강제 이동
|
||||||
Navigator.pushReplacement(
|
Navigator.pushReplacement(
|
||||||
@@ -49,7 +50,7 @@ class _ChangePasswordScreenState extends State<ChangePasswordScreen> {
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
ScaffoldMessenger.of(
|
ScaffoldMessenger.of(
|
||||||
context,
|
context,
|
||||||
).showSnackBar(const SnackBar(content: Text('❌ 통신 실패')));
|
).showSnackBar(const SnackBar(content: Text('통신 실패')));
|
||||||
} finally {
|
} finally {
|
||||||
setState(() => _isLoading = false);
|
setState(() => _isLoading = false);
|
||||||
}
|
}
|
||||||
@@ -58,6 +59,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 +67,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 +94,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
|
||||||
|
|||||||
@@ -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); // 카드 표면(거의 흰색)
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
// 서버 통신/상태는 lib/function/admin_controller.dart가 담당한다.
|
// 서버 통신/상태는 lib/function/admin_controller.dart가 담당한다.
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import '../function/admin_controller.dart';
|
import '../function/admin_controller.dart';
|
||||||
|
import '../theme/app_palette.dart';
|
||||||
import 'login_screen.dart';
|
import 'login_screen.dart';
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
@@ -37,7 +38,7 @@ class _AdminDashboardState extends State<AdminDashboard> {
|
|||||||
builder: (BuildContext dialogContext) {
|
builder: (BuildContext dialogContext) {
|
||||||
return AlertDialog(
|
return AlertDialog(
|
||||||
title: const Text(
|
title: const Text(
|
||||||
'⚠️ DB 초기화 경고',
|
'DB 초기화 경고',
|
||||||
style: TextStyle(color: Colors.red, fontWeight: FontWeight.bold),
|
style: TextStyle(color: Colors.red, fontWeight: FontWeight.bold),
|
||||||
),
|
),
|
||||||
content: const Text(
|
content: const Text(
|
||||||
@@ -71,10 +72,11 @@ class _AdminDashboardState extends State<AdminDashboard> {
|
|||||||
listenable: _controller,
|
listenable: _controller,
|
||||||
builder: (context, _) {
|
builder: (context, _) {
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
|
backgroundColor: AppPalette.mist,
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
title: const Text('🛠️ 관리자 시스템'),
|
title: const Text('관리자 시스템'),
|
||||||
backgroundColor: Colors.orange,
|
backgroundColor: AppPalette.ink,
|
||||||
foregroundColor: Colors.white,
|
foregroundColor: AppPalette.paper,
|
||||||
actions: [
|
actions: [
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: const Icon(Icons.logout),
|
icon: const Icon(Icons.logout),
|
||||||
@@ -94,7 +96,7 @@ class _AdminDashboardState extends State<AdminDashboard> {
|
|||||||
const Icon(
|
const Icon(
|
||||||
Icons.admin_panel_settings,
|
Icons.admin_panel_settings,
|
||||||
size: 100,
|
size: 100,
|
||||||
color: Colors.orange,
|
color: AppPalette.ink,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 20),
|
const SizedBox(height: 20),
|
||||||
const Text(
|
const Text(
|
||||||
|
|||||||
@@ -0,0 +1,162 @@
|
|||||||
|
// 🔔 스낵바 대신 쓰는 알약(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}) {
|
||||||
|
_queue.add(_QueuedNotice(context, message, icon));
|
||||||
|
_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,
|
||||||
|
onDone: () {
|
||||||
|
entry.remove();
|
||||||
|
_showing = false;
|
||||||
|
_tryShowNext();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
overlay.insert(entry);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _QueuedNotice {
|
||||||
|
final BuildContext context;
|
||||||
|
final String message;
|
||||||
|
final IconData? icon;
|
||||||
|
_QueuedNotice(this.context, this.message, this.icon);
|
||||||
|
}
|
||||||
|
|
||||||
|
class _NoticeBanner extends StatefulWidget {
|
||||||
|
final String message;
|
||||||
|
final IconData? icon;
|
||||||
|
final VoidCallback onDone;
|
||||||
|
|
||||||
|
const _NoticeBanner({
|
||||||
|
required this.message,
|
||||||
|
required this.icon,
|
||||||
|
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: 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,60 @@
|
|||||||
|
// ⚙️ 대시보드 설정 화면 (UI 전용). 지금은 "UI 편집" 진입점 하나만 있다.
|
||||||
|
// "UI 편집"을 누르면 이 화면을 pop('edit_ui')로 닫고, 대시보드가 그 결과를 받아 편집 모드로 들어간다.
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import '../models/dashboard_tile_layout.dart';
|
||||||
|
import '../theme/app_palette.dart';
|
||||||
|
|
||||||
|
class DashboardSettingsPage extends StatelessWidget {
|
||||||
|
const DashboardSettingsPage({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Scaffold(
|
||||||
|
backgroundColor: AppPalette.mist,
|
||||||
|
appBar: AppBar(
|
||||||
|
title: const Text('설정'),
|
||||||
|
backgroundColor: AppPalette.ink,
|
||||||
|
foregroundColor: AppPalette.paper,
|
||||||
|
elevation: 0,
|
||||||
|
),
|
||||||
|
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,319 @@
|
|||||||
|
// 📋 선생님용 "스마트기기 반출 대장" 화면 (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';
|
||||||
|
|
||||||
|
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;
|
||||||
|
ScaffoldMessenger.of(
|
||||||
|
context,
|
||||||
|
).showSnackBar(SnackBar(content: Text(message)));
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _reject(int id) async {
|
||||||
|
final (_, message) = await _controller.reject(id);
|
||||||
|
if (!mounted) return;
|
||||||
|
ScaffoldMessenger.of(
|
||||||
|
context,
|
||||||
|
).showSnackBar(SnackBar(content: Text(message)));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 🖐️ [하드웨어 자동 감지 전까지 임시] 기기를 실제로 돌려받았을 때 누르는 버튼.
|
||||||
|
Future<void> _confirmReturn(int id) async {
|
||||||
|
final (_, message) = await _controller.confirmReturn(id);
|
||||||
|
if (!mounted) return;
|
||||||
|
ScaffoldMessenger.of(
|
||||||
|
context,
|
||||||
|
).showSnackBar(SnackBar(content: Text(message)));
|
||||||
|
}
|
||||||
|
|
||||||
|
({Color color, String label}) _statusInfo(String status) {
|
||||||
|
switch (status) {
|
||||||
|
case 'PENDING':
|
||||||
|
return (color: Colors.orange, label: '대기중');
|
||||||
|
case 'APPROVED':
|
||||||
|
return (color: Colors.blue, label: '승인됨');
|
||||||
|
case 'RETURNED':
|
||||||
|
return (color: Colors.grey, label: '반납완료');
|
||||||
|
case 'REJECTED':
|
||||||
|
return (color: Colors.red, label: '거절됨');
|
||||||
|
default:
|
||||||
|
return (color: Colors.grey, label: status);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String _timeRange(String start, String end) {
|
||||||
|
// "YYYY-MM-DD HH:MM:SS" -> "HH:MM"
|
||||||
|
String hm(String full) => full.length >= 16 ? full.substring(11, 16) : full;
|
||||||
|
return '${hm(start)} ~ ${hm(end)}';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return ListenableBuilder(
|
||||||
|
listenable: _controller,
|
||||||
|
builder: (context, _) {
|
||||||
|
final requests = _controller.requests;
|
||||||
|
final pendingCount = requests
|
||||||
|
.where((r) => r['status'] == 'PENDING')
|
||||||
|
.length;
|
||||||
|
|
||||||
|
return Scaffold(
|
||||||
|
backgroundColor: AppPalette.mist,
|
||||||
|
appBar: AppBar(
|
||||||
|
title: const Text(
|
||||||
|
'스마트기기 반출 대장',
|
||||||
|
style: TextStyle(fontWeight: FontWeight.bold),
|
||||||
|
),
|
||||||
|
backgroundColor: AppPalette.ink,
|
||||||
|
foregroundColor: Colors.white,
|
||||||
|
elevation: 0,
|
||||||
|
actions: [
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.refresh_rounded),
|
||||||
|
onPressed: _controller.fetchAll,
|
||||||
|
tooltip: '새로고침',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
body: _controller.isLoading
|
||||||
|
? const Center(child: CircularProgressIndicator())
|
||||||
|
: Column(
|
||||||
|
children: [
|
||||||
|
if (pendingCount > 0)
|
||||||
|
Container(
|
||||||
|
width: double.infinity,
|
||||||
|
margin: const EdgeInsets.all(16),
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 16,
|
||||||
|
vertical: 10,
|
||||||
|
),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.orange[100],
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
border: Border.all(color: Colors.orange[400]!),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
'승인 대기 중인 요청이 $pendingCount건 있습니다.',
|
||||||
|
style: TextStyle(
|
||||||
|
color: Colors.orange[900],
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: requests.isEmpty
|
||||||
|
? const Center(
|
||||||
|
child: Text(
|
||||||
|
'오늘 신청된 반출 요청이 없습니다.',
|
||||||
|
style: TextStyle(color: Colors.grey),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: ListView.builder(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 16,
|
||||||
|
vertical: 8,
|
||||||
|
),
|
||||||
|
itemCount: requests.length,
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final r = requests[index];
|
||||||
|
final String status = r['status'];
|
||||||
|
final info = _statusInfo(status);
|
||||||
|
final bool isPending = status == 'PENDING';
|
||||||
|
|
||||||
|
return Card(
|
||||||
|
elevation: 0,
|
||||||
|
color: AppPalette.paper,
|
||||||
|
margin: const EdgeInsets.only(bottom: 10),
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
side: 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,194 @@
|
|||||||
|
// 📱 스마트기기(패드) 반출 신청 화면 (UI 전용). 시작~종료 시각과 사용 목적을 입력받는다.
|
||||||
|
// 서버 통신/상태는 lib/function/device_checkout_controller.dart가 담당한다.
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import '../function/device_checkout_controller.dart';
|
||||||
|
import '../theme/app_palette.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) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(content: Text('시작/종료 시각과 사용 목적을 모두 입력해주세요.')),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final (success, message) = await _controller.submitRequest(
|
||||||
|
studentId: widget.studentId,
|
||||||
|
studentName: widget.studentName,
|
||||||
|
purpose: purpose,
|
||||||
|
startTime: _formatTime(_startTime!),
|
||||||
|
endTime: _formatTime(_endTime!),
|
||||||
|
);
|
||||||
|
if (!mounted) return;
|
||||||
|
ScaffoldMessenger.of(
|
||||||
|
context,
|
||||||
|
).showSnackBar(SnackBar(content: Text(message)));
|
||||||
|
if (success) Navigator.pop(context);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return ListenableBuilder(
|
||||||
|
listenable: _controller,
|
||||||
|
builder: (context, _) {
|
||||||
|
return Scaffold(
|
||||||
|
backgroundColor: AppPalette.mist,
|
||||||
|
appBar: AppBar(
|
||||||
|
title: const Text(
|
||||||
|
'스마트기기 반출 신청',
|
||||||
|
style: TextStyle(fontWeight: FontWeight.bold),
|
||||||
|
),
|
||||||
|
backgroundColor: AppPalette.ink,
|
||||||
|
foregroundColor: Colors.white,
|
||||||
|
elevation: 0,
|
||||||
|
),
|
||||||
|
body: SingleChildScrollView(
|
||||||
|
padding: const EdgeInsets.all(24.0),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
const Text(
|
||||||
|
'패드는 NFC 태그가 안 되니, 사용 시간과 목적을 적어 신청하면\n'
|
||||||
|
'선생님이 확인하고 승인해줍니다. 승인되면 반납 바구니에서 꺼내 쓰세요.',
|
||||||
|
style: TextStyle(
|
||||||
|
color: Colors.black54,
|
||||||
|
fontSize: 13,
|
||||||
|
height: 1.4,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.all(20),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: 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,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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -3,9 +3,9 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import '../config.dart' show schoolName;
|
import '../config.dart' show schoolName;
|
||||||
import '../function/login_controller.dart';
|
import '../function/login_controller.dart';
|
||||||
import 'student_dashboard.dart';
|
import '../function/session_store.dart';
|
||||||
import 'teacher_dashboard.dart';
|
import '../theme/app_palette.dart';
|
||||||
import 'admin_dashboard.dart';
|
import 'main_dashboard.dart';
|
||||||
import 'teacher_register_screen.dart';
|
import 'teacher_register_screen.dart';
|
||||||
|
|
||||||
// -------------------------------------------------------------
|
// -------------------------------------------------------------
|
||||||
@@ -23,6 +23,7 @@ class _LoginScreenState extends State<LoginScreen> {
|
|||||||
final LoginController _controller = LoginController();
|
final LoginController _controller = LoginController();
|
||||||
final TextEditingController _idController = TextEditingController();
|
final TextEditingController _idController = TextEditingController();
|
||||||
final TextEditingController _pwController = TextEditingController();
|
final TextEditingController _pwController = TextEditingController();
|
||||||
|
bool _keepLoggedIn = true;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
@@ -44,15 +45,29 @@ class _LoginScreenState extends State<LoginScreen> {
|
|||||||
_showErrorDialog(result.errorMessage!);
|
_showErrorDialog(result.errorMessage!);
|
||||||
break;
|
break;
|
||||||
case LoginOutcome.masterSuccess:
|
case LoginOutcome.masterSuccess:
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
if (_keepLoggedIn) {
|
||||||
const SnackBar(content: Text('👑 개발자 최고 권한으로 로그인되었습니다.')),
|
await SessionStore.save(
|
||||||
|
SavedSession(
|
||||||
|
userId: result.studentId!,
|
||||||
|
userName: result.name!,
|
||||||
|
role: 'student',
|
||||||
|
isDeviceMatched: true,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
|
} else {
|
||||||
|
await SessionStore.clear();
|
||||||
|
}
|
||||||
|
if (!mounted) return;
|
||||||
|
ScaffoldMessenger.of(
|
||||||
|
context,
|
||||||
|
).showSnackBar(const SnackBar(content: Text('개발자 최고 권한으로 로그인되었습니다.')));
|
||||||
Navigator.pushReplacement(
|
Navigator.pushReplacement(
|
||||||
context,
|
context,
|
||||||
MaterialPageRoute(
|
MaterialPageRoute(
|
||||||
builder: (context) => StudentDashboard(
|
builder: (context) => MainDashboard(
|
||||||
studentId: result.studentId!,
|
userId: result.studentId!,
|
||||||
studentName: result.name!,
|
userName: result.name!,
|
||||||
|
role: 'student',
|
||||||
isDeviceMatched: true,
|
isDeviceMatched: true,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -69,7 +84,7 @@ class _LoginScreenState extends State<LoginScreen> {
|
|||||||
case LoginOutcome.success:
|
case LoginOutcome.success:
|
||||||
ScaffoldMessenger.of(
|
ScaffoldMessenger.of(
|
||||||
context,
|
context,
|
||||||
).showSnackBar(SnackBar(content: Text('✅ ${result.name}님 환영합니다!')));
|
).showSnackBar(SnackBar(content: Text('${result.name}님 환영합니다!')));
|
||||||
_navigateBasedOnRole(
|
_navigateBasedOnRole(
|
||||||
result.role!,
|
result.role!,
|
||||||
result.studentId!,
|
result.studentId!,
|
||||||
@@ -99,7 +114,7 @@ class _LoginScreenState extends State<LoginScreen> {
|
|||||||
builder: (context, setDialogState) {
|
builder: (context, setDialogState) {
|
||||||
return AlertDialog(
|
return AlertDialog(
|
||||||
title: const Text(
|
title: const Text(
|
||||||
'🔒 초기 비밀번호 변경',
|
'초기 비밀번호 변경',
|
||||||
style: TextStyle(fontWeight: FontWeight.bold),
|
style: TextStyle(fontWeight: FontWeight.bold),
|
||||||
),
|
),
|
||||||
content: Column(
|
content: Column(
|
||||||
@@ -129,16 +144,14 @@ class _LoginScreenState extends State<LoginScreen> {
|
|||||||
)
|
)
|
||||||
: ElevatedButton(
|
: ElevatedButton(
|
||||||
style: ElevatedButton.styleFrom(
|
style: ElevatedButton.styleFrom(
|
||||||
backgroundColor: Colors.indigo,
|
backgroundColor: AppPalette.ink,
|
||||||
foregroundColor: Colors.white,
|
foregroundColor: Colors.white,
|
||||||
),
|
),
|
||||||
onPressed: () async {
|
onPressed: () async {
|
||||||
String newPassword = newPwController.text.trim();
|
String newPassword = newPwController.text.trim();
|
||||||
if (newPassword.isEmpty) {
|
if (newPassword.isEmpty) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
const SnackBar(
|
const SnackBar(content: Text('새 비밀번호를 입력해 주세요.')),
|
||||||
content: Text('⚠️ 새 비밀번호를 입력해 주세요.'),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -154,9 +167,9 @@ class _LoginScreenState extends State<LoginScreen> {
|
|||||||
|
|
||||||
if (success) {
|
if (success) {
|
||||||
Navigator.pop(dialogContext);
|
Navigator.pop(dialogContext);
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(
|
||||||
SnackBar(content: Text(message)),
|
context,
|
||||||
);
|
).showSnackBar(SnackBar(content: Text(message)));
|
||||||
_navigateBasedOnRole(
|
_navigateBasedOnRole(
|
||||||
role,
|
role,
|
||||||
studentId,
|
studentId,
|
||||||
@@ -164,9 +177,9 @@ class _LoginScreenState extends State<LoginScreen> {
|
|||||||
isDeviceMatched,
|
isDeviceMatched,
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(
|
||||||
SnackBar(content: Text(message)),
|
context,
|
||||||
);
|
).showSnackBar(SnackBar(content: Text(message)));
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
child: const Text('변경하고 시작하기'),
|
child: const Text('변경하고 시작하기'),
|
||||||
@@ -180,45 +193,44 @@ class _LoginScreenState extends State<LoginScreen> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 🆕 권한별 화면 이동시 기기 일치 여부 파라미터(`isDeviceMatched`) 수신 및 대시보드 전달
|
// 🆕 권한별 화면 이동시 기기 일치 여부 파라미터(`isDeviceMatched`) 수신 및 대시보드 전달
|
||||||
void _navigateBasedOnRole(
|
Future<void> _navigateBasedOnRole(
|
||||||
String role,
|
String role,
|
||||||
String studentId,
|
String studentId,
|
||||||
String name,
|
String name,
|
||||||
bool isDeviceMatched,
|
bool isDeviceMatched,
|
||||||
) {
|
) async {
|
||||||
if (role == 'teacher') {
|
if (_keepLoggedIn) {
|
||||||
Navigator.pushReplacement(
|
await SessionStore.save(
|
||||||
context,
|
SavedSession(
|
||||||
MaterialPageRoute(
|
userId: studentId,
|
||||||
builder: (context) =>
|
userName: name,
|
||||||
TeacherDashboard(teacherId: studentId, teacherName: name),
|
role: role,
|
||||||
|
isDeviceMatched: isDeviceMatched,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
} else if (role == 'admin') {
|
|
||||||
Navigator.pushReplacement(
|
|
||||||
context,
|
|
||||||
MaterialPageRoute(builder: (context) => const AdminDashboard()),
|
|
||||||
);
|
|
||||||
} else {
|
} else {
|
||||||
|
await SessionStore.clear();
|
||||||
|
}
|
||||||
|
if (!mounted) return;
|
||||||
Navigator.pushReplacement(
|
Navigator.pushReplacement(
|
||||||
context,
|
context,
|
||||||
MaterialPageRoute(
|
MaterialPageRoute(
|
||||||
builder: (context) => StudentDashboard(
|
builder: (context) => MainDashboard(
|
||||||
studentId: studentId,
|
userId: studentId,
|
||||||
studentName: name,
|
userName: name,
|
||||||
|
role: role,
|
||||||
isDeviceMatched: isDeviceMatched,
|
isDeviceMatched: isDeviceMatched,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
void _showErrorDialog(String message) {
|
void _showErrorDialog(String message) {
|
||||||
showDialog(
|
showDialog(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (ctx) => AlertDialog(
|
builder: (ctx) => AlertDialog(
|
||||||
title: const Text(
|
title: const Text(
|
||||||
'⚠️ 인증 실패',
|
'인증 실패',
|
||||||
style: TextStyle(fontWeight: FontWeight.bold),
|
style: TextStyle(fontWeight: FontWeight.bold),
|
||||||
),
|
),
|
||||||
content: Text(message),
|
content: Text(message),
|
||||||
@@ -243,7 +255,7 @@ class _LoginScreenState extends State<LoginScreen> {
|
|||||||
barrierDismissible: false,
|
barrierDismissible: false,
|
||||||
builder: (BuildContext dialogContext) {
|
builder: (BuildContext dialogContext) {
|
||||||
return AlertDialog(
|
return AlertDialog(
|
||||||
title: Text('🔒 $title 권한 인증 (기존 방식)'),
|
title: Text('$title 권한 인증 (기존 방식)'),
|
||||||
content: TextField(
|
content: TextField(
|
||||||
controller: passwordController,
|
controller: passwordController,
|
||||||
obscureText: true,
|
obscureText: true,
|
||||||
@@ -258,7 +270,7 @@ class _LoginScreenState extends State<LoginScreen> {
|
|||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
const SnackBar(content: Text('❌ 비밀번호가 올바르지 않습니다.')),
|
const SnackBar(content: Text('비밀번호가 올바르지 않습니다.')),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -282,7 +294,7 @@ class _LoginScreenState extends State<LoginScreen> {
|
|||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
const SnackBar(content: Text('❌ 비밀번호가 올바르지 않습니다.')),
|
const SnackBar(content: Text('비밀번호가 올바르지 않습니다.')),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -300,7 +312,7 @@ class _LoginScreenState extends State<LoginScreen> {
|
|||||||
listenable: _controller,
|
listenable: _controller,
|
||||||
builder: (context, _) {
|
builder: (context, _) {
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
backgroundColor: Colors.grey[50],
|
backgroundColor: AppPalette.mist,
|
||||||
body: Center(
|
body: Center(
|
||||||
child: SingleChildScrollView(
|
child: SingleChildScrollView(
|
||||||
padding: const EdgeInsets.all(24.0),
|
padding: const EdgeInsets.all(24.0),
|
||||||
@@ -309,14 +321,14 @@ class _LoginScreenState extends State<LoginScreen> {
|
|||||||
child: Column(
|
child: Column(
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
const Icon(Icons.school, size: 80, color: Colors.indigo),
|
const Icon(Icons.school, size: 80, color: AppPalette.ink),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
Text(
|
Text(
|
||||||
'$schoolName 모니터',
|
'$schoolName 모니터',
|
||||||
style: const TextStyle(
|
style: const TextStyle(
|
||||||
fontSize: 26,
|
fontSize: 26,
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
color: Colors.indigo,
|
color: AppPalette.ink,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
@@ -346,7 +358,31 @@ class _LoginScreenState extends State<LoginScreen> {
|
|||||||
prefixIcon: Icon(Icons.lock),
|
prefixIcon: Icon(Icons.lock),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 24),
|
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
|
_controller.isLoading
|
||||||
? const CircularProgressIndicator()
|
? const CircularProgressIndicator()
|
||||||
: SizedBox(
|
: SizedBox(
|
||||||
@@ -354,7 +390,7 @@ class _LoginScreenState extends State<LoginScreen> {
|
|||||||
height: 55,
|
height: 55,
|
||||||
child: ElevatedButton(
|
child: ElevatedButton(
|
||||||
style: ElevatedButton.styleFrom(
|
style: ElevatedButton.styleFrom(
|
||||||
backgroundColor: Colors.indigo,
|
backgroundColor: AppPalette.ink,
|
||||||
foregroundColor: Colors.white,
|
foregroundColor: Colors.white,
|
||||||
shape: RoundedRectangleBorder(
|
shape: RoundedRectangleBorder(
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
@@ -379,9 +415,9 @@ class _LoginScreenState extends State<LoginScreen> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
child: const Text(
|
child: const Text(
|
||||||
'👨🏫 선생님이신가요? 교사 회원가입 하기',
|
'선생님이신가요? 교사 회원가입 하기',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
color: Colors.indigo,
|
color: AppPalette.ink,
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -403,7 +439,7 @@ class _LoginScreenState extends State<LoginScreen> {
|
|||||||
onPressed: () => _showLegacyPasswordDialog(
|
onPressed: () => _showLegacyPasswordDialog(
|
||||||
'선생님',
|
'선생님',
|
||||||
'1234',
|
'1234',
|
||||||
const TeacherDashboard(),
|
const MainDashboard(role: 'teacher'),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
TextButton.icon(
|
TextButton.icon(
|
||||||
@@ -419,7 +455,7 @@ class _LoginScreenState extends State<LoginScreen> {
|
|||||||
onPressed: () => _showLegacyPasswordDialog(
|
onPressed: () => _showLegacyPasswordDialog(
|
||||||
'시스템 관리자',
|
'시스템 관리자',
|
||||||
'4936',
|
'4936',
|
||||||
const AdminDashboard(),
|
const MainDashboard(role: 'admin'),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -0,0 +1,948 @@
|
|||||||
|
// 🏠 통합 대시보드 (UI 전용). 학생/교사/관리자가 전부 이 화면 하나를 공유하고,
|
||||||
|
// 권한(role)에 따라 배너 색상/문구와 보이는 타일만 달라진다.
|
||||||
|
// 타일 배치(위치/크기)는 사용자가 직접 편집할 수 있고, 계정별로 서버에 저장된다
|
||||||
|
// (lib/function/dashboard_layout_controller.dart). 캔버스는 가로 6칸×세로 4칸,
|
||||||
|
// 타일 크기는 1x1~4x4.
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_staggered_grid_view/flutter_staggered_grid_view.dart';
|
||||||
|
import '../function/dashboard_layout_controller.dart';
|
||||||
|
import '../function/session_store.dart';
|
||||||
|
import '../function/student_dashboard_controller.dart';
|
||||||
|
import '../models/dashboard_tile_layout.dart';
|
||||||
|
import '../theme/app_palette.dart';
|
||||||
|
import 'admin_dashboard.dart';
|
||||||
|
import 'app_notice.dart';
|
||||||
|
import 'dashboard_settings_page.dart';
|
||||||
|
import 'device_checkout_ledger_page.dart';
|
||||||
|
import 'device_checkout_request_screen.dart';
|
||||||
|
import 'launchpad_transition.dart';
|
||||||
|
import 'login_screen.dart';
|
||||||
|
import 'nfc_poccket_checkin_screen.dart';
|
||||||
|
import 'nfc_tag_writer_screen.dart';
|
||||||
|
import 'teacher_attendance_page.dart';
|
||||||
|
import 'teacher_student_management_page.dart';
|
||||||
|
|
||||||
|
class MainDashboard extends StatefulWidget {
|
||||||
|
final String? userId;
|
||||||
|
final String? userName;
|
||||||
|
final String role; // 'student' | 'teacher' | 'admin'
|
||||||
|
final bool isDeviceMatched;
|
||||||
|
|
||||||
|
const MainDashboard({
|
||||||
|
super.key,
|
||||||
|
this.userId,
|
||||||
|
this.userName,
|
||||||
|
required this.role,
|
||||||
|
this.isDeviceMatched = true,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<MainDashboard> createState() => _MainDashboardState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _MainDashboardState extends State<MainDashboard> {
|
||||||
|
final StudentDashboardController _controller = StudentDashboardController();
|
||||||
|
late final DashboardLayoutController _layout;
|
||||||
|
|
||||||
|
// 🎯 타일 이동/크기조절 드래그 중 기준값(드래그 시작 시점의 좌표)을 담아둔다.
|
||||||
|
Offset? _dragStartGlobal;
|
||||||
|
TileRect? _dragStartRect;
|
||||||
|
|
||||||
|
// 🔑 [권한 판정] 2061은 role이 'student'로 저장돼 있지만 실질적으로 최고 권한을 가진다.
|
||||||
|
bool get _isDeveloper => widget.userId == '2061';
|
||||||
|
bool get _isAdmin => widget.role == 'admin' || _isDeveloper;
|
||||||
|
bool get _isTeacherOrAbove => widget.role == 'teacher' || _isAdmin;
|
||||||
|
bool get _isStudent => widget.role == 'student';
|
||||||
|
|
||||||
|
String get _displayId =>
|
||||||
|
widget.userId ?? (widget.role == 'admin' ? '관리자' : '간편인증');
|
||||||
|
String get _displayName =>
|
||||||
|
widget.userName ?? (widget.role == 'admin' ? '시스템 관리자' : '간편인증 선생');
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_layout = DashboardLayoutController(_displayId);
|
||||||
|
// 📱 [기능 보류] kTileEditingEnabled가 false인 동안은 서버에서 배치를 불러올 필요가 없다.
|
||||||
|
if (kTileEditingEnabled) {
|
||||||
|
_layout.load(_tileSpecs().map((t) => t.id).toList());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_controller.dispose();
|
||||||
|
_layout.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _openPocketCheckIn(BuildContext context) {
|
||||||
|
pushLaunchpad(
|
||||||
|
context,
|
||||||
|
(context) => NfcPocketCheckInScreen(
|
||||||
|
studentId: _displayId,
|
||||||
|
studentName: _displayName,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _deleteUser(String userId) async {
|
||||||
|
final (_, message) = await _controller.deleteUser(userId);
|
||||||
|
if (!mounted) return;
|
||||||
|
AppNotice.show(context, message);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _showDeleteUserDialog(BuildContext context) {
|
||||||
|
final TextEditingController idController = TextEditingController();
|
||||||
|
|
||||||
|
showDialog(
|
||||||
|
context: context,
|
||||||
|
builder: (context) {
|
||||||
|
return AlertDialog(
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(24),
|
||||||
|
),
|
||||||
|
title: const Row(
|
||||||
|
children: [
|
||||||
|
Icon(Icons.warning_amber_rounded, color: Colors.redAccent),
|
||||||
|
SizedBox(width: 10),
|
||||||
|
Text(
|
||||||
|
'계정 강제 삭제',
|
||||||
|
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 18),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
content: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
const Text(
|
||||||
|
'학생의 학번 또는 교사의 교직원 번호를 입력하세요.\nDB에서 해당 계정과 토큰이 즉시 삭제됩니다.',
|
||||||
|
style: TextStyle(
|
||||||
|
color: Colors.black54,
|
||||||
|
fontSize: 13,
|
||||||
|
height: 1.4,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
TextField(
|
||||||
|
controller: idController,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
labelText: '학번 또는 교직원 번호',
|
||||||
|
hintText: '예: 201101 또는 T1001',
|
||||||
|
labelStyle: TextStyle(color: Colors.red[400]),
|
||||||
|
focusedBorder: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
borderSide: BorderSide(color: Colors.red[400]!, width: 2),
|
||||||
|
),
|
||||||
|
border: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
),
|
||||||
|
prefixIcon: const Icon(Icons.person_remove_alt_1_rounded),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(context),
|
||||||
|
child: const Text(
|
||||||
|
'취소',
|
||||||
|
style: TextStyle(
|
||||||
|
color: Colors.grey,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
ElevatedButton(
|
||||||
|
onPressed: () {
|
||||||
|
final inputId = idController.text.trim();
|
||||||
|
if (inputId.isNotEmpty) {
|
||||||
|
Navigator.pop(context);
|
||||||
|
_deleteUser(inputId);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
backgroundColor: Colors.redAccent,
|
||||||
|
foregroundColor: Colors.white,
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
elevation: 0,
|
||||||
|
),
|
||||||
|
child: const Text(
|
||||||
|
'삭제 실행',
|
||||||
|
style: TextStyle(fontWeight: FontWeight.bold),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _logout(BuildContext context) async {
|
||||||
|
await SessionStore.clear();
|
||||||
|
if (!context.mounted) return;
|
||||||
|
Navigator.pushReplacement(
|
||||||
|
context,
|
||||||
|
MaterialPageRoute(builder: (context) => const LoginScreen()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 🚀 오른쪽 위 ">" 버튼 — 로그아웃/설정을 런치패드 스타일 메뉴로 띄운다.
|
||||||
|
Future<void> _openMenu(BuildContext context) async {
|
||||||
|
final action = await showLaunchpadMenu<String>(
|
||||||
|
context: context,
|
||||||
|
builder: (context) => Align(
|
||||||
|
alignment: Alignment.topRight,
|
||||||
|
child: SafeArea(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 8, right: 12),
|
||||||
|
child: _DashboardMenuCard(
|
||||||
|
onLogout: () => Navigator.pop(context, 'logout'),
|
||||||
|
onSettings: () => Navigator.pop(context, 'settings'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (!context.mounted) return;
|
||||||
|
if (action == 'logout') {
|
||||||
|
_logout(context);
|
||||||
|
} else if (action == 'settings') {
|
||||||
|
_openSettings(context);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _openSettings(BuildContext context) async {
|
||||||
|
final result = await pushLaunchpad<String>(
|
||||||
|
context,
|
||||||
|
(context) => const DashboardSettingsPage(),
|
||||||
|
);
|
||||||
|
if (result == 'edit_ui' && kTileEditingEnabled) {
|
||||||
|
_layout.enterEditMode();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 🎨 [역할별 테마] 배너 색상은 팔레트로 통일하고, 제목/문구/아이콘만 역할에 따라 다르게.
|
||||||
|
({String title, String subtitle, Color color, IconData icon}) _theme() {
|
||||||
|
if (_isDeveloper) {
|
||||||
|
return (
|
||||||
|
title: 'MASTER CONTROL',
|
||||||
|
subtitle: '최고 관리 권한 활성화됨',
|
||||||
|
color: AppPalette.ink,
|
||||||
|
icon: Icons.admin_panel_settings_rounded,
|
||||||
|
);
|
||||||
|
} else if (widget.role == 'admin') {
|
||||||
|
return (
|
||||||
|
title: 'ADMIN CONTROL',
|
||||||
|
subtitle: '시스템 관리자 권한 활성화됨',
|
||||||
|
color: AppPalette.ink,
|
||||||
|
icon: Icons.admin_panel_settings_rounded,
|
||||||
|
);
|
||||||
|
} else if (widget.role == 'teacher') {
|
||||||
|
return (
|
||||||
|
title: 'TEACHER PORTAL',
|
||||||
|
subtitle: '교직원 번호: $_displayId | 교사 권한 활성화됨',
|
||||||
|
color: AppPalette.ink,
|
||||||
|
icon: Icons.admin_panel_settings_rounded,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
title: 'STUDENT PORTAL',
|
||||||
|
subtitle: '학번: $_displayId | 인증 완료',
|
||||||
|
color: AppPalette.ink,
|
||||||
|
icon: Icons.school_rounded,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 🧩 역할에 따라 보이는 타일 목록. id는 서버 레이아웃 저장의 키로 쓰이므로 절대 바뀌면 안 된다.
|
||||||
|
List<({String id, Widget child})> _tileSpecs() {
|
||||||
|
final List<({String id, Widget child})> tiles = [];
|
||||||
|
|
||||||
|
if (_isStudent) {
|
||||||
|
tiles.add((
|
||||||
|
id: 'nfc_tag',
|
||||||
|
child: _buildModernCard(
|
||||||
|
icon: widget.isDeviceMatched
|
||||||
|
? Icons.contactless_rounded
|
||||||
|
: Icons.lock_rounded,
|
||||||
|
title: 'NFC 태그',
|
||||||
|
subtitle: widget.isDeviceMatched ? '출석 및 폰 수거 완료' : '본인 인증 기기 전용',
|
||||||
|
color: widget.isDeviceMatched ? AppPalette.ink : AppPalette.sage,
|
||||||
|
onTap: widget.isDeviceMatched
|
||||||
|
? () => _openPocketCheckIn(context)
|
||||||
|
: () => AppNotice.show(
|
||||||
|
context,
|
||||||
|
'대리 출석 방지를 위해 등록된 본인 스마트폰에서만 출석 가능합니다.',
|
||||||
|
icon: Icons.lock_rounded,
|
||||||
|
),
|
||||||
|
isActionButton: widget.isDeviceMatched,
|
||||||
|
isLoading: _controller.isLoading,
|
||||||
|
),
|
||||||
|
));
|
||||||
|
tiles.add((
|
||||||
|
id: 'school_status',
|
||||||
|
child: _buildModernCard(
|
||||||
|
icon: Icons.fastfood_rounded,
|
||||||
|
title: '실시간 학교 상황',
|
||||||
|
subtitle: '급식실 줄 & 매점 재고 확인',
|
||||||
|
color: AppPalette.ink,
|
||||||
|
onTap: () => AppNotice.show(
|
||||||
|
context,
|
||||||
|
'실시간 학교 상황 페이지로 이동합니다.',
|
||||||
|
icon: Icons.fastfood_rounded,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
));
|
||||||
|
tiles.add((
|
||||||
|
id: 'device_checkout_request',
|
||||||
|
child: _buildModernCard(
|
||||||
|
icon: Icons.tablet_mac_rounded,
|
||||||
|
title: '스마트기기 반출',
|
||||||
|
subtitle: '패드 사용 신청 (시간/목적)',
|
||||||
|
color: AppPalette.ink,
|
||||||
|
onTap: () => pushLaunchpad(
|
||||||
|
context,
|
||||||
|
(context) => DeviceCheckoutRequestScreen(
|
||||||
|
studentId: _displayId,
|
||||||
|
studentName: _displayName,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_isTeacherOrAbove) {
|
||||||
|
tiles.add((
|
||||||
|
id: 'attendance_check',
|
||||||
|
child: _buildModernCard(
|
||||||
|
icon: Icons.assignment_turned_in_rounded,
|
||||||
|
title: '실시간 출석 확인',
|
||||||
|
subtitle: '학생 제출 로그 모니터링',
|
||||||
|
color: AppPalette.ink,
|
||||||
|
onTap: () => pushLaunchpad(
|
||||||
|
context,
|
||||||
|
(context) => const TeacherAttendancePage(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
));
|
||||||
|
tiles.add((
|
||||||
|
id: 'student_management',
|
||||||
|
child: _buildModernCard(
|
||||||
|
icon: Icons.manage_accounts_rounded,
|
||||||
|
title: '학생 계정 관리',
|
||||||
|
subtitle: '계정 추가 및 강제 리셋',
|
||||||
|
color: AppPalette.ink,
|
||||||
|
onTap: () => pushLaunchpad(
|
||||||
|
context,
|
||||||
|
(context) => const TeacherStudentManagementPage(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
));
|
||||||
|
tiles.add((
|
||||||
|
id: 'device_checkout_ledger',
|
||||||
|
child: _buildModernCard(
|
||||||
|
icon: Icons.tablet_mac_rounded,
|
||||||
|
title: '스마트기기 반출 대장',
|
||||||
|
subtitle: '패드 반출 신청 승인/거절',
|
||||||
|
color: AppPalette.ink,
|
||||||
|
onTap: () => pushLaunchpad(
|
||||||
|
context,
|
||||||
|
(context) => DeviceCheckoutLedgerPage(
|
||||||
|
teacherId: widget.userId,
|
||||||
|
teacherName: widget.userName,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_isAdmin) {
|
||||||
|
tiles.add((
|
||||||
|
id: 'admin_db',
|
||||||
|
child: _buildModernCard(
|
||||||
|
icon: Icons.terminal_rounded,
|
||||||
|
title: '서버 DB 제어',
|
||||||
|
subtitle: '시스템 원격 초기화',
|
||||||
|
color: AppPalette.ink,
|
||||||
|
onTap: () =>
|
||||||
|
pushLaunchpad(context, (context) => const AdminDashboard()),
|
||||||
|
),
|
||||||
|
));
|
||||||
|
tiles.add((
|
||||||
|
id: 'delete_account',
|
||||||
|
child: _buildModernCard(
|
||||||
|
icon: Icons.delete_sweep_rounded,
|
||||||
|
title: '계정 강제 삭제',
|
||||||
|
subtitle: '학생 및 교사 DB 삭제',
|
||||||
|
color: AppPalette.ink,
|
||||||
|
onTap: () => _showDeleteUserDialog(context),
|
||||||
|
),
|
||||||
|
));
|
||||||
|
tiles.add((
|
||||||
|
id: 'nfc_tag_writer',
|
||||||
|
child: _buildModernCard(
|
||||||
|
icon: Icons.edit_note_rounded,
|
||||||
|
title: 'NFC 태그 쓰기',
|
||||||
|
subtitle: '주머니 스티커 초기 설정',
|
||||||
|
color: AppPalette.ink,
|
||||||
|
onTap: () =>
|
||||||
|
pushLaunchpad(context, (context) => const NfcTagWriterScreen()),
|
||||||
|
),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
return tiles;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────
|
||||||
|
// 🖐️ 편집 모드: 드래그로 이동, 모서리 손잡이로 크기 조절 (1x1~4x4).
|
||||||
|
// ─────────────────────────────────────────────────────────
|
||||||
|
void _onMovePanStart(String id, DragStartDetails details) {
|
||||||
|
_dragStartGlobal = details.globalPosition;
|
||||||
|
_dragStartRect = _layout.positions[id];
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onMovePanUpdate(String id, double cellSize, DragUpdateDetails details) {
|
||||||
|
if (_dragStartGlobal == null || _dragStartRect == null) return;
|
||||||
|
final delta = details.globalPosition - _dragStartGlobal!;
|
||||||
|
final dx = (delta.dx / cellSize).round();
|
||||||
|
final dy = (delta.dy / cellSize).round();
|
||||||
|
_layout.tryMove(id, _dragStartRect!.x + dx, _dragStartRect!.y + dy);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onMovePanEnd(DragEndDetails details) {
|
||||||
|
_dragStartGlobal = null;
|
||||||
|
_dragStartRect = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onResizePanStart(String id, DragStartDetails details) {
|
||||||
|
_dragStartGlobal = details.globalPosition;
|
||||||
|
_dragStartRect = _layout.positions[id];
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onResizePanUpdate(
|
||||||
|
String id,
|
||||||
|
double cellSize,
|
||||||
|
DragUpdateDetails details,
|
||||||
|
) {
|
||||||
|
if (_dragStartGlobal == null || _dragStartRect == null) return;
|
||||||
|
final delta = details.globalPosition - _dragStartGlobal!;
|
||||||
|
final dw = (delta.dx / cellSize).round();
|
||||||
|
final dh = (delta.dy / cellSize).round();
|
||||||
|
_layout.tryResize(id, _dragStartRect!.w + dw, _dragStartRect!.h + dh);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _saveAndExitEdit() async {
|
||||||
|
await _layout.save();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return ListenableBuilder(
|
||||||
|
listenable: Listenable.merge([_controller, _layout]),
|
||||||
|
builder: (context, _) {
|
||||||
|
final theme = _theme();
|
||||||
|
final tiles = _tileSpecs();
|
||||||
|
final bool isEditing = _layout.isEditing;
|
||||||
|
|
||||||
|
return Scaffold(
|
||||||
|
backgroundColor: AppPalette.mist,
|
||||||
|
// 🪶 큰 색상 배너 대신 작은 프로필 카드를 본문에 두므로, 앱바는 ">" 메뉴
|
||||||
|
// 버튼(또는 편집 중 "완료" 버튼)만 올려두는 투명한 얇은 바로 둔다.
|
||||||
|
appBar: AppBar(
|
||||||
|
backgroundColor: Colors.transparent,
|
||||||
|
foregroundColor: AppPalette.ink,
|
||||||
|
elevation: 0,
|
||||||
|
actions: isEditing
|
||||||
|
? [
|
||||||
|
TextButton.icon(
|
||||||
|
onPressed: _layout.isSaving ? null : _saveAndExitEdit,
|
||||||
|
icon: _layout.isSaving
|
||||||
|
? const SizedBox(
|
||||||
|
width: 16,
|
||||||
|
height: 16,
|
||||||
|
child: CircularProgressIndicator(strokeWidth: 2),
|
||||||
|
)
|
||||||
|
: const Icon(Icons.check_rounded),
|
||||||
|
label: const Text('완료'),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
: [
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.chevron_right_rounded),
|
||||||
|
tooltip: '메뉴',
|
||||||
|
onPressed: () => _openMenu(context),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
body: SingleChildScrollView(
|
||||||
|
physics: isEditing
|
||||||
|
? const NeverScrollableScrollPhysics()
|
||||||
|
: const AlwaysScrollableScrollPhysics(),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
// 🪪 작은 프로필 카드로 배치 — 화면 전체를 차지하던 배너 대신
|
||||||
|
// 가운데에 떠 있는 카드 하나만 둔다.
|
||||||
|
Center(
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 28,
|
||||||
|
vertical: 18,
|
||||||
|
),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: theme.color,
|
||||||
|
borderRadius: BorderRadius.circular(24),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: theme.color.withValues(alpha: 0.25),
|
||||||
|
blurRadius: 16,
|
||||||
|
offset: const Offset(0, 6),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
theme.title,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
letterSpacing: 1.2,
|
||||||
|
color: Colors.white,
|
||||||
|
fontSize: 13,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.all(4),
|
||||||
|
decoration: const BoxDecoration(
|
||||||
|
color: Colors.white,
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
),
|
||||||
|
child: CircleAvatar(
|
||||||
|
radius: 22,
|
||||||
|
backgroundColor: AppPalette.linen,
|
||||||
|
child: Icon(
|
||||||
|
theme.icon,
|
||||||
|
size: 22,
|
||||||
|
color: theme.color,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
Text(
|
||||||
|
'$_displayName 님',
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 17,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: Colors.white,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 3),
|
||||||
|
Text(
|
||||||
|
theme.subtitle,
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: TextStyle(
|
||||||
|
color: Colors.white.withValues(alpha: 0.8),
|
||||||
|
fontSize: 12,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 28),
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 24.0),
|
||||||
|
child: Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
width: 4,
|
||||||
|
height: 18,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: theme.color,
|
||||||
|
borderRadius: BorderRadius.circular(2),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 10),
|
||||||
|
const Text(
|
||||||
|
'스마트 관리 시스템 메뉴',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: AppPalette.ink,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (isEditing)
|
||||||
|
const Padding(
|
||||||
|
padding: EdgeInsets.only(top: 8, left: 24, right: 24),
|
||||||
|
child: Text(
|
||||||
|
'타일을 드래그해서 옮기고, 오른쪽 아래 손잡이로 크기를 바꿔보세요.',
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: TextStyle(color: AppPalette.sage, fontSize: 12),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
// 📱 [기능 보류] kTileEditingEnabled가 꺼져 있는 동안은 화면 크기에 맞춰
|
||||||
|
// 알아서 줄서는 기본 그리드를 쓰고, 드래그 편집용 캔버스는 쓰지 않는다.
|
||||||
|
kTileEditingEnabled
|
||||||
|
? (_layout.isLoading
|
||||||
|
? const Padding(
|
||||||
|
padding: EdgeInsets.symmetric(vertical: 40),
|
||||||
|
child: Center(child: CircularProgressIndicator()),
|
||||||
|
)
|
||||||
|
: _buildGridCanvas(tiles, isEditing))
|
||||||
|
: _buildSimpleGrid(tiles),
|
||||||
|
const SizedBox(height: 32),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 🧱 타일마다 크기(가로/세로 칸 수)가 다른 매이슨리(벽돌쌓기) 그리드.
|
||||||
|
// 화면 폭에 맞춰 열 개수가 자동으로 바뀌기 때문에 폰에서도 안 깨진다.
|
||||||
|
// id는 서버 레이아웃 저장용 키와 같지만, 여기서는 그냥 "이 타일이 몇 칸짜리인지" 찾는 데만 쓴다.
|
||||||
|
static const Map<String, (int w, int h)> _tileSpans = {
|
||||||
|
'nfc_tag': (1, 2),
|
||||||
|
'attendance_check': (2, 1),
|
||||||
|
'admin_db': (2, 1),
|
||||||
|
};
|
||||||
|
|
||||||
|
(int, int) _spanFor(String id) => _tileSpans[id] ?? (1, 1);
|
||||||
|
|
||||||
|
Widget _buildSimpleGrid(List<({String id, Widget child})> tiles) {
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 24.0),
|
||||||
|
child: LayoutBuilder(
|
||||||
|
builder: (context, constraints) {
|
||||||
|
final double width = constraints.maxWidth;
|
||||||
|
// 🔲 정사각형 1칸 기준 4×3 배치 — 화면이 넓어도 6칸까지 늘리지 않고 4칸에서 멈춘다.
|
||||||
|
final int crossAxisCount = width < 420
|
||||||
|
? 2
|
||||||
|
: width < 700
|
||||||
|
? 3
|
||||||
|
: 4;
|
||||||
|
// 📏 1칸(1x1) 크기가 대략 계정 프로필 카드만 해지도록 목표 크기를 잡고,
|
||||||
|
// 화면이 그보다 넓으면 가운데로 모아서 여백을 준다(좁으면 화면 폭에 맞춤).
|
||||||
|
const double targetCellSize = 184;
|
||||||
|
const double gap = 12;
|
||||||
|
final double idealGridWidth =
|
||||||
|
crossAxisCount * targetCellSize + (crossAxisCount - 1) * gap;
|
||||||
|
final double gridWidth = width < idealGridWidth
|
||||||
|
? width
|
||||||
|
: idealGridWidth;
|
||||||
|
|
||||||
|
return Center(
|
||||||
|
child: SizedBox(
|
||||||
|
width: gridWidth,
|
||||||
|
child: StaggeredGrid.count(
|
||||||
|
crossAxisCount: crossAxisCount,
|
||||||
|
mainAxisSpacing: gap,
|
||||||
|
crossAxisSpacing: gap,
|
||||||
|
children: [
|
||||||
|
for (final tile in tiles)
|
||||||
|
StaggeredGridTile.count(
|
||||||
|
crossAxisCellCount: _spanFor(
|
||||||
|
tile.id,
|
||||||
|
).$1.clamp(1, crossAxisCount),
|
||||||
|
mainAxisCellCount: _spanFor(tile.id).$2,
|
||||||
|
child: tile.child,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildGridCanvas(
|
||||||
|
List<({String id, Widget child})> tiles,
|
||||||
|
bool isEditing,
|
||||||
|
) {
|
||||||
|
const double gap = 10;
|
||||||
|
const double maxCanvasWidth = 720;
|
||||||
|
|
||||||
|
return LayoutBuilder(
|
||||||
|
builder: (context, constraints) {
|
||||||
|
final double canvasWidth = constraints.maxWidth < maxCanvasWidth
|
||||||
|
? constraints.maxWidth - 24
|
||||||
|
: maxCanvasWidth;
|
||||||
|
final double cellSize = canvasWidth / kGridCols;
|
||||||
|
final double canvasHeight = cellSize * kGridRows;
|
||||||
|
|
||||||
|
final List<Widget> positioned = [];
|
||||||
|
for (final tile in tiles) {
|
||||||
|
final rect = _layout.positions[tile.id];
|
||||||
|
if (rect == null) continue; // 캔버스가 꽉 차서 자리를 못 찾은 타일
|
||||||
|
|
||||||
|
final double left = rect.x * cellSize;
|
||||||
|
final double top = rect.y * cellSize;
|
||||||
|
final double w = rect.w * cellSize - gap;
|
||||||
|
final double h = rect.h * cellSize - gap;
|
||||||
|
|
||||||
|
positioned.add(
|
||||||
|
Positioned(
|
||||||
|
key: ValueKey(tile.id),
|
||||||
|
left: left,
|
||||||
|
top: top,
|
||||||
|
width: w,
|
||||||
|
height: h,
|
||||||
|
child: Stack(
|
||||||
|
clipBehavior: Clip.none,
|
||||||
|
children: [
|
||||||
|
Positioned.fill(
|
||||||
|
child: IgnorePointer(
|
||||||
|
ignoring: isEditing,
|
||||||
|
child: tile.child,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (isEditing)
|
||||||
|
Positioned.fill(
|
||||||
|
child: GestureDetector(
|
||||||
|
behavior: HitTestBehavior.translucent,
|
||||||
|
onPanStart: (d) => _onMovePanStart(tile.id, d),
|
||||||
|
onPanUpdate: (d) =>
|
||||||
|
_onMovePanUpdate(tile.id, cellSize, d),
|
||||||
|
onPanEnd: _onMovePanEnd,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (isEditing)
|
||||||
|
Positioned(
|
||||||
|
right: 2,
|
||||||
|
bottom: 2,
|
||||||
|
child: GestureDetector(
|
||||||
|
onPanStart: (d) => _onResizePanStart(tile.id, d),
|
||||||
|
onPanUpdate: (d) =>
|
||||||
|
_onResizePanUpdate(tile.id, cellSize, d),
|
||||||
|
onPanEnd: _onMovePanEnd,
|
||||||
|
child: Container(
|
||||||
|
width: 26,
|
||||||
|
height: 26,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: AppPalette.ink,
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
border: Border.all(
|
||||||
|
color: AppPalette.paper,
|
||||||
|
width: 2,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: const Icon(
|
||||||
|
Icons.open_in_full_rounded,
|
||||||
|
size: 14,
|
||||||
|
color: AppPalette.paper,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Center(
|
||||||
|
child: SizedBox(
|
||||||
|
width: canvasWidth,
|
||||||
|
height: canvasHeight,
|
||||||
|
child: Stack(children: positioned),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 💎 [카드 디자인 위젯] 타일 크기가 커지면 아이콘/글자도 비례해서 커진다.
|
||||||
|
Widget _buildModernCard({
|
||||||
|
required IconData icon,
|
||||||
|
required String title,
|
||||||
|
required String subtitle,
|
||||||
|
required Color color,
|
||||||
|
required VoidCallback onTap,
|
||||||
|
bool isActionButton = false,
|
||||||
|
bool isLoading = false,
|
||||||
|
}) {
|
||||||
|
return LayoutBuilder(
|
||||||
|
builder: (context, constraints) {
|
||||||
|
// 📏 타일의 실제 픽셀 크기를 기준으로 배율을 계산 (1x1 기준 ≈ 1.0).
|
||||||
|
// 가로/세로 중 더 작은 쪽을 기준으로 삼아야, 가로로 넓지만 낮은 타일에서
|
||||||
|
// 배율이 과하게 커져 내용이 넘치는 걸 막을 수 있다.
|
||||||
|
final double referenceSize = constraints.hasBoundedHeight
|
||||||
|
? constraints.maxWidth < constraints.maxHeight
|
||||||
|
? constraints.maxWidth
|
||||||
|
: constraints.maxHeight
|
||||||
|
: constraints.maxWidth;
|
||||||
|
final double scale = (referenceSize / 110).clamp(1.0, 3.4);
|
||||||
|
final double iconBoxPadding = 6 + 6 * (scale - 1);
|
||||||
|
final double iconSize = 16 + 10 * (scale - 1);
|
||||||
|
final double iconRadius = 10 + 6 * (scale - 1);
|
||||||
|
final double cardPadding = 8 + 8 * (scale - 1);
|
||||||
|
final double cardRadius = 16 + 6 * (scale - 1);
|
||||||
|
final double titleFontSize = (10 + 3 * (scale - 1)).clamp(10, 18);
|
||||||
|
final double subtitleFontSize = (8 + 2 * (scale - 1)).clamp(8, 14);
|
||||||
|
|
||||||
|
return InkWell(
|
||||||
|
onTap: isLoading ? null : onTap,
|
||||||
|
borderRadius: BorderRadius.circular(cardRadius),
|
||||||
|
child: Ink(
|
||||||
|
padding: EdgeInsets.all(cardPadding),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: AppPalette.paper,
|
||||||
|
borderRadius: BorderRadius.circular(cardRadius),
|
||||||
|
border: Border.all(color: AppPalette.sage, width: 1),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
padding: EdgeInsets.all(iconBoxPadding),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: AppPalette.linen,
|
||||||
|
borderRadius: BorderRadius.circular(iconRadius),
|
||||||
|
),
|
||||||
|
child: Icon(icon, color: color, size: iconSize),
|
||||||
|
),
|
||||||
|
Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
title,
|
||||||
|
maxLines: 2,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: titleFontSize,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: AppPalette.ink,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (isLoading)
|
||||||
|
const SizedBox(
|
||||||
|
width: 14,
|
||||||
|
height: 14,
|
||||||
|
child: CircularProgressIndicator(strokeWidth: 2),
|
||||||
|
)
|
||||||
|
else if (isActionButton)
|
||||||
|
Icon(
|
||||||
|
Icons.touch_app_rounded,
|
||||||
|
size: 14,
|
||||||
|
color: color.withValues(alpha: 0.5),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
SizedBox(height: 1 + 2 * (scale - 1)),
|
||||||
|
Text(
|
||||||
|
subtitle,
|
||||||
|
maxLines: 2,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: subtitleFontSize,
|
||||||
|
color: AppPalette.ink.withValues(alpha: 0.55),
|
||||||
|
height: 1.15,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 🚀 오른쪽 위 ">" 버튼을 누르면 뜨는 런치패드 스타일 메뉴 카드.
|
||||||
|
class _DashboardMenuCard extends StatelessWidget {
|
||||||
|
final VoidCallback onLogout;
|
||||||
|
final VoidCallback onSettings;
|
||||||
|
|
||||||
|
const _DashboardMenuCard({required this.onLogout, required this.onSettings});
|
||||||
|
|
||||||
|
@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: 180),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
_MenuRow(
|
||||||
|
icon: Icons.logout_rounded,
|
||||||
|
label: '로그아웃',
|
||||||
|
onTap: onLogout,
|
||||||
|
),
|
||||||
|
const Divider(height: 1, color: AppPalette.sage),
|
||||||
|
_MenuRow(
|
||||||
|
icon: Icons.settings_outlined,
|
||||||
|
label: '설정',
|
||||||
|
onTap: onSettings,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _MenuRow extends StatelessWidget {
|
||||||
|
final IconData icon;
|
||||||
|
final String label;
|
||||||
|
final VoidCallback onTap;
|
||||||
|
|
||||||
|
const _MenuRow({
|
||||||
|
required this.icon,
|
||||||
|
required this.label,
|
||||||
|
required this.onTap,
|
||||||
|
});
|
||||||
|
|
||||||
|
@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: [
|
||||||
|
Icon(icon, size: 20, color: AppPalette.ink),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Text(
|
||||||
|
label,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: AppPalette.ink,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -72,7 +72,7 @@ class _NfcPocketCheckInScreenState extends State<NfcPocketCheckInScreen> {
|
|||||||
builder: (context) => AlertDialog(
|
builder: (context) => AlertDialog(
|
||||||
backgroundColor: Colors.red[50],
|
backgroundColor: Colors.red[50],
|
||||||
title: const Text(
|
title: const Text(
|
||||||
"🚨 무단 반출 감지",
|
"무단 반출 감지",
|
||||||
style: TextStyle(color: Colors.red, fontWeight: FontWeight.bold),
|
style: TextStyle(color: Colors.red, fontWeight: FontWeight.bold),
|
||||||
),
|
),
|
||||||
content: Text(
|
content: Text(
|
||||||
@@ -98,7 +98,7 @@ class _NfcPocketCheckInScreenState extends State<NfcPocketCheckInScreen> {
|
|||||||
context: context,
|
context: context,
|
||||||
barrierDismissible: false,
|
barrierDismissible: false,
|
||||||
builder: (context) => AlertDialog(
|
builder: (context) => AlertDialog(
|
||||||
title: const Text("🛡️ 감시 모드 켜기 전에"),
|
title: const Text("감시 모드 켜기 전에"),
|
||||||
content: const Text(
|
content: const Text(
|
||||||
"iOS는 화면을 잠그면 감시가 끊겨요. 아래 순서로 '가이드 접근'을 먼저 켜주세요.\n\n"
|
"iOS는 화면을 잠그면 감시가 끊겨요. 아래 순서로 '가이드 접근'을 먼저 켜주세요.\n\n"
|
||||||
"1. 사이드(또는 홈) 버튼 3번 빠르게 누르기\n"
|
"1. 사이드(또는 홈) 버튼 3번 빠르게 누르기\n"
|
||||||
@@ -127,9 +127,9 @@ class _NfcPocketCheckInScreenState extends State<NfcPocketCheckInScreen> {
|
|||||||
showDialog(
|
showDialog(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (context) => AlertDialog(
|
builder: (context) => AlertDialog(
|
||||||
title: const Text("🎉 자습실 출석 완료!"),
|
title: const Text("자습실 출석 완료!"),
|
||||||
content: Text(
|
content: Text(
|
||||||
"${widget.studentName} 학생!\n[$pocketNumber] 주머니 출석이 확인되었습니다.\n\n폰을 주머니에 쏙 넣고 자습에 집중해 주세요! ✏️",
|
"${widget.studentName} 학생!\n[$pocketNumber] 주머니 출석이 확인되었습니다.\n\n폰을 주머니에 쏙 넣고 자습에 집중해 주세요!",
|
||||||
),
|
),
|
||||||
actions: [
|
actions: [
|
||||||
TextButton(
|
TextButton(
|
||||||
@@ -161,7 +161,10 @@ class _NfcPocketCheckInScreenState extends State<NfcPocketCheckInScreen> {
|
|||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
title: const Text(
|
title: const Text(
|
||||||
"자습실 NFC 출석체크",
|
"자습실 NFC 출석체크",
|
||||||
style: TextStyle(color: Colors.white, fontWeight: FontWeight.bold),
|
style: TextStyle(
|
||||||
|
color: Colors.white,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
backgroundColor: const Color.fromARGB(255, 48, 48, 52),
|
backgroundColor: const Color.fromARGB(255, 48, 48, 52),
|
||||||
),
|
),
|
||||||
@@ -258,8 +261,8 @@ class _NfcPocketCheckInScreenState extends State<NfcPocketCheckInScreen> {
|
|||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
Text(
|
Text(
|
||||||
!kIsWeb && Platform.isIOS
|
!kIsWeb && Platform.isIOS
|
||||||
? "🔒 가이드 접근을 켠 채로 화면만 잠기도록 두세요.\n(임의로 앱을 나가면 감시가 끊깁니다)\n감시를 끝내려면 가이드 접근을 먼저 끄고 다시 태깅하세요."
|
? "가이드 접근을 켠 채로 화면만 잠기도록 두세요.\n(임의로 앱을 나가면 감시가 끊깁니다)\n감시를 끝내려면 가이드 접근을 먼저 끄고 다시 태깅하세요."
|
||||||
: "📴 화면을 꺼도 감시는 계속됩니다.\n알림바에서 상태를 확인할 수 있어요.\n(폰을 꺼내 다시 태깅하면 반출 처리됩니다)",
|
: "화면을 꺼도 감시는 계속됩니다.\n알림바에서 상태를 확인할 수 있어요.\n(폰을 꺼내 다시 태깅하면 반출 처리됩니다)",
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
style: const TextStyle(color: Colors.white38, fontSize: 13),
|
style: const TextStyle(color: Colors.white38, fontSize: 13),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
// 버튼/입력폼을 그린다. NFC 세션/서버 통신은 lib/function/nfc_tag_writer_controller.dart가 담당한다.
|
// 버튼/입력폼을 그린다. NFC 세션/서버 통신은 lib/function/nfc_tag_writer_controller.dart가 담당한다.
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import '../function/nfc_tag_writer_controller.dart';
|
import '../function/nfc_tag_writer_controller.dart';
|
||||||
|
import '../theme/app_palette.dart';
|
||||||
|
|
||||||
class NfcTagWriterScreen extends StatefulWidget {
|
class NfcTagWriterScreen extends StatefulWidget {
|
||||||
const NfcTagWriterScreen({super.key});
|
const NfcTagWriterScreen({super.key});
|
||||||
@@ -49,10 +50,11 @@ class _NfcTagWriterScreenState extends State<NfcTagWriterScreen> {
|
|||||||
builder: (context, _) {
|
builder: (context, _) {
|
||||||
final bool isWriting = _controller.isWriting;
|
final bool isWriting = _controller.isWriting;
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
|
backgroundColor: AppPalette.mist,
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
title: const Text("🏷️ NFC 주머니 태그 쓰기"),
|
title: const Text("NFC 주머니 태그 쓰기"),
|
||||||
backgroundColor: Colors.deepPurple,
|
backgroundColor: AppPalette.ink,
|
||||||
foregroundColor: Colors.white,
|
foregroundColor: AppPalette.paper,
|
||||||
),
|
),
|
||||||
body: Padding(
|
body: Padding(
|
||||||
padding: const EdgeInsets.all(24.0),
|
padding: const EdgeInsets.all(24.0),
|
||||||
@@ -73,7 +75,7 @@ class _NfcTagWriterScreenState extends State<NfcTagWriterScreen> {
|
|||||||
Icon(
|
Icon(
|
||||||
isWriting ? Icons.nfc : Icons.edit_note_rounded,
|
isWriting ? Icons.nfc : Icons.edit_note_rounded,
|
||||||
size: 80,
|
size: 80,
|
||||||
color: isWriting ? Colors.orange : Colors.deepPurple,
|
color: isWriting ? Colors.orange : AppPalette.ink,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
Text(
|
Text(
|
||||||
@@ -87,8 +89,8 @@ class _NfcTagWriterScreenState extends State<NfcTagWriterScreen> {
|
|||||||
height: 55,
|
height: 55,
|
||||||
child: ElevatedButton(
|
child: ElevatedButton(
|
||||||
style: ElevatedButton.styleFrom(
|
style: ElevatedButton.styleFrom(
|
||||||
backgroundColor: Colors.deepPurple,
|
backgroundColor: AppPalette.ink,
|
||||||
foregroundColor: Colors.white,
|
foregroundColor: AppPalette.paper,
|
||||||
),
|
),
|
||||||
onPressed: isWriting
|
onPressed: isWriting
|
||||||
? null
|
? null
|
||||||
|
|||||||
@@ -1,511 +0,0 @@
|
|||||||
// 🎓 학생 대시보드 (UI 전용). NFC 주머니 체크인 진입, 실시간 학교 상황 안내,
|
|
||||||
// 개발자 마스터 계정 전용 관리 메뉴(현황/DB제어/계정관리/삭제) 카드를 그린다.
|
|
||||||
// 계정 삭제 서버 통신은 lib/function/student_dashboard_controller.dart가 담당한다.
|
|
||||||
import 'package:flutter/material.dart';
|
|
||||||
import '../function/student_dashboard_controller.dart';
|
|
||||||
import 'nfc_poccket_checkin_screen.dart';
|
|
||||||
import 'login_screen.dart';
|
|
||||||
import 'teacher_attendance_page.dart';
|
|
||||||
import 'admin_dashboard.dart';
|
|
||||||
import 'teacher_student_management_page.dart';
|
|
||||||
import 'nfc_tag_writer_screen.dart';
|
|
||||||
|
|
||||||
// -------------------------------------------------------------
|
|
||||||
// 2. 학생 대시보드 (StudentDashboard)
|
|
||||||
// -------------------------------------------------------------
|
|
||||||
|
|
||||||
class StudentDashboard extends StatefulWidget {
|
|
||||||
final String studentId;
|
|
||||||
final String studentName;
|
|
||||||
final bool isDeviceMatched; // 🆕 [변경] 기기 UUID가 매칭되었는지 확인하는 변수 추가
|
|
||||||
|
|
||||||
const StudentDashboard({
|
|
||||||
super.key,
|
|
||||||
required this.studentId,
|
|
||||||
required this.studentName,
|
|
||||||
required this.isDeviceMatched, // 🆕 [변경] 필수 매개변수로 등록
|
|
||||||
});
|
|
||||||
|
|
||||||
@override
|
|
||||||
State<StudentDashboard> createState() => _StudentDashboardState();
|
|
||||||
}
|
|
||||||
|
|
||||||
class _StudentDashboardState extends State<StudentDashboard> {
|
|
||||||
final StudentDashboardController _controller = StudentDashboardController();
|
|
||||||
|
|
||||||
@override
|
|
||||||
void dispose() {
|
|
||||||
_controller.dispose();
|
|
||||||
super.dispose();
|
|
||||||
}
|
|
||||||
|
|
||||||
// 1️⃣ NFC 태그 카드 → 실제 NFC 주머니 체크인 화면으로 이동
|
|
||||||
void _openPocketCheckIn(BuildContext context) {
|
|
||||||
Navigator.push(
|
|
||||||
context,
|
|
||||||
MaterialPageRoute(
|
|
||||||
builder: (context) => NfcPocketCheckInScreen(
|
|
||||||
studentId: widget.studentId,
|
|
||||||
studentName: widget.studentName,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2️⃣ [기존 동일] 마스터 계정 전용 회원 삭제 버튼 동작
|
|
||||||
Future<void> _deleteUser(String userId) async {
|
|
||||||
final (_, message) = await _controller.deleteUser(userId);
|
|
||||||
if (!mounted) return;
|
|
||||||
ScaffoldMessenger.of(
|
|
||||||
context,
|
|
||||||
).showSnackBar(SnackBar(content: Text(message)));
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3️⃣ [기존 동일] 학번/교직원 번호를 입력받는 모던 팝업창(Dialog)
|
|
||||||
void _showDeleteUserDialog(BuildContext context) {
|
|
||||||
final TextEditingController idController = TextEditingController();
|
|
||||||
|
|
||||||
showDialog(
|
|
||||||
context: context,
|
|
||||||
builder: (context) {
|
|
||||||
return AlertDialog(
|
|
||||||
shape: RoundedRectangleBorder(
|
|
||||||
borderRadius: BorderRadius.circular(24),
|
|
||||||
),
|
|
||||||
title: const Row(
|
|
||||||
children: [
|
|
||||||
Icon(Icons.warning_amber_rounded, color: Colors.redAccent),
|
|
||||||
SizedBox(width: 10),
|
|
||||||
Text(
|
|
||||||
'계정 강제 삭제',
|
|
||||||
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 18),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
content: Column(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
const Text(
|
|
||||||
'학생의 학번 또는 교사의 교직원 번호를 입력하세요.\nDB에서 해당 계정과 토큰이 즉시 삭제됩니다.',
|
|
||||||
style: TextStyle(
|
|
||||||
color: Colors.black54,
|
|
||||||
fontSize: 13,
|
|
||||||
height: 1.4,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
TextField(
|
|
||||||
controller: idController,
|
|
||||||
decoration: InputDecoration(
|
|
||||||
labelText: '학번 또는 교직원 번호',
|
|
||||||
hintText: '예: 201101 또는 T1001',
|
|
||||||
labelStyle: TextStyle(color: Colors.red[400]),
|
|
||||||
focusedBorder: OutlineInputBorder(
|
|
||||||
borderRadius: BorderRadius.circular(16),
|
|
||||||
borderSide: BorderSide(color: Colors.red[400]!, width: 2),
|
|
||||||
),
|
|
||||||
border: OutlineInputBorder(
|
|
||||||
borderRadius: BorderRadius.circular(16),
|
|
||||||
),
|
|
||||||
prefixIcon: const Icon(Icons.person_remove_alt_1_rounded),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
actions: [
|
|
||||||
TextButton(
|
|
||||||
onPressed: () => Navigator.pop(context),
|
|
||||||
child: const Text(
|
|
||||||
'취소',
|
|
||||||
style: TextStyle(
|
|
||||||
color: Colors.grey,
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
ElevatedButton(
|
|
||||||
onPressed: () {
|
|
||||||
final inputId = idController.text.trim();
|
|
||||||
if (inputId.isNotEmpty) {
|
|
||||||
Navigator.pop(context);
|
|
||||||
_deleteUser(inputId);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
style: ElevatedButton.styleFrom(
|
|
||||||
backgroundColor: Colors.redAccent,
|
|
||||||
foregroundColor: Colors.white,
|
|
||||||
shape: RoundedRectangleBorder(
|
|
||||||
borderRadius: BorderRadius.circular(12),
|
|
||||||
),
|
|
||||||
elevation: 0,
|
|
||||||
),
|
|
||||||
child: const Text(
|
|
||||||
'삭제 실행',
|
|
||||||
style: TextStyle(fontWeight: FontWeight.bold),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return ListenableBuilder(
|
|
||||||
listenable: _controller,
|
|
||||||
builder: (context, _) {
|
|
||||||
final bool isDeveloper = widget.studentId == "2061";
|
|
||||||
|
|
||||||
return Scaffold(
|
|
||||||
backgroundColor: Colors.grey[100],
|
|
||||||
appBar: AppBar(
|
|
||||||
title: Text(
|
|
||||||
isDeveloper ? '👑 MASTER CONTROL' : '🎓 STUDENT PORTAL',
|
|
||||||
style: const TextStyle(
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
letterSpacing: 1.2,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
centerTitle: true,
|
|
||||||
backgroundColor: isDeveloper
|
|
||||||
? Colors.deepPurple[700]
|
|
||||||
: Colors.indigo[700],
|
|
||||||
foregroundColor: Colors.white,
|
|
||||||
elevation: 0,
|
|
||||||
actions: [
|
|
||||||
IconButton(
|
|
||||||
icon: const Icon(Icons.logout_rounded),
|
|
||||||
onPressed: () => Navigator.pushReplacement(
|
|
||||||
context,
|
|
||||||
MaterialPageRoute(builder: (context) => const LoginScreen()),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
body: SingleChildScrollView(
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Container(
|
|
||||||
width: double.infinity,
|
|
||||||
padding: const EdgeInsets.symmetric(
|
|
||||||
horizontal: 24,
|
|
||||||
vertical: 28,
|
|
||||||
),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: isDeveloper
|
|
||||||
? Colors.deepPurple[700]
|
|
||||||
: Colors.indigo[700],
|
|
||||||
borderRadius: const BorderRadius.only(
|
|
||||||
bottomLeft: Radius.circular(32),
|
|
||||||
bottomRight: Radius.circular(32),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
child: Row(
|
|
||||||
children: [
|
|
||||||
Container(
|
|
||||||
padding: const EdgeInsets.all(4),
|
|
||||||
decoration: const BoxDecoration(
|
|
||||||
color: Colors.white,
|
|
||||||
shape: BoxShape.circle,
|
|
||||||
),
|
|
||||||
child: CircleAvatar(
|
|
||||||
radius: 30,
|
|
||||||
backgroundColor: isDeveloper
|
|
||||||
? Colors.deepPurple[50]
|
|
||||||
: Colors.indigo[50],
|
|
||||||
child: Icon(
|
|
||||||
isDeveloper
|
|
||||||
? Icons.admin_panel_settings_rounded
|
|
||||||
: Icons.school_rounded,
|
|
||||||
size: 32,
|
|
||||||
color: isDeveloper
|
|
||||||
? Colors.deepPurple
|
|
||||||
: Colors.indigo,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 18),
|
|
||||||
Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Text(
|
|
||||||
'${widget.studentName} 님',
|
|
||||||
style: const TextStyle(
|
|
||||||
fontSize: 22,
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
color: Colors.white,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 4),
|
|
||||||
Text(
|
|
||||||
isDeveloper
|
|
||||||
? '최고 관리 권한 활성화됨'
|
|
||||||
: '학번: ${widget.studentId} | 인증 완료',
|
|
||||||
style: TextStyle(
|
|
||||||
color: Colors.white.withValues(alpha: 0.8),
|
|
||||||
fontSize: 14,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 32),
|
|
||||||
Padding(
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 24.0),
|
|
||||||
child: Row(
|
|
||||||
children: [
|
|
||||||
Container(
|
|
||||||
width: 4,
|
|
||||||
height: 18,
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: isDeveloper
|
|
||||||
? Colors.deepPurple
|
|
||||||
: Colors.indigo,
|
|
||||||
borderRadius: BorderRadius.circular(2),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 10),
|
|
||||||
const Text(
|
|
||||||
'스마트 관리 시스템 메뉴',
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 18,
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
color: Colors.black87,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
LayoutBuilder(
|
|
||||||
builder: (context, constraints) {
|
|
||||||
// 🖥️ 웹(넓은 화면)은 한 줄에 5개씩, 폰(좁은 화면)은 기존 2개 그대로.
|
|
||||||
final bool isWide = constraints.maxWidth >= 800;
|
|
||||||
return Center(
|
|
||||||
child: ConstrainedBox(
|
|
||||||
constraints: BoxConstraints(
|
|
||||||
maxWidth: isWide ? 1300 : 700,
|
|
||||||
),
|
|
||||||
child: Padding(
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 24.0),
|
|
||||||
child: GridView.count(
|
|
||||||
shrinkWrap: true,
|
|
||||||
physics: const NeverScrollableScrollPhysics(),
|
|
||||||
crossAxisCount: isWide ? 5 : 2,
|
|
||||||
crossAxisSpacing: 16,
|
|
||||||
mainAxisSpacing: 16,
|
|
||||||
childAspectRatio: 0.95,
|
|
||||||
children: [
|
|
||||||
// 🆕 [변경 지점] 기기가 일치하면 정상 활성화, 일치하지 않으면 자물쇠Lock 처리
|
|
||||||
if (isDeveloper || widget.studentId != "2061")
|
|
||||||
_buildModernCard(
|
|
||||||
icon: widget.isDeviceMatched
|
|
||||||
? Icons.contactless_rounded
|
|
||||||
: Icons.lock_rounded,
|
|
||||||
title: 'NFC 태그',
|
|
||||||
subtitle: widget.isDeviceMatched
|
|
||||||
? '출석 및 폰 수거 완료'
|
|
||||||
: '⚠️ 본인 인증 기기 전용',
|
|
||||||
color: widget.isDeviceMatched
|
|
||||||
? Colors.blue
|
|
||||||
: Colors.grey[400]!,
|
|
||||||
onTap: widget.isDeviceMatched
|
|
||||||
? () => _openPocketCheckIn(context)
|
|
||||||
: () {
|
|
||||||
ScaffoldMessenger.of(
|
|
||||||
context,
|
|
||||||
).showSnackBar(
|
|
||||||
const SnackBar(
|
|
||||||
content: Text(
|
|
||||||
'🚨 대리 출석 방지를 위해 등록된 본인 스마트폰에서만 출석 가능합니다.',
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
isActionButton: widget.isDeviceMatched,
|
|
||||||
isLoading: _controller.isLoading,
|
|
||||||
),
|
|
||||||
|
|
||||||
// 🆕 [추가 지점] 기기 일치 여부 상관없이 태블릿에서도 누구나 확인 가능한 학교 상황판 카드
|
|
||||||
_buildModernCard(
|
|
||||||
icon: Icons.fastfood_rounded,
|
|
||||||
title: '실시간 학교 상황',
|
|
||||||
subtitle: '급식실 줄 & 매점 재고 확인',
|
|
||||||
color: Colors.orange[700]!,
|
|
||||||
onTap: () {
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
const SnackBar(
|
|
||||||
content: Text('🍔 실시간 학교 상황 페이지로 이동합니다.'),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
|
|
||||||
if (isDeveloper)
|
|
||||||
_buildModernCard(
|
|
||||||
icon: Icons.monitor_heart_rounded,
|
|
||||||
title: '실시간 현황',
|
|
||||||
subtitle: '교사용 수거 모니터링',
|
|
||||||
color: Colors.teal,
|
|
||||||
onTap: () => Navigator.push(
|
|
||||||
context,
|
|
||||||
MaterialPageRoute(
|
|
||||||
builder: (context) =>
|
|
||||||
const TeacherAttendancePage(),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
if (isDeveloper)
|
|
||||||
_buildModernCard(
|
|
||||||
icon: Icons.terminal_rounded,
|
|
||||||
title: '서버 DB 제어',
|
|
||||||
subtitle: '시스템 원격 초기화',
|
|
||||||
color: Colors.amber[800]!,
|
|
||||||
onTap: () => Navigator.push(
|
|
||||||
context,
|
|
||||||
MaterialPageRoute(
|
|
||||||
builder: (context) =>
|
|
||||||
const AdminDashboard(),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
if (isDeveloper)
|
|
||||||
_buildModernCard(
|
|
||||||
icon: Icons.add_moderator_rounded,
|
|
||||||
title: '학생 계정 관리',
|
|
||||||
subtitle: 'UUID 리셋 및 승인',
|
|
||||||
color: Colors.purple,
|
|
||||||
onTap: () => Navigator.push(
|
|
||||||
context,
|
|
||||||
MaterialPageRoute(
|
|
||||||
builder: (context) =>
|
|
||||||
const TeacherStudentManagementPage(),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
if (isDeveloper)
|
|
||||||
_buildModernCard(
|
|
||||||
icon: Icons.delete_sweep_rounded,
|
|
||||||
title: '계정 강제 삭제',
|
|
||||||
subtitle: '학생 및 교사 DB 삭제',
|
|
||||||
color: Colors.red[600]!,
|
|
||||||
onTap: () => _showDeleteUserDialog(context),
|
|
||||||
),
|
|
||||||
if (isDeveloper)
|
|
||||||
_buildModernCard(
|
|
||||||
icon: Icons.edit_note_rounded,
|
|
||||||
title: 'NFC 태그 쓰기',
|
|
||||||
subtitle: '주머니 스티커 초기 설정',
|
|
||||||
color: Colors.deepPurple,
|
|
||||||
onTap: () => Navigator.push(
|
|
||||||
context,
|
|
||||||
MaterialPageRoute(
|
|
||||||
builder: (context) =>
|
|
||||||
const NfcTagWriterScreen(),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
const SizedBox(height: 32),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 5️⃣ [카드 디자인 위젯] - 기존 형태 완전 보존
|
|
||||||
Widget _buildModernCard({
|
|
||||||
required IconData icon,
|
|
||||||
required String title,
|
|
||||||
required String subtitle,
|
|
||||||
required Color color,
|
|
||||||
required VoidCallback onTap,
|
|
||||||
bool isActionButton = false,
|
|
||||||
bool isLoading = false,
|
|
||||||
}) {
|
|
||||||
return InkWell(
|
|
||||||
onTap: isLoading ? null : onTap,
|
|
||||||
borderRadius: BorderRadius.circular(24),
|
|
||||||
child: Ink(
|
|
||||||
padding: const EdgeInsets.all(20),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: Colors.white,
|
|
||||||
borderRadius: BorderRadius.circular(24),
|
|
||||||
boxShadow: [
|
|
||||||
BoxShadow(
|
|
||||||
color: Colors.black.withValues(alpha: 0.04),
|
|
||||||
blurRadius: 16,
|
|
||||||
offset: const Offset(0, 4),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
||||||
children: [
|
|
||||||
Container(
|
|
||||||
padding: const EdgeInsets.all(12),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: color.withValues(alpha: 0.1),
|
|
||||||
borderRadius: BorderRadius.circular(16),
|
|
||||||
),
|
|
||||||
child: Icon(icon, color: color, size: 28),
|
|
||||||
),
|
|
||||||
Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Row(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
||||||
children: [
|
|
||||||
Text(
|
|
||||||
title,
|
|
||||||
style: const TextStyle(
|
|
||||||
fontSize: 16,
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
color: Colors.black87,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
if (isLoading)
|
|
||||||
const SizedBox(
|
|
||||||
width: 16,
|
|
||||||
height: 16,
|
|
||||||
child: CircularProgressIndicator(strokeWidth: 2),
|
|
||||||
)
|
|
||||||
else if (isActionButton)
|
|
||||||
Icon(
|
|
||||||
Icons.touch_app_rounded,
|
|
||||||
size: 16,
|
|
||||||
color: color.withValues(alpha: 0.5),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
const SizedBox(height: 4),
|
|
||||||
Text(
|
|
||||||
subtitle,
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 12,
|
|
||||||
color: Colors.grey[500],
|
|
||||||
height: 1.2,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
// 서버 통신·상태·파생 로직은 lib/function/teacher_attendance_controller.dart가 담당한다.
|
// 서버 통신·상태·파생 로직은 lib/function/teacher_attendance_controller.dart가 담당한다.
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import '../function/teacher_attendance_controller.dart';
|
import '../function/teacher_attendance_controller.dart';
|
||||||
|
import '../theme/app_palette.dart';
|
||||||
|
|
||||||
// -----------------------------------------------------------------------------
|
// -----------------------------------------------------------------------------
|
||||||
// 📅 [서브 화면 1] 실시간 출석 확인 란 (StudentDashboard 카드 스타일 리스트화)
|
// 📅 [서브 화면 1] 실시간 출석 확인 란 (StudentDashboard 카드 스타일 리스트화)
|
||||||
@@ -62,7 +63,7 @@ class _TeacherAttendancePageState extends State<TeacherAttendancePage> {
|
|||||||
showDialog(
|
showDialog(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (context) => AlertDialog(
|
builder: (context) => AlertDialog(
|
||||||
title: const Text('🧪 테스트용 허용시간 초기화'),
|
title: const Text('테스트용 허용시간 초기화'),
|
||||||
content: const Text(
|
content: const Text(
|
||||||
'하교 처리나 반출 허용 시간 설정으로 켜져 있는 모든 허용 시간대를 지금 즉시 해제합니다.\n'
|
'하교 처리나 반출 허용 시간 설정으로 켜져 있는 모든 허용 시간대를 지금 즉시 해제합니다.\n'
|
||||||
'(무단반출 감지 테스트할 때만 사용하세요)',
|
'(무단반출 감지 테스트할 때만 사용하세요)',
|
||||||
@@ -125,7 +126,7 @@ class _TeacherAttendancePageState extends State<TeacherAttendancePage> {
|
|||||||
showDialog(
|
showDialog(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (context) => AlertDialog(
|
builder: (context) => AlertDialog(
|
||||||
title: const Text('⏰ 전체 학생 반출 허용 시간 설정'),
|
title: const Text('전체 학생 반출 허용 시간 설정'),
|
||||||
content: Column(
|
content: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
@@ -168,7 +169,7 @@ class _TeacherAttendancePageState extends State<TeacherAttendancePage> {
|
|||||||
showDialog(
|
showDialog(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (context) => AlertDialog(
|
builder: (context) => AlertDialog(
|
||||||
title: const Text('🏫 하교 처리'),
|
title: const Text('하교 처리'),
|
||||||
content: const Text(
|
content: const Text(
|
||||||
'지금부터 모든 학생의 반출이 자동으로 허용되며, 더 이상 무단반출 경고가 뜨지 않습니다.\n하교 처리하시겠습니까?',
|
'지금부터 모든 학생의 반출이 자동으로 허용되며, 더 이상 무단반출 경고가 뜨지 않습니다.\n하교 처리하시겠습니까?',
|
||||||
),
|
),
|
||||||
@@ -202,13 +203,13 @@ class _TeacherAttendancePageState extends State<TeacherAttendancePage> {
|
|||||||
final int absentCount = totalCount - checkedInCount;
|
final int absentCount = totalCount - checkedInCount;
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
backgroundColor: Colors.grey[100],
|
backgroundColor: AppPalette.mist,
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
title: const Text(
|
title: const Text(
|
||||||
'📋 실시간 출석 현황',
|
'실시간 출석 현황',
|
||||||
style: TextStyle(fontWeight: FontWeight.bold),
|
style: TextStyle(fontWeight: FontWeight.bold),
|
||||||
),
|
),
|
||||||
backgroundColor: Colors.blue,
|
backgroundColor: AppPalette.ink,
|
||||||
foregroundColor: Colors.white,
|
foregroundColor: Colors.white,
|
||||||
elevation: 0,
|
elevation: 0,
|
||||||
actions: [
|
actions: [
|
||||||
@@ -260,7 +261,7 @@ class _TeacherAttendancePageState extends State<TeacherAttendancePage> {
|
|||||||
Icons.bug_report_outlined,
|
Icons.bug_report_outlined,
|
||||||
color: Colors.white70,
|
color: Colors.white70,
|
||||||
),
|
),
|
||||||
tooltip: '🧪 테스트용: 허용시간 초기화',
|
tooltip: '테스트용: 허용시간 초기화',
|
||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
],
|
],
|
||||||
@@ -336,7 +337,7 @@ class _TeacherAttendancePageState extends State<TeacherAttendancePage> {
|
|||||||
width: 4,
|
width: 4,
|
||||||
height: 16,
|
height: 16,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Colors.blue,
|
color: AppPalette.ink,
|
||||||
borderRadius: BorderRadius.circular(2),
|
borderRadius: BorderRadius.circular(2),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -346,7 +347,7 @@ class _TeacherAttendancePageState extends State<TeacherAttendancePage> {
|
|||||||
style: const TextStyle(
|
style: const TextStyle(
|
||||||
fontSize: 16,
|
fontSize: 16,
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
color: Colors.black87,
|
color: AppPalette.ink,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 6),
|
const SizedBox(width: 6),
|
||||||
@@ -386,9 +387,9 @@ class _TeacherAttendancePageState extends State<TeacherAttendancePage> {
|
|||||||
? Colors.red
|
? Colors.red
|
||||||
: (isPending ? Colors.orange : (isComplete ? Colors.blue : Colors.red));
|
: (isPending ? Colors.orange : (isComplete ? Colors.blue : Colors.red));
|
||||||
final String statusLine = hasViolation
|
final String statusLine = hasViolation
|
||||||
? '🚨 무단반출 (${student['violationTime']})'
|
? '무단반출 (${student['violationTime']})'
|
||||||
: (isPending
|
: (isPending
|
||||||
? '⏳ 미완료 (${student['checkInTime']})'
|
? '미완료 (${student['checkInTime']})'
|
||||||
: (isComplete
|
: (isComplete
|
||||||
? '${student['checkInTime']}'
|
? '${student['checkInTime']}'
|
||||||
: (student['hasEverCheckedIn'] == true ? '미제출' : '미등록')));
|
: (student['hasEverCheckedIn'] == true ? '미제출' : '미등록')));
|
||||||
@@ -396,11 +397,12 @@ class _TeacherAttendancePageState extends State<TeacherAttendancePage> {
|
|||||||
return Container(
|
return Container(
|
||||||
padding: const EdgeInsets.all(14),
|
padding: const EdgeInsets.all(14),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: hasViolation ? Colors.red[50] : Colors.white,
|
color: hasViolation ? Colors.red[50] : AppPalette.paper,
|
||||||
borderRadius: BorderRadius.circular(20),
|
borderRadius: BorderRadius.circular(20),
|
||||||
border: hasViolation
|
border: Border.all(
|
||||||
? Border.all(color: Colors.red[300]!, width: 1.5)
|
color: hasViolation ? Colors.red[300]! : AppPalette.sage,
|
||||||
: null,
|
width: hasViolation ? 1.5 : 1,
|
||||||
|
),
|
||||||
boxShadow: [
|
boxShadow: [
|
||||||
BoxShadow(
|
BoxShadow(
|
||||||
color: Colors.black.withValues(alpha: 0.03),
|
color: Colors.black.withValues(alpha: 0.03),
|
||||||
@@ -440,15 +442,15 @@ class _TeacherAttendancePageState extends State<TeacherAttendancePage> {
|
|||||||
vertical: 3,
|
vertical: 3,
|
||||||
),
|
),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Colors.blue.shade50,
|
color: AppPalette.linen,
|
||||||
borderRadius: BorderRadius.circular(20),
|
borderRadius: BorderRadius.circular(20),
|
||||||
border: Border.all(color: Colors.blue.shade200),
|
border: Border.all(color: AppPalette.sage),
|
||||||
),
|
),
|
||||||
child: Text(
|
child: Text(
|
||||||
student['pocketNumber'],
|
student['pocketNumber'],
|
||||||
style: const TextStyle(
|
style: const TextStyle(
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
color: Colors.blueAccent,
|
color: AppPalette.ink,
|
||||||
fontSize: 11,
|
fontSize: 11,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -590,7 +592,7 @@ class _TeacherAttendancePageState extends State<TeacherAttendancePage> {
|
|||||||
return Container(
|
return Container(
|
||||||
padding: const EdgeInsets.all(20),
|
padding: const EdgeInsets.all(20),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Colors.white,
|
color: AppPalette.paper,
|
||||||
borderRadius: BorderRadius.circular(16),
|
borderRadius: BorderRadius.circular(16),
|
||||||
boxShadow: [
|
boxShadow: [
|
||||||
BoxShadow(
|
BoxShadow(
|
||||||
@@ -639,7 +641,7 @@ class _TeacherAttendancePageState extends State<TeacherAttendancePage> {
|
|||||||
return Container(
|
return Container(
|
||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
color: const Color(0xFF1E293B),
|
color: AppPalette.ink,
|
||||||
child: Row(
|
child: Row(
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceAround,
|
mainAxisAlignment: MainAxisAlignment.spaceAround,
|
||||||
children: [
|
children: [
|
||||||
@@ -689,7 +691,7 @@ class _TeacherAttendancePageState extends State<TeacherAttendancePage> {
|
|||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Text(
|
child: Text(
|
||||||
'🔓 지금 전체 반출 허용 중입니다 ($until 까지) — 이 시간 동안은 무단반출 경고가 뜨지 않아요.',
|
'지금 전체 반출 허용 중입니다 ($until 까지) — 이 시간 동안은 무단반출 경고가 뜨지 않아요.',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
color: Colors.amber[900],
|
color: Colors.amber[900],
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
@@ -719,12 +721,12 @@ class _TeacherAttendancePageState extends State<TeacherAttendancePage> {
|
|||||||
onSelected: (_) => _controller.setFilter("ALL"),
|
onSelected: (_) => _controller.setFilter("ALL"),
|
||||||
),
|
),
|
||||||
FilterChip(
|
FilterChip(
|
||||||
label: const Text("🟢 출석자"),
|
label: const Text("출석자"),
|
||||||
selected: _controller.filterType == "CHECKED_IN",
|
selected: _controller.filterType == "CHECKED_IN",
|
||||||
onSelected: (_) => _controller.setFilter("CHECKED_IN"),
|
onSelected: (_) => _controller.setFilter("CHECKED_IN"),
|
||||||
),
|
),
|
||||||
FilterChip(
|
FilterChip(
|
||||||
label: const Text("🔴 미출석자"),
|
label: const Text("미출석자"),
|
||||||
selected: _controller.filterType == "ABSENT",
|
selected: _controller.filterType == "ABSENT",
|
||||||
onSelected: (_) => _controller.setFilter("ABSENT"),
|
onSelected: (_) => _controller.setFilter("ABSENT"),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -1,273 +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),
|
|
||||||
|
|
||||||
// 📊 [그리드 레이아웃 메뉴] 시후의 카드 컴포넌트 스타일 적용
|
|
||||||
// 🖥️ 데스크톱 브라우저에서 카드가 지나치게 커지지 않도록 최대 너비를 제한한다.
|
|
||||||
Center(
|
|
||||||
child: ConstrainedBox(
|
|
||||||
constraints: const BoxConstraints(maxWidth: 700),
|
|
||||||
child: 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,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
// lib/function/teacher_register_controller.dart가 담당한다.
|
// lib/function/teacher_register_controller.dart가 담당한다.
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import '../function/teacher_register_controller.dart';
|
import '../function/teacher_register_controller.dart';
|
||||||
|
import '../theme/app_palette.dart';
|
||||||
|
|
||||||
class TeacherRegisterScreen extends StatefulWidget {
|
class TeacherRegisterScreen extends StatefulWidget {
|
||||||
const TeacherRegisterScreen({super.key});
|
const TeacherRegisterScreen({super.key});
|
||||||
@@ -36,7 +37,7 @@ class _TeacherRegisterScreenState extends State<TeacherRegisterScreen> {
|
|||||||
if (id.isEmpty || pw.isEmpty || name.isEmpty || secret.isEmpty) {
|
if (id.isEmpty || pw.isEmpty || name.isEmpty || secret.isEmpty) {
|
||||||
ScaffoldMessenger.of(
|
ScaffoldMessenger.of(
|
||||||
context,
|
context,
|
||||||
).showSnackBar(const SnackBar(content: Text('⚠️ 모든 빈칸을 입력해 주세요.')));
|
).showSnackBar(const SnackBar(content: Text('모든 빈칸을 입력해 주세요.')));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -61,10 +62,11 @@ class _TeacherRegisterScreenState extends State<TeacherRegisterScreen> {
|
|||||||
listenable: _controller,
|
listenable: _controller,
|
||||||
builder: (context, _) {
|
builder: (context, _) {
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
|
backgroundColor: AppPalette.mist,
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
title: const Text('교사 회원가입'),
|
title: const Text('교사 회원가입'),
|
||||||
backgroundColor: Colors.indigo,
|
backgroundColor: AppPalette.ink,
|
||||||
foregroundColor: Colors.white,
|
foregroundColor: AppPalette.paper,
|
||||||
),
|
),
|
||||||
body: SingleChildScrollView(
|
body: SingleChildScrollView(
|
||||||
padding: const EdgeInsets.all(24.0),
|
padding: const EdgeInsets.all(24.0),
|
||||||
@@ -72,7 +74,7 @@ class _TeacherRegisterScreenState extends State<TeacherRegisterScreen> {
|
|||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
const Text(
|
const Text(
|
||||||
'👨🏫 교직원 전용 인증',
|
'교직원 전용 인증',
|
||||||
style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold),
|
style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
@@ -85,7 +87,7 @@ class _TeacherRegisterScreenState extends State<TeacherRegisterScreen> {
|
|||||||
controller: _secretController,
|
controller: _secretController,
|
||||||
obscureText: true,
|
obscureText: true,
|
||||||
decoration: const InputDecoration(
|
decoration: const InputDecoration(
|
||||||
labelText: '🔑 교사 인증 비밀코드 입력',
|
labelText: '교사 인증 비밀코드 입력',
|
||||||
border: OutlineInputBorder(),
|
border: OutlineInputBorder(),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -122,8 +124,8 @@ class _TeacherRegisterScreenState extends State<TeacherRegisterScreen> {
|
|||||||
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: _controller.isLoading ? null : _registerTeacher,
|
onPressed: _controller.isLoading ? null : _registerTeacher,
|
||||||
child: _controller.isLoading
|
child: _controller.isLoading
|
||||||
|
|||||||
@@ -5,6 +5,11 @@ import 'package:desktop_drop/desktop_drop.dart';
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import '../function/excel_file_picker.dart';
|
import '../function/excel_file_picker.dart';
|
||||||
import '../function/teacher_student_management_controller.dart';
|
import '../function/teacher_student_management_controller.dart';
|
||||||
|
import '../theme/app_palette.dart';
|
||||||
|
|
||||||
|
// 📐 "실시간 출석 확인" 목록(teacher_attendance_page.dart)의 타일 규격과 동일하게 맞춘다.
|
||||||
|
const double kAttendanceTileWidth = 220;
|
||||||
|
const double kAttendanceTileHeight = 148;
|
||||||
|
|
||||||
// -----------------------------------------------------------------------------
|
// -----------------------------------------------------------------------------
|
||||||
// 👥 [서브 화면 2] 학생 계정 관리 란 (추가 양식 폼 + 강제 삭제 다이얼로그 완전 내장)
|
// 👥 [서브 화면 2] 학생 계정 관리 란 (추가 양식 폼 + 강제 삭제 다이얼로그 완전 내장)
|
||||||
@@ -23,7 +28,6 @@ class _TeacherStudentManagementPageState
|
|||||||
TeacherStudentManagementController();
|
TeacherStudentManagementController();
|
||||||
final TextEditingController _addIdController = TextEditingController();
|
final TextEditingController _addIdController = TextEditingController();
|
||||||
final TextEditingController _addNameController = TextEditingController();
|
final TextEditingController _addNameController = TextEditingController();
|
||||||
final TextEditingController _addPwController = TextEditingController();
|
|
||||||
int? _selectedGrade;
|
int? _selectedGrade;
|
||||||
bool _isDragging = false;
|
bool _isDragging = false;
|
||||||
|
|
||||||
@@ -38,34 +42,31 @@ class _TeacherStudentManagementPageState
|
|||||||
_controller.dispose();
|
_controller.dispose();
|
||||||
_addIdController.dispose();
|
_addIdController.dispose();
|
||||||
_addNameController.dispose();
|
_addNameController.dispose();
|
||||||
_addPwController.dispose();
|
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ➕ [학생 추가 버튼 동작]
|
// ➕ [학생 추가 버튼 동작] 기본 비밀번호는 항상 1234로 고정.
|
||||||
Future<void> _addStudentAccount() async {
|
Future<void> _addStudentAccount() async {
|
||||||
final sId = _addIdController.text.trim();
|
final sId = _addIdController.text.trim();
|
||||||
final sName = _addNameController.text.trim();
|
final sName = _addNameController.text.trim();
|
||||||
final sPw = _addPwController.text.trim();
|
|
||||||
|
|
||||||
if (sId.isEmpty || sName.isEmpty || sPw.isEmpty) {
|
if (sId.isEmpty || sName.isEmpty) {
|
||||||
ScaffoldMessenger.of(
|
ScaffoldMessenger.of(
|
||||||
context,
|
context,
|
||||||
).showSnackBar(const SnackBar(content: Text('⚠️ 모든 입력란을 채워주세요.')));
|
).showSnackBar(const SnackBar(content: Text('모든 입력란을 채워주세요.')));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
final (success, message) = await _controller.addStudentAccount(
|
final (success, message) = await _controller.addStudentAccount(
|
||||||
studentId: sId,
|
studentId: sId,
|
||||||
name: sName,
|
name: sName,
|
||||||
password: sPw,
|
password: '1234',
|
||||||
grade: _selectedGrade,
|
grade: _selectedGrade,
|
||||||
);
|
);
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
if (success) {
|
if (success) {
|
||||||
_addIdController.clear();
|
_addIdController.clear();
|
||||||
_addNameController.clear();
|
_addNameController.clear();
|
||||||
_addPwController.clear();
|
|
||||||
setState(() => _selectedGrade = null);
|
setState(() => _selectedGrade = null);
|
||||||
}
|
}
|
||||||
ScaffoldMessenger.of(
|
ScaffoldMessenger.of(
|
||||||
@@ -90,15 +91,13 @@ class _TeacherStudentManagementPageState
|
|||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
ScaffoldMessenger.of(
|
ScaffoldMessenger.of(
|
||||||
context,
|
context,
|
||||||
).showSnackBar(SnackBar(content: Text('❌ 엑셀 파일을 읽는 데 실패했습니다: $e')));
|
).showSnackBar(SnackBar(content: Text('엑셀 파일을 읽는 데 실패했습니다: $e')));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (rows.isEmpty) {
|
if (rows.isEmpty) {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
const SnackBar(
|
const SnackBar(content: Text('유효한 학생 데이터를 찾지 못했습니다. (1행은 머리글로 건너뜁니다)')),
|
||||||
content: Text('⚠️ 유효한 학생 데이터를 찾지 못했습니다. (1행은 머리글로 건너뜁니다)'),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -111,7 +110,7 @@ class _TeacherStudentManagementPageState
|
|||||||
context: context,
|
context: context,
|
||||||
builder: (context) => AlertDialog(
|
builder: (context) => AlertDialog(
|
||||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(24)),
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(24)),
|
||||||
title: Text('📄 ${rows.length}명 확인됨'),
|
title: Text('${rows.length}명 확인됨'),
|
||||||
content: SizedBox(
|
content: SizedBox(
|
||||||
width: 400,
|
width: 400,
|
||||||
child: Column(
|
child: Column(
|
||||||
@@ -162,8 +161,8 @@ class _TeacherStudentManagementPageState
|
|||||||
_runBulkImport(rows);
|
_runBulkImport(rows);
|
||||||
},
|
},
|
||||||
style: ElevatedButton.styleFrom(
|
style: ElevatedButton.styleFrom(
|
||||||
backgroundColor: Colors.orange,
|
backgroundColor: AppPalette.ink,
|
||||||
foregroundColor: Colors.white,
|
foregroundColor: AppPalette.paper,
|
||||||
shape: RoundedRectangleBorder(
|
shape: RoundedRectangleBorder(
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
),
|
),
|
||||||
@@ -208,9 +207,7 @@ class _TeacherStudentManagementPageState
|
|||||||
context: context,
|
context: context,
|
||||||
builder: (context) => AlertDialog(
|
builder: (context) => AlertDialog(
|
||||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(24)),
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(24)),
|
||||||
title: Text(
|
title: Text(result.failures.isEmpty ? '일괄 등록 완료' : '일괄 등록 완료 (일부 실패)'),
|
||||||
result.failures.isEmpty ? '✅ 일괄 등록 완료' : '⚠️ 일괄 등록 완료 (일부 실패)',
|
|
||||||
),
|
|
||||||
content: SizedBox(
|
content: SizedBox(
|
||||||
width: 400,
|
width: 400,
|
||||||
child: Column(
|
child: Column(
|
||||||
@@ -294,8 +291,8 @@ class _TeacherStudentManagementPageState
|
|||||||
context: context,
|
context: context,
|
||||||
builder: (ctx) => AlertDialog(
|
builder: (ctx) => AlertDialog(
|
||||||
title: const Text(
|
title: const Text(
|
||||||
'⚠️ 기기 등록 초기화',
|
'기기 등록 초기화',
|
||||||
style: TextStyle(fontWeight: FontWeight.bold, color: Colors.purple),
|
style: TextStyle(fontWeight: FontWeight.bold, color: AppPalette.ink),
|
||||||
),
|
),
|
||||||
content: Text('$studentName 학생의 스마트폰 기기 등록과 비밀번호(1234)를 초기화하시겠습니까?'),
|
content: Text('$studentName 학생의 스마트폰 기기 등록과 비밀번호(1234)를 초기화하시겠습니까?'),
|
||||||
actions: [
|
actions: [
|
||||||
@@ -305,8 +302,8 @@ class _TeacherStudentManagementPageState
|
|||||||
),
|
),
|
||||||
ElevatedButton(
|
ElevatedButton(
|
||||||
style: ElevatedButton.styleFrom(
|
style: ElevatedButton.styleFrom(
|
||||||
backgroundColor: Colors.purple,
|
backgroundColor: AppPalette.ink,
|
||||||
foregroundColor: Colors.white,
|
foregroundColor: AppPalette.paper,
|
||||||
),
|
),
|
||||||
onPressed: () async {
|
onPressed: () async {
|
||||||
Navigator.pop(ctx);
|
Navigator.pop(ctx);
|
||||||
@@ -372,56 +369,37 @@ class _TeacherStudentManagementPageState
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
// 🏷️ 섹션 제목 (색 인디케이터 바 + 텍스트).
|
||||||
Widget build(BuildContext context) {
|
Widget _sectionTitle(String text, {Color color = AppPalette.ink}) {
|
||||||
return ListenableBuilder(
|
return Row(
|
||||||
listenable: _controller,
|
|
||||||
builder: (context, _) {
|
|
||||||
return Scaffold(
|
|
||||||
backgroundColor: Colors.grey[100],
|
|
||||||
appBar: AppBar(
|
|
||||||
title: const Text(
|
|
||||||
'⚙️ 학생 통합 관리 센터',
|
|
||||||
style: TextStyle(fontWeight: FontWeight.bold),
|
|
||||||
),
|
|
||||||
backgroundColor: Colors.orange,
|
|
||||||
foregroundColor: Colors.white,
|
|
||||||
elevation: 0,
|
|
||||||
),
|
|
||||||
body: SingleChildScrollView(
|
|
||||||
padding: const EdgeInsets.all(24.0),
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
// 🏷️ 인디케이터 바 1 (추가 메뉴)
|
|
||||||
Row(
|
|
||||||
children: [
|
children: [
|
||||||
Container(
|
Container(
|
||||||
width: 4,
|
width: 4,
|
||||||
height: 16,
|
height: 16,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Colors.orange,
|
color: color,
|
||||||
borderRadius: BorderRadius.circular(2),
|
borderRadius: BorderRadius.circular(2),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
const Text(
|
Text(
|
||||||
'신규 학생 계정 추가',
|
text,
|
||||||
style: TextStyle(
|
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
|
||||||
fontSize: 18,
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
);
|
||||||
const SizedBox(height: 16),
|
}
|
||||||
|
|
||||||
// 📝 학생 추가 컨테이너 폼
|
// 📝 [정사각형 박스 1] 신규 학생 계정 추가 폼. 박스가 작아도 버튼까지 다 보이도록 촘촘하게 배치.
|
||||||
Container(
|
Widget _buildAddAccountBox() {
|
||||||
padding: const EdgeInsets.all(20),
|
const InputDecoration Function(String, IconData) fieldDecoration =
|
||||||
|
_compactFieldDecoration;
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.all(10),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Colors.white,
|
color: AppPalette.paper,
|
||||||
borderRadius: BorderRadius.circular(24),
|
borderRadius: BorderRadius.circular(24),
|
||||||
|
border: Border.all(color: AppPalette.sage),
|
||||||
boxShadow: [
|
boxShadow: [
|
||||||
BoxShadow(
|
BoxShadow(
|
||||||
color: Colors.black.withValues(alpha: 0.04),
|
color: Colors.black.withValues(alpha: 0.04),
|
||||||
@@ -429,72 +407,76 @@ class _TeacherStudentManagementPageState
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
child: SingleChildScrollView(
|
||||||
child: Column(
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
TextField(
|
TextField(
|
||||||
controller: _addIdController,
|
controller: _addIdController,
|
||||||
keyboardType: TextInputType.number,
|
keyboardType: TextInputType.number,
|
||||||
decoration: const InputDecoration(
|
style: const TextStyle(fontSize: 12),
|
||||||
labelText: '학번',
|
decoration: fieldDecoration('학번', Icons.badge),
|
||||||
prefixIcon: Icon(Icons.badge),
|
|
||||||
),
|
),
|
||||||
),
|
const SizedBox(height: 4),
|
||||||
const SizedBox(height: 12),
|
|
||||||
TextField(
|
TextField(
|
||||||
controller: _addNameController,
|
controller: _addNameController,
|
||||||
decoration: const InputDecoration(
|
style: const TextStyle(fontSize: 12),
|
||||||
labelText: '이름',
|
decoration: fieldDecoration('이름', Icons.person),
|
||||||
prefixIcon: Icon(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: 12),
|
const SizedBox(height: 4),
|
||||||
TextField(
|
|
||||||
controller: _addPwController,
|
|
||||||
obscureText: true,
|
|
||||||
decoration: const InputDecoration(
|
|
||||||
labelText: '초기 비밀번호',
|
|
||||||
prefixIcon: Icon(Icons.lock),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 12),
|
|
||||||
DropdownButtonFormField<int>(
|
DropdownButtonFormField<int>(
|
||||||
initialValue: _selectedGrade,
|
initialValue: _selectedGrade,
|
||||||
decoration: const InputDecoration(
|
style: const TextStyle(fontSize: 12, color: Colors.black87),
|
||||||
labelText: '학년 (선택)',
|
decoration: fieldDecoration('학년 (선택)', Icons.class_),
|
||||||
prefixIcon: Icon(Icons.class_),
|
|
||||||
),
|
|
||||||
items: const [
|
items: const [
|
||||||
DropdownMenuItem(value: 1, child: Text('1학년')),
|
DropdownMenuItem(value: 1, child: Text('1학년')),
|
||||||
DropdownMenuItem(value: 2, child: Text('2학년')),
|
DropdownMenuItem(value: 2, child: Text('2학년')),
|
||||||
DropdownMenuItem(value: 3, child: Text('3학년')),
|
DropdownMenuItem(value: 3, child: Text('3학년')),
|
||||||
],
|
],
|
||||||
onChanged: (value) =>
|
onChanged: (value) => setState(() => _selectedGrade = value),
|
||||||
setState(() => _selectedGrade = value),
|
|
||||||
),
|
),
|
||||||
const SizedBox(height: 20),
|
const SizedBox(height: 6),
|
||||||
SizedBox(
|
SizedBox(
|
||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
height: 50,
|
height: 30,
|
||||||
child: ElevatedButton(
|
child: ElevatedButton(
|
||||||
onPressed: _controller.isWorking
|
onPressed: _controller.isWorking ? null : _addStudentAccount,
|
||||||
? null
|
|
||||||
: _addStudentAccount,
|
|
||||||
style: ElevatedButton.styleFrom(
|
style: ElevatedButton.styleFrom(
|
||||||
backgroundColor: Colors.orange,
|
backgroundColor: AppPalette.ink,
|
||||||
foregroundColor: Colors.white,
|
foregroundColor: AppPalette.paper,
|
||||||
|
padding: EdgeInsets.zero,
|
||||||
shape: RoundedRectangleBorder(
|
shape: RoundedRectangleBorder(
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(10),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
child: _controller.isWorking
|
child: _controller.isWorking
|
||||||
? const CircularProgressIndicator(
|
? const SizedBox(
|
||||||
|
width: 16,
|
||||||
|
height: 16,
|
||||||
|
child: CircularProgressIndicator(
|
||||||
color: Colors.white,
|
color: Colors.white,
|
||||||
|
strokeWidth: 2,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
: const Text(
|
: const Text(
|
||||||
'학생 등록 완료',
|
'학생 등록 완료',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
fontSize: 15,
|
fontSize: 12,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -502,32 +484,22 @@ class _TeacherStudentManagementPageState
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 36),
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// 🏷️ 인디케이터 바 (엑셀 일괄 등록)
|
static InputDecoration _compactFieldDecoration(String label, IconData icon) {
|
||||||
Row(
|
return InputDecoration(
|
||||||
children: [
|
labelText: label,
|
||||||
Container(
|
labelStyle: const TextStyle(fontSize: 12),
|
||||||
width: 4,
|
prefixIcon: Icon(icon, size: 16),
|
||||||
height: 16,
|
isDense: true,
|
||||||
decoration: BoxDecoration(
|
contentPadding: const EdgeInsets.symmetric(vertical: 4, horizontal: 8),
|
||||||
color: Colors.orange,
|
);
|
||||||
borderRadius: BorderRadius.circular(2),
|
}
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 8),
|
|
||||||
const Text(
|
|
||||||
'엑셀로 한번에 추가 (신입생 등)',
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 18,
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
|
|
||||||
DropTarget(
|
// 📤 [정사각형 박스 2] 엑셀 일괄 등록 드롭존.
|
||||||
|
Widget _buildExcelBox() {
|
||||||
|
return DropTarget(
|
||||||
onDragEntered: (_) => setState(() => _isDragging = true),
|
onDragEntered: (_) => setState(() => _isDragging = true),
|
||||||
onDragExited: (_) => setState(() => _isDragging = false),
|
onDragExited: (_) => setState(() => _isDragging = false),
|
||||||
onDragDone: (details) async {
|
onDragDone: (details) async {
|
||||||
@@ -542,71 +514,240 @@ class _TeacherStudentManagementPageState
|
|||||||
borderRadius: BorderRadius.circular(24),
|
borderRadius: BorderRadius.circular(24),
|
||||||
child: Container(
|
child: Container(
|
||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
padding: const EdgeInsets.symmetric(vertical: 28),
|
height: double.infinity,
|
||||||
|
padding: const EdgeInsets.all(20),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: _isDragging ? Colors.orange[50] : Colors.white,
|
color: _isDragging ? Colors.orange[50] : AppPalette.paper,
|
||||||
borderRadius: BorderRadius.circular(24),
|
borderRadius: BorderRadius.circular(24),
|
||||||
border: Border.all(
|
border: Border.all(
|
||||||
color: _isDragging
|
color: _isDragging ? Colors.orange : AppPalette.sage,
|
||||||
? Colors.orange
|
|
||||||
: Colors.grey.shade300,
|
|
||||||
width: _isDragging ? 2 : 1,
|
width: _isDragging ? 2 : 1,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
child: Column(
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
Icon(
|
Icon(
|
||||||
Icons.upload_file_rounded,
|
Icons.upload_file_rounded,
|
||||||
size: 40,
|
size: 40,
|
||||||
color: _isDragging
|
color: _isDragging ? Colors.orange : AppPalette.sage,
|
||||||
? Colors.orange
|
|
||||||
: Colors.grey[400],
|
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
Text(
|
Text(
|
||||||
_isDragging ? '여기에 놓으세요' : '엑셀 파일을 드래그하거나 눌러서 선택',
|
_isDragging ? '여기에 놓으세요' : '엑셀 파일을 드래그하거나 눌러서 선택',
|
||||||
|
textAlign: TextAlign.center,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
color: _isDragging
|
color: _isDragging ? Colors.orange[800] : AppPalette.ink,
|
||||||
? Colors.orange[800]
|
|
||||||
: Colors.black87,
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
Text(
|
Text(
|
||||||
'.xlsx · 1행은 머리글, A열=학번 B열=이름 C열=학년(선택)',
|
'.xlsx · 1행은 머리글, A열=학번 B열=이름 C열=학년(선택)',
|
||||||
style: TextStyle(
|
style: TextStyle(fontSize: 12, color: Colors.grey[500]),
|
||||||
fontSize: 12,
|
|
||||||
color: Colors.grey[500],
|
|
||||||
),
|
|
||||||
textAlign: TextAlign.center,
|
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(
|
||||||
|
title: const Text(
|
||||||
|
'학생 통합 관리 센터',
|
||||||
|
style: TextStyle(fontWeight: FontWeight.bold),
|
||||||
|
),
|
||||||
|
backgroundColor: AppPalette.ink,
|
||||||
|
foregroundColor: AppPalette.paper,
|
||||||
|
elevation: 0,
|
||||||
|
),
|
||||||
|
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),
|
const SizedBox(height: 36),
|
||||||
|
|
||||||
// 🏷️ 인디케이터 바 (전체 학생 목록)
|
// 🏷️ 인디케이터 바 (전체 학생 목록) — 위 두 칸을 합친 폭에 맞춘다.
|
||||||
|
SizedBox(
|
||||||
|
width: listWidth,
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
Container(
|
_sectionTitle('전체 학생 목록'),
|
||||||
width: 4,
|
|
||||||
height: 16,
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: Colors.blue,
|
|
||||||
borderRadius: BorderRadius.circular(2),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 8),
|
|
||||||
const Text(
|
|
||||||
'전체 학생 목록',
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 18,
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const Spacer(),
|
const Spacer(),
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: const Icon(Icons.refresh_rounded),
|
icon: const Icon(Icons.refresh_rounded),
|
||||||
@@ -620,14 +761,18 @@ class _TeacherStudentManagementPageState
|
|||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
const Text(
|
const Text(
|
||||||
'학년 칩을 눌러 학년을 바꾸고, 기기 리셋은 학생이 폰을 바꿨을 때 사용하세요.',
|
'학년 칩을 눌러 학년을 바꾸고, 기기 리셋은 학생이 폰을 바꿨을 때 사용하세요.',
|
||||||
style: TextStyle(color: Colors.black54, fontSize: 12),
|
style: TextStyle(
|
||||||
|
color: Colors.black54,
|
||||||
|
fontSize: 12,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
|
|
||||||
_controller.isLoadingStudents
|
_controller.isLoadingStudents
|
||||||
? const Padding(
|
? const Padding(
|
||||||
padding: EdgeInsets.symmetric(vertical: 40),
|
padding: EdgeInsets.symmetric(vertical: 40),
|
||||||
child: Center(child: CircularProgressIndicator()),
|
child: Center(
|
||||||
|
child: CircularProgressIndicator(),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
: _controller.students.isEmpty
|
: _controller.students.isEmpty
|
||||||
? const Padding(
|
? const Padding(
|
||||||
@@ -639,98 +784,30 @@ class _TeacherStudentManagementPageState
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
: ListView.builder(
|
: GridView.builder(
|
||||||
shrinkWrap: true,
|
shrinkWrap: true,
|
||||||
physics: const NeverScrollableScrollPhysics(),
|
physics: const NeverScrollableScrollPhysics(),
|
||||||
|
gridDelegate:
|
||||||
|
const SliverGridDelegateWithMaxCrossAxisExtent(
|
||||||
|
maxCrossAxisExtent:
|
||||||
|
kAttendanceTileWidth,
|
||||||
|
mainAxisExtent: kAttendanceTileHeight,
|
||||||
|
crossAxisSpacing: 12,
|
||||||
|
mainAxisSpacing: 12,
|
||||||
|
),
|
||||||
itemCount: _controller.students.length,
|
itemCount: _controller.students.length,
|
||||||
itemBuilder: (context, index) {
|
itemBuilder: (context, index) =>
|
||||||
final student = _controller.students[index];
|
_buildStudentTile(
|
||||||
final String studentId = student['id'].toString();
|
_controller.students[index],
|
||||||
final String studentName = student['name'].toString();
|
|
||||||
final int? grade = student['grade'] as int?;
|
|
||||||
final bool needsReset = student['device'] == '초기화 필요';
|
|
||||||
|
|
||||||
return Card(
|
|
||||||
elevation: 1,
|
|
||||||
margin: const EdgeInsets.only(bottom: 10),
|
|
||||||
shape: RoundedRectangleBorder(
|
|
||||||
borderRadius: BorderRadius.circular(16),
|
|
||||||
),
|
|
||||||
child: ListTile(
|
|
||||||
contentPadding: const EdgeInsets.symmetric(
|
|
||||||
horizontal: 16,
|
|
||||||
vertical: 8,
|
|
||||||
),
|
|
||||||
leading: CircleAvatar(
|
|
||||||
backgroundColor: needsReset
|
|
||||||
? Colors.red[50]
|
|
||||||
: Colors.blue[50],
|
|
||||||
child: Icon(
|
|
||||||
needsReset ? Icons.lock_reset : Icons.person,
|
|
||||||
color: needsReset ? Colors.red : Colors.blue,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
title: Text(
|
|
||||||
'$studentName ($studentId)',
|
|
||||||
style: const TextStyle(
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
subtitle: Text(
|
|
||||||
needsReset ? '기기 초기화 승인 대기중' : '정상 등록 상태',
|
|
||||||
style: TextStyle(
|
|
||||||
color: needsReset ? Colors.red : Colors.grey,
|
|
||||||
fontWeight: needsReset
|
|
||||||
? FontWeight.bold
|
|
||||||
: FontWeight.normal,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
trailing: Row(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
Builder(
|
|
||||||
builder: (chipContext) => ActionChip(
|
|
||||||
label: Text(
|
|
||||||
grade != null ? '$grade학년' : '미배정',
|
|
||||||
),
|
|
||||||
onPressed: () => _showGradeMenu(
|
|
||||||
chipContext,
|
|
||||||
studentId,
|
|
||||||
studentName,
|
|
||||||
grade,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
IconButton(
|
|
||||||
icon: const Icon(
|
|
||||||
Icons.lock_reset,
|
|
||||||
color: Colors.purple,
|
|
||||||
),
|
|
||||||
tooltip: '기기 리셋',
|
|
||||||
onPressed: () => _confirmResetDevice(
|
|
||||||
studentId,
|
|
||||||
studentName,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
IconButton(
|
|
||||||
icon: const Icon(
|
|
||||||
Icons.delete_forever_rounded,
|
|
||||||
color: Colors.redAccent,
|
|
||||||
),
|
|
||||||
tooltip: '계정 영구 삭제',
|
|
||||||
onPressed: () => _confirmDeleteStudent(
|
|
||||||
studentId,
|
|
||||||
studentName,
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
],
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ 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"))
|
DesktopDropPlugin.register(with: registry.registrar(forPlugin: "DesktopDropPlugin"))
|
||||||
@@ -19,4 +20,5 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
|||||||
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"))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -350,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
|
||||||
@@ -472,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:
|
||||||
@@ -544,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
|
||||||
@@ -703,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.1.5+7
|
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
|
||||||
@@ -56,6 +57,7 @@ dependencies:
|
|||||||
file_picker: ^12.1.2
|
file_picker: ^12.1.2
|
||||||
desktop_drop: ^0.8.3
|
desktop_drop: ^0.8.3
|
||||||
web: ^1.1.1
|
web: ^1.1.1
|
||||||
|
flutter_staggered_grid_view: ^0.7.0
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
flutter_test:
|
flutter_test:
|
||||||
|
|||||||
|
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 |