diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 24a4fe9..ccf181a 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -1,3 +1,6 @@ +import java.util.Properties +import java.io.FileInputStream + plugins { id("com.android.application") // 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") } +val keystoreProperties = Properties() +val keystorePropertiesFile = rootProject.file("key.properties") +if (keystorePropertiesFile.exists()) { + keystoreProperties.load(FileInputStream(keystorePropertiesFile)) +} + android { namespace = "com.example.school_attendance" compileSdk = 36 @@ -17,8 +26,7 @@ android { } defaultConfig { - // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). - applicationId = "com.example.school_attendance" + applicationId = "com.backsanhighschool.app" // You can update the following values to match your application needs. // For more information, see: https://flutter.dev/to/review-gradle-config. minSdk = flutter.minSdkVersion @@ -27,11 +35,20 @@ android { 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 { release { - // TODO: Add your own signing config for the release build. - // Signing with the debug keys for now, so `flutter run --release` works. - signingConfig = signingConfigs.getByName("debug") + signingConfig = signingConfigs.getByName("release") } } } diff --git a/android/app/google-services.json b/android/app/google-services.json index 2af4fd3..c02d642 100644 --- a/android/app/google-services.json +++ b/android/app/google-services.json @@ -5,6 +5,25 @@ "storage_bucket": "school-display-ff28f.firebasestorage.app" }, "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": { "mobilesdk_app_id": "1:137322214849:android:52d9ca6efcb9613eb16b40", diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 316cea1..bd809ec 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -11,8 +11,7 @@ + android:icon="@mipmap/ic_launcher"> ํ”„๋กœ์ ํŠธ ์„ค์ • > Cloud Messaging > ์›น ๊ตฌ์„ฑ > ์›น ํ‘ธ์‹œ ์ธ์ฆ์„œ์—์„œ ๋ฐœ๊ธ‰) diff --git a/lib/firebase_options.dart b/lib/firebase_options.dart index 1df426b..ee5b1db 100644 --- a/lib/firebase_options.dart +++ b/lib/firebase_options.dart @@ -61,7 +61,7 @@ class DefaultFirebaseOptions { static const FirebaseOptions android = FirebaseOptions( apiKey: 'AIzaSyBR6CZrAafCMssuifI9So42uSOSuPASe84', - appId: '1:137322214849:android:52d9ca6efcb9613eb16b40', + appId: '1:137322214849:android:2595999513206bdeb16b40', messagingSenderId: '137322214849', projectId: 'school-display-ff28f', storageBucket: 'school-display-ff28f.firebasestorage.app', diff --git a/lib/nfc_poccket_checkin_screen.dart b/lib/nfc_poccket_checkin_screen.dart index 41ac678..12ea5cd 100644 --- a/lib/nfc_poccket_checkin_screen.dart +++ b/lib/nfc_poccket_checkin_screen.dart @@ -8,6 +8,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_background_service/flutter_background_service.dart'; import 'package:http/http.dart' as http; import 'package:nfc_manager/nfc_manager.dart'; +import 'package:nfc_manager/nfc_manager_android.dart'; import 'package:nfc_manager_ndef/nfc_manager_ndef.dart'; import 'package:permission_handler/permission_handler.dart'; import 'config.dart' show baseUrl; @@ -104,8 +105,8 @@ class _NfcPocketCheckInScreenState extends State { cleanPocketNumber = rawPocketData.trim(); } - // 2. ํŒŒ์ด์ฌ ์„œ๋ฒ„๋กœ ์ถœ์„ ์ •๋ณด ๋ณด๋‚ด๊ธฐ - await _sendAttendanceToBackend(cleanPocketNumber); + // 2. ํŒŒ์ด์ฌ ์„œ๋ฒ„๋กœ ์ถœ์„ ์ •๋ณด ๋ณด๋‚ด๊ธฐ (๋ณต์ œ ๋ฐฉ์ง€์šฉ ํƒœ๊ทธ UID๋„ ํ•จ๊ป˜ ์ „์†ก) + await _sendAttendanceToBackend(cleanPocketNumber, tagUid: _getTagUid(tag)); } catch (e) { _showSnackBar("โŒ ํƒœ๊ทธ ์ฝ๊ธฐ ์‹คํŒจ: $e", Colors.red); } finally { @@ -119,8 +120,18 @@ class _NfcPocketCheckInScreenState extends State { ); } + /// ํƒœ๊ทธ์˜ ๊ณต์žฅ ๊ฐ์ธ ํ•˜๋“œ์›จ์–ด UID๋ฅผ 16์ง„์ˆ˜ ๋ฌธ์ž์—ด๋กœ ๋ฐ˜ํ™˜ํ•œ๋‹ค (์“ฐ๊ธฐ๋กœ ๋ฐ”๊ฟ€ ์ˆ˜ ์—†์–ด ๋ณต์ œ ๋ฐฉ์ง€ ๊ธฐ์ค€์œผ๋กœ ์”€). + String? _getTagUid(NfcTag tag) { + final id = NfcTagAndroid.from(tag)?.id; + if (id == null || id.isEmpty) return null; + return id.map((b) => b.toRadixString(16).padLeft(2, '0')).join().toUpperCase(); + } + /// ๐Ÿšš ํŒŒ์ด์ฌ ๋ฐฑ์—”๋“œ๋กœ ์ถœ์„ ๋ฐ์ดํ„ฐ HTTP POST ์ „์†ก - Future _sendAttendanceToBackend(String pocketNumber) async { + Future _sendAttendanceToBackend( + String pocketNumber, { + String? tagUid, + }) async { final url = Uri.parse("$baseUrl/attendance"); try { @@ -131,6 +142,7 @@ class _NfcPocketCheckInScreenState extends State { "studentId": widget.studentId, "studentName": widget.studentName, "pocketNumber": pocketNumber, // ์ฃผ๋จธ๋‹ˆ ๋ฒˆํ˜ธ ์ „๋‹ฌ + "tagUid": tagUid, // ๐Ÿ”’ ์„œ๋ฒ„๊ฐ€ ํƒœ๊ทธ ์ง„์œ„๋ฅผ ๋Œ€์กฐํ•  ํ•˜๋“œ์›จ์–ด UID }), ); @@ -287,19 +299,6 @@ class _NfcPocketCheckInScreenState extends State { textAlign: TextAlign.center, style: TextStyle(color: Colors.grey, fontSize: 14), ), - const SizedBox(height: 40), - - // ๐Ÿงช [๋””๋ฒ„๊ทธ ์ „์šฉ] NFC ํƒœ๊ทธ๋‚˜ ์นด๋“œ๊ฐ€ ์—†์„ ๋•Œ ํ๋ฆ„์„ ํ…Œ์ŠคํŠธํ•˜๊ธฐ ์œ„ํ•œ ๊ฐ€์ƒ ํƒœ๊ทธ ๋ฒ„ํŠผ - if (!_isProcessing) - OutlinedButton.icon( - onPressed: () => _sendAttendanceToBackend("POCKET_TEST_01"), - icon: const Icon(Icons.science_outlined), - label: const Text("๊ฐ€์ƒ ํƒœ๊ทธ๋กœ ํ…Œ์ŠคํŠธ (NFC ์—†์ด)"), - style: OutlinedButton.styleFrom( - foregroundColor: Colors.purple, - side: const BorderSide(color: Colors.purple), - ), - ), ], ), ), diff --git a/lib/screens/nfc_tag_writer_screen.dart b/lib/screens/nfc_tag_writer_screen.dart new file mode 100644 index 0000000..bc61ecd --- /dev/null +++ b/lib/screens/nfc_tag_writer_screen.dart @@ -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 createState() => _NfcTagWriterScreenState(); +} + +class _NfcTagWriterScreenState extends State { + 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 _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, + ), + ), + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/screens/student_dashboard.dart b/lib/screens/student_dashboard.dart index 0595dab..6e28926 100644 --- a/lib/screens/student_dashboard.dart +++ b/lib/screens/student_dashboard.dart @@ -9,6 +9,7 @@ import 'login_screen.dart'; import 'teacher_attendance_page.dart'; import 'admin_dashboard.dart'; import 'student_management_screen.dart'; +import 'nfc_tag_writer_screen.dart'; // ------------------------------------------------------------- // 2. ํ•™์ƒ ๋Œ€์‹œ๋ณด๋“œ (StudentDashboard) @@ -33,7 +34,7 @@ class StudentDashboard extends StatefulWidget { class _StudentDashboardState extends State { bool _isLoading = false; - // 1๏ธโƒฃ ๊ฐ€์ƒ NFC ํƒœ๊น… ์นด๋“œ โ†’ ์‹ค์ œ NFC ์ฃผ๋จธ๋‹ˆ ์ฒดํฌ์ธ ํ™”๋ฉด์œผ๋กœ ์ด๋™ + // 1๏ธโƒฃ NFC ํƒœ๊ทธ ์นด๋“œ โ†’ ์‹ค์ œ NFC ์ฃผ๋จธ๋‹ˆ ์ฒดํฌ์ธ ํ™”๋ฉด์œผ๋กœ ์ด๋™ void _openPocketCheckIn(BuildContext context) { Navigator.push( context, @@ -303,7 +304,7 @@ class _StudentDashboardState extends State { icon: widget.isDeviceMatched ? Icons.contactless_rounded : Icons.lock_rounded, - title: '๊ฐ€์ƒ NFC ํƒœ๊น…', + title: 'NFC ํƒœ๊ทธ', subtitle: widget.isDeviceMatched ? '์ถœ์„ ๋ฐ ํฐ ์ˆ˜๊ฑฐ ์™„๋ฃŒ' : 'โš ๏ธ ๋ณธ์ธ ์ธ์ฆ ๊ธฐ๊ธฐ ์ „์šฉ', @@ -387,6 +388,19 @@ class _StudentDashboardState extends State { 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(), + ), + ), + ), ], ), ),