플레이스토어 출시 준비: 릴리즈 서명, 패키지명 변경, 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:
2026-08-03 15:46:51 +09:00
co-authored by Claude Sonnet 5
parent 3f2fb0a57e
commit 7f6448ae9b
9 changed files with 280 additions and 28 deletions
+204
View File
@@ -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,
),
),
),
),
],
),
),
);
}
}
+16 -2
View File
@@ -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<StudentDashboard> {
bool _isLoading = false;
// 1️⃣ 가상 NFC 태깅 카드 → 실제 NFC 주머니 체크인 화면으로 이동
// 1️⃣ NFC 태그 카드 → 실제 NFC 주머니 체크인 화면으로 이동
void _openPocketCheckIn(BuildContext context) {
Navigator.push(
context,
@@ -303,7 +304,7 @@ class _StudentDashboardState extends State<StudentDashboard> {
icon: widget.isDeviceMatched
? Icons.contactless_rounded
: Icons.lock_rounded,
title: '가상 NFC 태깅',
title: 'NFC 태그',
subtitle: widget.isDeviceMatched
? '출석 및 폰 수거 완료'
: '⚠️ 본인 인증 기기 전용',
@@ -387,6 +388,19 @@ class _StudentDashboardState extends State<StudentDashboard> {
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(),
),
),
),
],
),
),