RFID Access Command Center(RFID 門禁控制中控台)
整理完整且包含手機 UI 最佳化、SQLite 本地儲存、MQTT 非同步監聯以及 Android 明文網路權限的 Flutter 專案完整程式碼與設定檔。
1. pubspec.yaml(套件依賴設定)
name: mfrc522_mqtt
description: "RFID & MQTT 門禁控制中控台"
publish_to: 'none'
version: 1.0.0+1
environment:
sdk: '>=3.0.0 <4.0.0'
dependencies:
flutter:
sdk: flutter
sqflite: ^2.3.0
path: ^1.8.3
mqtt_client: ^10.2.0
intl: ^0.19.0
dev_dependencies:
flutter_test:
sdk: flutter
flutter_lints: ^3.0.0
flutter:
uses-material-design: true
2. android/app/src/main/AndroidManifest.xml(權限與明文連線)
包含 INTERNET 權限與 android:usesCleartextTraffic="true" 設定:
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- 網路權限 (Release APK 必須) -->
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
<application
android:label="mfrc522_mqtt"
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher"
android:usesCleartextTraffic="true">
<activity
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTop"
android:taskAffinity=""
android:theme="@style/LaunchTheme"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
android:hardwareAccelerated="true"
android:windowSoftInputMode="adjustResize">
<meta-data
android:name="io.flutter.embedding.android.NormalTheme"
android:resource="@style/NormalTheme"
/>
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
</activity>
<meta-data
android:name="flutterEmbedding"
android:value="2" />
</application>
<queries>
<intent>
<action android:name="android.intent.action.PROCESS_TEXT"/>
<data android:mimeType="text/plain"/>
</intent>
</queries>
</manifest>
3. lib/main.dart(完整主程式)
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:sqflite/sqflite.dart';
import 'package:path/path.dart' as p;
import 'package:mqtt_client/mqtt_client.dart';
import 'package:mqtt_client/mqtt_server_client.dart';
import 'package:intl/intl.dart';
// ==========================
// 1. MQTT 設定與主題
// ==========================
const String broker = "broker.emqx.io";
const int port = 1883;
const String topicUid = "alex9ufo/2026/RFID/RFID_UID";
const String topicPicc = "alex9ufo/2026/RFID/RFID_PICC";
const String topicControl = "alex9ufo/2026/RFID/CONTROL";
void main() {
WidgetsFlutterBinding.ensureInitialized();
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'RFID 控制中控台',
debugShowCheckedModeBanner: false,
theme: ThemeData(
primarySwatch: Colors.green,
useMaterial3: true,
),
home: const RFIDConsoleScreen(),
);
}
}
// ==========================
// 2. SQLite 資料庫管理類別
// ==========================
class LogModel {
final int? id;
final String mqtt;
final String date;
final String time;
LogModel({this.id, required this.mqtt, required this.date, required this.time});
Map<String, dynamic> toMap() {
return {
'id': id,
'mqtt': mqtt,
'date': date,
'time': time,
};
}
factory LogModel.fromMap(Map<String, dynamic> map) {
return LogModel(
id: map['id'],
mqtt: map['mqtt'],
date: map['date'],
time: map['time'],
);
}
}
class DatabaseManager {
static Database? _db;
Future<Database> get database async {
if (_db != null) return _db!;
_db = await initDb();
return _db!;
}
Future<Database> initDb() async {
String dbPath = await getDatabasesPath();
String path = p.join(dbPath, 'mqtt.db');
return await openDatabase(
path,
version: 1,
onCreate: (Database db, int version) async {
await db.execute('''
CREATE TABLE rfid_logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
mqtt TEXT NOT NULL,
date TEXT NOT NULL,
time TEXT NOT NULL
)
''');
},
);
}
Future<void> insertLog(String message) async {
final db = await database;
DateTime now = DateTime.now();
String dateStr = DateFormat('yyyy-MM-dd').format(now);
String timeStr = DateFormat('HH:mm:ss').format(now);
await db.insert(
'rfid_logs',
{
'mqtt': message,
'date': dateStr,
'time': timeStr,
},
conflictAlgorithm: ConflictAlgorithm.replace,
);
}
Future<List<LogModel>> fetchRecent30() async {
final db = await database;
final List<Map<String, dynamic>> maps = await db.query(
'rfid_logs',
orderBy: 'id DESC',
limit: 30,
);
return List.generate(maps.length, (i) => LogModel.fromMap(maps[i]));
}
}
// ==========================
// 3. GUI 主程式介面 (UI)
// ==========================
class RFIDConsoleScreen extends StatefulWidget {
const RFIDConsoleScreen({super.key});
@override
State<RFIDConsoleScreen> createState() => _RFIDConsoleScreenState();
}
class _RFIDConsoleScreenState extends State<RFIDConsoleScreen> {
final DatabaseManager _dbManager = DatabaseManager();
late MqttServerClient _mqttClient;
String _statusText = "連線中...";
Color _statusColor = Colors.orange;
List<LogModel> _logs = [];
StreamSubscription? _mqttSubscription;
@override
void initState() {
super.initState();
_refreshTable();
_setupMqtt();
}
@override
void dispose() {
_mqttSubscription?.cancel();
_mqttClient.disconnect();
super.dispose();
}
// 刷新表格資料
Future<void> _refreshTable() async {
List<LogModel> logs = await _dbManager.fetchRecent30();
setState(() {
_logs = logs;
});
}
// MQTT 初始化與連線
Future<void> _setupMqtt() async {
String clientIdentifier = 'flutter_client_${DateTime.now().millisecondsSinceEpoch}';
_mqttClient = MqttServerClient(broker, clientIdentifier);
_mqttClient.port = port;
_mqttClient.keepAlivePeriod = 60;
_mqttClient.autoReconnect = true;
_mqttClient.logging(on: false);
_mqttClient.onDisconnected = () {
if (mounted) {
setState(() {
_statusText = "已斷開連線";
_statusColor = Colors.red;
});
}
};
final connMess = MqttConnectMessage()
.withClientIdentifier(clientIdentifier)
.startClean();
_mqttClient.connectionMessage = connMess;
try {
await _mqttClient.connect();
if (_mqttClient.connectionStatus?.state == MqttConnectionState.connected) {
setState(() {
_statusText = "已連線 (EMQX)";
_statusColor = Colors.green;
});
// 訂閱主題
_mqttClient.subscribe(topicUid, MqttQos.atMostOnce);
_mqttClient.subscribe(topicPicc, MqttQos.atMostOnce);
// 監聽接收訊息
_mqttSubscription = _mqttClient.updates?.listen((List<MqttReceivedMessage<MqttMessage>> c) {
final MqttPublishMessage recMess = c[0].payload as MqttPublishMessage;
final String payload = MqttPublishPayload.bytesToStringAsString(recMess.payload.message);
final String topic = c[0].topic;
_handleIncomingMessage(topic, payload);
});
} else {
setState(() {
_statusText = "連線失敗";
_statusColor = Colors.red;
});
}
} catch (e) {
if (mounted) {
setState(() {
_statusText = "連線異常: $e";
_statusColor = Colors.red;
});
}
_mqttClient.disconnect();
}
}
// 處理收到的 MQTT 訊息
Future<void> _handleIncomingMessage(String topic, String payload) async {
String formattedMsg;
if (topic == topicUid) {
formattedMsg = "[UID] $payload";
} else if (topic == topicPicc) {
formattedMsg = "[PICC/Keypad] $payload";
} else {
formattedMsg = "[$topic] $payload";
}
// 寫入 SQLite 並刷新 UI
await _dbManager.insertLog(formattedMsg);
await _refreshTable();
}
// 發送遠端開門指令
Future<void> _sendOpenCommand() async {
if (_mqttClient.connectionStatus?.state == MqttConnectionState.connected) {
// 1. 發送 MQTT 指令
final builder = MqttClientPayloadBuilder();
builder.addString('open');
_mqttClient.publishMessage(topicControl, MqttQos.atMostOnce, builder.payload!);
// 2. 寫入 SQLite
await _dbManager.insertLog("RFID/CONTROL");
// 3. 即時刷新 GUI
await _refreshTable();
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text("已發送 'open' 指令至 ESP32!"),
duration: Duration(seconds: 2),
),
);
}
} else {
if (mounted) {
showDialog(
context: context,
builder: (ctx) => AlertDialog(
title: const Text("連線失敗"),
content: const Text("MQTT 目前未連線,無法發送指令!"),
actions: [
TextButton(
onPressed: () => Navigator.of(ctx).pop(),
child: const Text("確定"),
)
],
),
);
}
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text("RFID 門禁控制中控台", style: TextStyle(fontSize: 18)),
centerTitle: true,
),
body: SafeArea(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 10.0, vertical: 8.0),
child: Column(
children: [
// 頂部狀態列與控制按鈕
Container(
padding: const EdgeInsets.all(8.0),
decoration: BoxDecoration(
color: Colors.grey[100],
borderRadius: BorderRadius.circular(8),
),
child: Row(
children: [
Expanded(
child: Row(
children: [
const Text("狀態: ", style: TextStyle(fontSize: 12, fontWeight: FontWeight.bold)),
Expanded(
child: Text(
_statusText,
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.bold,
color: _statusColor,
),
overflow: TextOverflow.ellipsis,
),
),
],
),
),
const SizedBox(width: 8),
ElevatedButton.icon(
style: ElevatedButton.styleFrom(
backgroundColor: Colors.green,
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
visualDensity: VisualDensity.compact,
),
onPressed: _sendOpenCommand,
icon: const Text("🔓", style: TextStyle(fontSize: 14)),
label: const Text("遠端開門", style: TextStyle(fontSize: 12, fontWeight: FontWeight.bold)),
),
],
),
),
const SizedBox(height: 8),
// 表格區域 (針對手機最佳化)
Expanded(
child: Card(
margin: EdgeInsets.zero,
elevation: 1,
child: SizedBox(
width: double.infinity,
child: SingleChildScrollView(
scrollDirection: Axis.vertical,
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: DataTable(
columnSpacing: 10,
horizontalMargin: 8,
headingRowHeight: 36,
dataRowMinHeight: 32,
dataRowMaxHeight: 44,
columns: const [
DataColumn(label: Text('ID', style: TextStyle(fontSize: 11, fontWeight: FontWeight.bold))),
DataColumn(label: Text('MQTT 接收內容', style: TextStyle(fontSize: 11, fontWeight: FontWeight.bold))),
DataColumn(label: Text('日期', style: TextStyle(fontSize: 11, fontWeight: FontWeight.bold))),
DataColumn(label: Text('時間', style: TextStyle(fontSize: 11, fontWeight: FontWeight.bold))),
],
rows: _logs.map((log) {
return DataRow(cells: [
DataCell(Text(log.id.toString(), style: const TextStyle(fontSize: 11))),
DataCell(Text(log.mqtt, style: const TextStyle(fontSize: 11))),
DataCell(Text(log.date, style: const TextStyle(fontSize: 11))),
DataCell(Text(log.time, style: const TextStyle(fontSize: 11))),
]);
}).toList(),
),
),
),
),
),
),
// 底部說明文字
Padding(
padding: const EdgeInsets.only(top: 6.0),
child: Align(
alignment: Alignment.centerLeft,
child: Text(
"註:SQLite (mqtt.db) 自動維持顯示最近 30 筆紀錄。",
style: TextStyle(fontSize: 10, color: Colors.grey[600]),
),
),
),
],
),
),
),
);
}
}要將 Flutter 專案編譯並打包成可以在安卓手機上直接安裝的 Release APK 檔案,請依照以下步驟執行:
💡 檔案傳輸與安裝方式
直接複製:將
app-release.apk複製到 LINE、Google Drive,或透過 USB 傳輸線直接傳進安卓手機。手機端安裝:在手機點擊該 APK 檔時,系統若提示「未知的應用程式」,請選擇「允許來自此來源的安裝」即可順利安裝。
為 Flutter App 設定正式 Release 簽署金鑰(Keystore)需要四個主要步驟:建立金鑰、設定環境變數/憑證檔、在 Flutter 專案中引用,以及修改 Gradle 設定。
步驟 1:建立金鑰檔案 (Keystore)
請開啟 PowerShell 或 命令提示字元 (CMD),執行 keytool 命令來產生簽署金鑰檔(.jks):
keytool -genkey -v -keystore C:\Users\YourUsername\upload-keystore.jks -storetype JKS -keyalg RSA -keysize 2048 -validity 10000 -alias upload
註:請將 YourUsername 替換為你的 Windows 使用者名稱,或是指定你想要的儲存路徑。
在執行過程中,系統會要求你設定:
Keystore 密碼(請務必牢記!)
個人與組織資訊(公司名稱、部門等,可直接按 Enter 略過)
Key 密碼(通常直接設為與 Keystore 密碼相同)
步驟 2:建立 key.properties 憑證設定檔
為了保護金鑰密碼不被上傳到 Git 版控,建議將密碼獨立寫在設定檔中:
在 Flutter 專案的 android/ 目錄下建立一個名為 key.properties 的檔案,並輸入以下內容:
storePassword=你的Keystore密碼
keyPassword=你的Key密碼
keyAlias=upload
storeFile=C:/Users/YourUsername/upload-keystore.jks
(注意:路徑中的反斜線 \ 建議改為正斜線 /)
步驟 3:忽略 key.properties 防止提交到 Git
開啟專案根目錄的 .gitignore 檔案,確保包含以下這行(Flutter 預設通常已有):
*.jks
**/android/key.properties
步驟 4:修改 android/app/build.gradle (或 build.gradle.kts)
開啟 android/app/build.gradle 檔案,找到 android { ... } 區塊進行修改:
import java.io.FileInputStream
import java.util.Properties
// 1. 讀取 key.properties 檔案
def keystoreProperties = new Properties()
def keystorePropertiesFile = rootProject.file('key.properties')
if (keystorePropertiesFile.exists()) {
keystoreProperties.load(new FileInputStream(keystorePropertiesFile))
}
android {
...
// 2. 設定 signingConfigs
signingConfigs {
release {
if (keystorePropertiesFile.exists()) {
keyAlias keystoreProperties['keyAlias']
keyPassword keystoreProperties['keyPassword']
storeFile file(keystoreProperties['storeFile'])
storePassword keystoreProperties['storePassword']
}
}
}
buildTypes {
release {
// 3. 將 release 的 signingConfig 指向剛剛設定好的 release 簽署
signingConfig signingConfigs.release
minifyEnabled false
shrinkResources false
}
}
}
步驟 5:測試打包
完成設定後,執行以下指令打包:
flutter clean
flutter build apk --release
產出的 app-release.apk 就不再是預設的除錯簽署(Debug Key),而是使用你個人專屬金鑰簽署的正式 Release 版本。





沒有留言:
張貼留言