플레이스토어 출시 준비: 릴리즈 서명, 패키지명 변경, HTTPS 전환, NFC 태그 쓰기 기능 추가
## 릴리즈 빌드 준비 - android/app/build.gradle.kts: 디버그 키로 서명하던 release 빌드 타입을 android/key.properties 기반 정식 릴리즈 키스토어(upload-keystore.jks) 서명으로 변경 - applicationId를 com.example.school_attendance → com.backsanhighschool.app 로 변경 (com.example.* 패키지는 구글 플레이 업로드 자체가 거부되기 때문) ## Firebase 재등록 - 새 패키지명(com.backsanhighschool.app)으로 Firebase Android 앱을 새로 등록 - google-services.json, lib/firebase_options.dart, firebase.json을 새 앱 ID 기준으로 재생성 ## HTTPS 전환 - lib/config.dart: baseUrl을 http:// → https:// 로 변경 (서버에 이미 발급되어 있었지만 프록시에 연결이 안 되어 있던 Let's Encrypt 인증서를 nginx-proxy-manager에 연결하고 HTTP→HTTPS 강제 리다이렉트 설정) - AndroidManifest.xml: 더 이상 필요 없는 usesCleartextTraffic="true" 제거 ## NFC 태그 쓰기 기능 (신규) - lib/screens/nfc_tag_writer_screen.dart 추가: 관리자가 빈 NFC 스티커에 "POCKET_번호" 텍스트를 쓰고, 태그 하드웨어 UID를 서버(/api/pockets/register)에 등록해서 태그 복제(내용만 베낀 위조 태그)를 방지 - lib/nfc_poccket_checkin_screen.dart: 체크인 시 태그 UID를 함께 서버로 전송해서 등록된 진짜 주머니 번호로 검증하도록 변경 - lib/screens/student_dashboard.dart: 개발자 메뉴에 "NFC 태그 쓰기" 카드 추가, 기존 "가상 NFC 태깅" 카드는 "NFC 태그"로 이름 변경 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,3 +1,6 @@
|
|||||||
|
import java.util.Properties
|
||||||
|
import java.io.FileInputStream
|
||||||
|
|
||||||
plugins {
|
plugins {
|
||||||
id("com.android.application")
|
id("com.android.application")
|
||||||
// The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins.
|
// The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins.
|
||||||
@@ -5,6 +8,12 @@ plugins {
|
|||||||
id("com.google.gms.google-services")
|
id("com.google.gms.google-services")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val keystoreProperties = Properties()
|
||||||
|
val keystorePropertiesFile = rootProject.file("key.properties")
|
||||||
|
if (keystorePropertiesFile.exists()) {
|
||||||
|
keystoreProperties.load(FileInputStream(keystorePropertiesFile))
|
||||||
|
}
|
||||||
|
|
||||||
android {
|
android {
|
||||||
namespace = "com.example.school_attendance"
|
namespace = "com.example.school_attendance"
|
||||||
compileSdk = 36
|
compileSdk = 36
|
||||||
@@ -17,8 +26,7 @@ android {
|
|||||||
}
|
}
|
||||||
|
|
||||||
defaultConfig {
|
defaultConfig {
|
||||||
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
|
applicationId = "com.backsanhighschool.app"
|
||||||
applicationId = "com.example.school_attendance"
|
|
||||||
// You can update the following values to match your application needs.
|
// You can update the following values to match your application needs.
|
||||||
// For more information, see: https://flutter.dev/to/review-gradle-config.
|
// For more information, see: https://flutter.dev/to/review-gradle-config.
|
||||||
minSdk = flutter.minSdkVersion
|
minSdk = flutter.minSdkVersion
|
||||||
@@ -27,11 +35,20 @@ android {
|
|||||||
versionName = flutter.versionName
|
versionName = flutter.versionName
|
||||||
}
|
}
|
||||||
|
|
||||||
|
signingConfigs {
|
||||||
|
create("release") {
|
||||||
|
if (keystorePropertiesFile.exists()) {
|
||||||
|
keyAlias = keystoreProperties["keyAlias"] as String
|
||||||
|
keyPassword = keystoreProperties["keyPassword"] as String
|
||||||
|
storeFile = file(keystoreProperties["storeFile"] as String)
|
||||||
|
storePassword = keystoreProperties["storePassword"] as String
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
buildTypes {
|
buildTypes {
|
||||||
release {
|
release {
|
||||||
// TODO: Add your own signing config for the release build.
|
signingConfig = signingConfigs.getByName("release")
|
||||||
// Signing with the debug keys for now, so `flutter run --release` works.
|
|
||||||
signingConfig = signingConfigs.getByName("debug")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,25 @@
|
|||||||
"storage_bucket": "school-display-ff28f.firebasestorage.app"
|
"storage_bucket": "school-display-ff28f.firebasestorage.app"
|
||||||
},
|
},
|
||||||
"client": [
|
"client": [
|
||||||
|
{
|
||||||
|
"client_info": {
|
||||||
|
"mobilesdk_app_id": "1:137322214849:android:2595999513206bdeb16b40",
|
||||||
|
"android_client_info": {
|
||||||
|
"package_name": "com.backsanhighschool.app"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"oauth_client": [],
|
||||||
|
"api_key": [
|
||||||
|
{
|
||||||
|
"current_key": "AIzaSyBR6CZrAafCMssuifI9So42uSOSuPASe84"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"services": {
|
||||||
|
"appinvite_service": {
|
||||||
|
"other_platform_oauth_client": []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"client_info": {
|
"client_info": {
|
||||||
"mobilesdk_app_id": "1:137322214849:android:52d9ca6efcb9613eb16b40",
|
"mobilesdk_app_id": "1:137322214849:android:52d9ca6efcb9613eb16b40",
|
||||||
|
|||||||
@@ -11,8 +11,7 @@
|
|||||||
<application
|
<application
|
||||||
android:label="school_attendance"
|
android:label="school_attendance"
|
||||||
android:name="${applicationName}"
|
android:name="${applicationName}"
|
||||||
android:icon="@mipmap/ic_launcher"
|
android:icon="@mipmap/ic_launcher">
|
||||||
android:usesCleartextTraffic="true">
|
|
||||||
<activity
|
<activity
|
||||||
android:name=".MainActivity"
|
android:name=".MainActivity"
|
||||||
android:exported="true"
|
android:exported="true"
|
||||||
|
|||||||
+1
-1
@@ -1 +1 @@
|
|||||||
{"flutter":{"platforms":{"android":{"default":{"projectId":"school-display-ff28f","appId":"1:137322214849:android:52d9ca6efcb9613eb16b40","fileOutput":"android/app/google-services.json"}},"dart":{"lib/firebase_options.dart":{"projectId":"school-display-ff28f","configurations":{"android":"1:137322214849:android:52d9ca6efcb9613eb16b40","web":"1:137322214849:web:0423e5c788d95761b16b40"}}}}}}
|
{"flutter":{"platforms":{"android":{"default":{"projectId":"school-display-ff28f","appId":"1:137322214849:android:2595999513206bdeb16b40","fileOutput":"android/app/google-services.json"}},"dart":{"lib/firebase_options.dart":{"projectId":"school-display-ff28f","configurations":{"android":"1:137322214849:android:2595999513206bdeb16b40","web":"1:137322214849:web:0423e5c788d95761b16b40"}}}}}}
|
||||||
+1
-1
@@ -6,7 +6,7 @@ import 'package:firebase_messaging/firebase_messaging.dart';
|
|||||||
import 'package:flutter/foundation.dart' show kIsWeb;
|
import 'package:flutter/foundation.dart' show kIsWeb;
|
||||||
|
|
||||||
// 🌐 기존에 사용하시던 실제 백엔드 서버 도메인 주소로 통일합니다.
|
// 🌐 기존에 사용하시던 실제 백엔드 서버 도메인 주소로 통일합니다.
|
||||||
const String baseUrl = 'http://backsanhi.mcv.kr';
|
const String baseUrl = 'https://backsanhi.mcv.kr';
|
||||||
|
|
||||||
// 🔑 웹에서 FCM 토큰을 받으려면 필요한 VAPID 키
|
// 🔑 웹에서 FCM 토큰을 받으려면 필요한 VAPID 키
|
||||||
// (Firebase Console > 프로젝트 설정 > Cloud Messaging > 웹 구성 > 웹 푸시 인증서에서 발급)
|
// (Firebase Console > 프로젝트 설정 > Cloud Messaging > 웹 구성 > 웹 푸시 인증서에서 발급)
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ class DefaultFirebaseOptions {
|
|||||||
|
|
||||||
static const FirebaseOptions android = FirebaseOptions(
|
static const FirebaseOptions android = FirebaseOptions(
|
||||||
apiKey: 'AIzaSyBR6CZrAafCMssuifI9So42uSOSuPASe84',
|
apiKey: 'AIzaSyBR6CZrAafCMssuifI9So42uSOSuPASe84',
|
||||||
appId: '1:137322214849:android:52d9ca6efcb9613eb16b40',
|
appId: '1:137322214849:android:2595999513206bdeb16b40',
|
||||||
messagingSenderId: '137322214849',
|
messagingSenderId: '137322214849',
|
||||||
projectId: 'school-display-ff28f',
|
projectId: 'school-display-ff28f',
|
||||||
storageBucket: 'school-display-ff28f.firebasestorage.app',
|
storageBucket: 'school-display-ff28f.firebasestorage.app',
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:flutter_background_service/flutter_background_service.dart';
|
import 'package:flutter_background_service/flutter_background_service.dart';
|
||||||
import 'package:http/http.dart' as http;
|
import 'package:http/http.dart' as http;
|
||||||
import 'package:nfc_manager/nfc_manager.dart';
|
import 'package:nfc_manager/nfc_manager.dart';
|
||||||
|
import 'package:nfc_manager/nfc_manager_android.dart';
|
||||||
import 'package:nfc_manager_ndef/nfc_manager_ndef.dart';
|
import 'package:nfc_manager_ndef/nfc_manager_ndef.dart';
|
||||||
import 'package:permission_handler/permission_handler.dart';
|
import 'package:permission_handler/permission_handler.dart';
|
||||||
import 'config.dart' show baseUrl;
|
import 'config.dart' show baseUrl;
|
||||||
@@ -104,8 +105,8 @@ class _NfcPocketCheckInScreenState extends State<NfcPocketCheckInScreen> {
|
|||||||
cleanPocketNumber = rawPocketData.trim();
|
cleanPocketNumber = rawPocketData.trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. 파이썬 서버로 출석 정보 보내기
|
// 2. 파이썬 서버로 출석 정보 보내기 (복제 방지용 태그 UID도 함께 전송)
|
||||||
await _sendAttendanceToBackend(cleanPocketNumber);
|
await _sendAttendanceToBackend(cleanPocketNumber, tagUid: _getTagUid(tag));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
_showSnackBar("❌ 태그 읽기 실패: $e", Colors.red);
|
_showSnackBar("❌ 태그 읽기 실패: $e", Colors.red);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -119,8 +120,18 @@ class _NfcPocketCheckInScreenState extends State<NfcPocketCheckInScreen> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 태그의 공장 각인 하드웨어 UID를 16진수 문자열로 반환한다 (쓰기로 바꿀 수 없어 복제 방지 기준으로 씀).
|
||||||
|
String? _getTagUid(NfcTag tag) {
|
||||||
|
final id = NfcTagAndroid.from(tag)?.id;
|
||||||
|
if (id == null || id.isEmpty) return null;
|
||||||
|
return id.map((b) => b.toRadixString(16).padLeft(2, '0')).join().toUpperCase();
|
||||||
|
}
|
||||||
|
|
||||||
/// 🚚 파이썬 백엔드로 출석 데이터 HTTP POST 전송
|
/// 🚚 파이썬 백엔드로 출석 데이터 HTTP POST 전송
|
||||||
Future<void> _sendAttendanceToBackend(String pocketNumber) async {
|
Future<void> _sendAttendanceToBackend(
|
||||||
|
String pocketNumber, {
|
||||||
|
String? tagUid,
|
||||||
|
}) async {
|
||||||
final url = Uri.parse("$baseUrl/attendance");
|
final url = Uri.parse("$baseUrl/attendance");
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -131,6 +142,7 @@ class _NfcPocketCheckInScreenState extends State<NfcPocketCheckInScreen> {
|
|||||||
"studentId": widget.studentId,
|
"studentId": widget.studentId,
|
||||||
"studentName": widget.studentName,
|
"studentName": widget.studentName,
|
||||||
"pocketNumber": pocketNumber, // 주머니 번호 전달
|
"pocketNumber": pocketNumber, // 주머니 번호 전달
|
||||||
|
"tagUid": tagUid, // 🔒 서버가 태그 진위를 대조할 하드웨어 UID
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -287,19 +299,6 @@ class _NfcPocketCheckInScreenState extends State<NfcPocketCheckInScreen> {
|
|||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
style: TextStyle(color: Colors.grey, fontSize: 14),
|
style: TextStyle(color: Colors.grey, fontSize: 14),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 40),
|
|
||||||
|
|
||||||
// 🧪 [디버그 전용] NFC 태그나 카드가 없을 때 흐름을 테스트하기 위한 가상 태그 버튼
|
|
||||||
if (!_isProcessing)
|
|
||||||
OutlinedButton.icon(
|
|
||||||
onPressed: () => _sendAttendanceToBackend("POCKET_TEST_01"),
|
|
||||||
icon: const Icon(Icons.science_outlined),
|
|
||||||
label: const Text("가상 태그로 테스트 (NFC 없이)"),
|
|
||||||
style: OutlinedButton.styleFrom(
|
|
||||||
foregroundColor: Colors.purple,
|
|
||||||
side: const BorderSide(color: Colors.purple),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -0,0 +1,204 @@
|
|||||||
|
// 🏷️ (관리자용) 주머니 NFC 스티커 초기 설정 화면. 빈 태그에 "POCKET_번호" 텍스트를 써넣는다.
|
||||||
|
import 'dart:convert';
|
||||||
|
import 'dart:typed_data';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:http/http.dart' as http;
|
||||||
|
import 'package:nfc_manager/nfc_manager.dart';
|
||||||
|
import 'package:nfc_manager/nfc_manager_android.dart';
|
||||||
|
import 'package:nfc_manager/ndef_record.dart';
|
||||||
|
import 'package:nfc_manager_ndef/nfc_manager_ndef.dart';
|
||||||
|
import '../config.dart';
|
||||||
|
|
||||||
|
class NfcTagWriterScreen extends StatefulWidget {
|
||||||
|
const NfcTagWriterScreen({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<NfcTagWriterScreen> createState() => _NfcTagWriterScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _NfcTagWriterScreenState extends State<NfcTagWriterScreen> {
|
||||||
|
final TextEditingController _numberController = TextEditingController(
|
||||||
|
text: "1",
|
||||||
|
);
|
||||||
|
bool _isWriting = false;
|
||||||
|
String _statusMessage = "주머니 번호를 입력하고 '쓰기 시작'을 눌러주세요.";
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
NfcManager.instance.stopSession();
|
||||||
|
_numberController.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _startWriteSession() async {
|
||||||
|
final String number = _numberController.text.trim();
|
||||||
|
if (number.isEmpty) {
|
||||||
|
_showSnackBar("⚠️ 주머니 번호를 입력해주세요.", Colors.orange);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final String pocketLabel = "POCKET_$number";
|
||||||
|
|
||||||
|
bool isAvailable = await NfcManager.instance.isAvailable();
|
||||||
|
if (!isAvailable) {
|
||||||
|
_showSnackBar("❌ NFC가 꺼져있거나 지원되지 않습니다.", Colors.red);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setState(() {
|
||||||
|
_isWriting = true;
|
||||||
|
_statusMessage = "📡 [$pocketLabel] 쓸 준비 완료! 스티커에 폰 뒷면을 대주세요.";
|
||||||
|
});
|
||||||
|
|
||||||
|
NfcManager.instance.startSession(
|
||||||
|
pollingOptions: {NfcPollingOption.iso14443, NfcPollingOption.iso15693},
|
||||||
|
onDiscovered: (NfcTag tag) async {
|
||||||
|
try {
|
||||||
|
final ndef = Ndef.from(tag);
|
||||||
|
if (ndef == null) {
|
||||||
|
_showSnackBar("❌ 이 태그는 NDEF 쓰기를 지원하지 않는 종류입니다.", Colors.red);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!ndef.isWritable) {
|
||||||
|
_showSnackBar("❌ 이 태그는 쓰기 잠금(read-only) 상태입니다.", Colors.red);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await ndef.write(
|
||||||
|
message: NdefMessage(records: [_createTextRecord(pocketLabel)]),
|
||||||
|
);
|
||||||
|
|
||||||
|
// 🔒 태그 복제 방지: 하드웨어 UID를 서버에 등록해서 이 물리 태그만 [pocketLabel]로 인정되게 한다.
|
||||||
|
final String? tagUid = _getTagUid(tag);
|
||||||
|
if (tagUid != null) {
|
||||||
|
await _registerTagUid(tagUid, pocketLabel);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mounted) {
|
||||||
|
_showSnackBar(
|
||||||
|
tagUid != null
|
||||||
|
? "✅ [$pocketLabel] 쓰기 + UID 등록 성공!"
|
||||||
|
: "⚠️ [$pocketLabel] 쓰기는 성공했지만 UID를 못 읽어 등록은 안 됐습니다.",
|
||||||
|
tagUid != null ? Colors.green : Colors.orange,
|
||||||
|
);
|
||||||
|
// 다음 스티커를 연달아 쓰기 편하도록 번호를 자동으로 1 올려준다.
|
||||||
|
final int? n = int.tryParse(number);
|
||||||
|
setState(() {
|
||||||
|
if (n != null) _numberController.text = (n + 1).toString();
|
||||||
|
_statusMessage = "다음 번호를 확인하고 '쓰기 시작'을 다시 눌러주세요.";
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
_showSnackBar("❌ 쓰기 실패: $e", Colors.red);
|
||||||
|
} finally {
|
||||||
|
await NfcManager.instance.stopSession();
|
||||||
|
if (mounted) setState(() => _isWriting = false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 태그의 공장 각인 하드웨어 UID를 16진수 문자열로 반환한다 (쓰기로 바꿀 수 없어 복제 방지 기준으로 씀).
|
||||||
|
String? _getTagUid(NfcTag tag) {
|
||||||
|
final id = NfcTagAndroid.from(tag)?.id;
|
||||||
|
if (id == null || id.isEmpty) return null;
|
||||||
|
return id.map((b) => b.toRadixString(16).padLeft(2, '0')).join().toUpperCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 서버에 "이 UID는 이 주머니 번호다"를 등록한다.
|
||||||
|
Future<void> _registerTagUid(String tagUid, String pocketLabel) async {
|
||||||
|
try {
|
||||||
|
await http.post(
|
||||||
|
Uri.parse("$baseUrl/api/pockets/register"),
|
||||||
|
headers: {"Content-Type": "application/json"},
|
||||||
|
body: jsonEncode({"tagUid": tagUid, "pocketNumber": pocketLabel}),
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
_showSnackBar("❌ 서버에 UID 등록 실패: $e", Colors.red);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// NFC Forum Text Record Type Definition에 맞춰 "언어코드+텍스트" 페이로드를 만든다.
|
||||||
|
NdefRecord _createTextRecord(String text) {
|
||||||
|
const languageCode = 'en';
|
||||||
|
final languageBytes = utf8.encode(languageCode);
|
||||||
|
final textBytes = utf8.encode(text);
|
||||||
|
final payload = Uint8List.fromList([
|
||||||
|
languageBytes.length, // 상태 바이트: UTF-8 + 언어코드 길이
|
||||||
|
...languageBytes,
|
||||||
|
...textBytes,
|
||||||
|
]);
|
||||||
|
return NdefRecord(
|
||||||
|
typeNameFormat: TypeNameFormat.wellKnown,
|
||||||
|
type: Uint8List.fromList([0x54]), // 'T' = Text Record
|
||||||
|
identifier: Uint8List(0),
|
||||||
|
payload: payload,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _showSnackBar(String text, Color color) {
|
||||||
|
if (!mounted) return;
|
||||||
|
ScaffoldMessenger.of(
|
||||||
|
context,
|
||||||
|
).showSnackBar(SnackBar(content: Text(text), backgroundColor: color));
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(
|
||||||
|
title: const Text("🏷️ NFC 주머니 태그 쓰기"),
|
||||||
|
backgroundColor: Colors.deepPurple,
|
||||||
|
foregroundColor: Colors.white,
|
||||||
|
),
|
||||||
|
body: Padding(
|
||||||
|
padding: const EdgeInsets.all(24.0),
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
TextField(
|
||||||
|
controller: _numberController,
|
||||||
|
keyboardType: TextInputType.number,
|
||||||
|
enabled: !_isWriting,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: "주머니 번호",
|
||||||
|
prefixText: "POCKET_",
|
||||||
|
border: OutlineInputBorder(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 32),
|
||||||
|
Icon(
|
||||||
|
_isWriting ? Icons.nfc : Icons.edit_note_rounded,
|
||||||
|
size: 80,
|
||||||
|
color: _isWriting ? Colors.orange : Colors.deepPurple,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Text(
|
||||||
|
_statusMessage,
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: const TextStyle(fontSize: 15),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 32),
|
||||||
|
SizedBox(
|
||||||
|
width: double.infinity,
|
||||||
|
height: 55,
|
||||||
|
child: ElevatedButton(
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
backgroundColor: Colors.deepPurple,
|
||||||
|
foregroundColor: Colors.white,
|
||||||
|
),
|
||||||
|
onPressed: _isWriting ? null : _startWriteSession,
|
||||||
|
child: Text(
|
||||||
|
_isWriting ? "태그를 기다리는 중..." : "쓰기 시작",
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ import 'login_screen.dart';
|
|||||||
import 'teacher_attendance_page.dart';
|
import 'teacher_attendance_page.dart';
|
||||||
import 'admin_dashboard.dart';
|
import 'admin_dashboard.dart';
|
||||||
import 'student_management_screen.dart';
|
import 'student_management_screen.dart';
|
||||||
|
import 'nfc_tag_writer_screen.dart';
|
||||||
|
|
||||||
// -------------------------------------------------------------
|
// -------------------------------------------------------------
|
||||||
// 2. 학생 대시보드 (StudentDashboard)
|
// 2. 학생 대시보드 (StudentDashboard)
|
||||||
@@ -33,7 +34,7 @@ class StudentDashboard extends StatefulWidget {
|
|||||||
class _StudentDashboardState extends State<StudentDashboard> {
|
class _StudentDashboardState extends State<StudentDashboard> {
|
||||||
bool _isLoading = false;
|
bool _isLoading = false;
|
||||||
|
|
||||||
// 1️⃣ 가상 NFC 태깅 카드 → 실제 NFC 주머니 체크인 화면으로 이동
|
// 1️⃣ NFC 태그 카드 → 실제 NFC 주머니 체크인 화면으로 이동
|
||||||
void _openPocketCheckIn(BuildContext context) {
|
void _openPocketCheckIn(BuildContext context) {
|
||||||
Navigator.push(
|
Navigator.push(
|
||||||
context,
|
context,
|
||||||
@@ -303,7 +304,7 @@ class _StudentDashboardState extends State<StudentDashboard> {
|
|||||||
icon: widget.isDeviceMatched
|
icon: widget.isDeviceMatched
|
||||||
? Icons.contactless_rounded
|
? Icons.contactless_rounded
|
||||||
: Icons.lock_rounded,
|
: Icons.lock_rounded,
|
||||||
title: '가상 NFC 태깅',
|
title: 'NFC 태그',
|
||||||
subtitle: widget.isDeviceMatched
|
subtitle: widget.isDeviceMatched
|
||||||
? '출석 및 폰 수거 완료'
|
? '출석 및 폰 수거 완료'
|
||||||
: '⚠️ 본인 인증 기기 전용',
|
: '⚠️ 본인 인증 기기 전용',
|
||||||
@@ -387,6 +388,19 @@ class _StudentDashboardState extends State<StudentDashboard> {
|
|||||||
color: Colors.red[600]!,
|
color: Colors.red[600]!,
|
||||||
onTap: () => _showDeleteUserDialog(context),
|
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(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
Reference in New Issue
Block a user