2026年10月4日 星期日

RFID Access Command Center(RFID 門禁控制中控台)

RFID Access Command Center(RFID 門禁控制中控台)







整理完整且包含手機 UI 最佳化、SQLite 本地儲存、MQTT 非同步監聯以及 Android 明文網路權限的 Flutter 專案完整程式碼與設定檔。

1. pubspec.yaml(套件依賴設定)

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" 設定:

XML
<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(完整主程式)

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 clean

重新取得套件依賴
執行 flutter pub get

完整重新編譯並執行
flutter run


要將 Flutter 專案編譯並打包成可以在安卓手機上直接安裝的 Release APK 檔案,請依照以下步驟執行:

1.檢查與設定 App 名稱與圖示 (可選):全域設定。

若需要自訂 App 顯示名稱,可開啟 android/app/src/main/AndroidManifest.xml,修改 android:label 欄位:

XML
<application
    android:label="門禁中控台"
    ... >
2.清理暫存檔案:程式碼檢查。

在打包前先清理舊的編譯快取,確保所有變更都以最新程式碼構建:

PowerShell
flutter clean

接著重新取得依賴套件:

PowerShell
flutter pub get
3.執行打包指令:核心指令。

在專案根目錄的 PowerShell 中執行以下打包命令(自動包含 ARM64、v7a 等常見架構):

PowerShell
flutter build apk --release

如果要產出檔案較小、適合特定 CPU 架構獨立安裝的 APK,可以使用 flutter build apk --split-per-abi。

4.尋找產出的 APK 檔案:取得檔案。

編譯完成後,終端機會顯示產出路徑。APK 預設儲存位置為:

Plaintext
<專案目錄>\build\app\outputs\flutter-apk\app-release.apk

💡 檔案傳輸與安裝方式

  1. 直接複製:將 app-release.apk 複製到 LINE、Google Drive,或透過 USB 傳輸線直接傳進安卓手機。

  2. 手機端安裝:在手機點擊該 APK 檔時,系統若提示「未知的應用程式」,請選擇「允許來自此來源的安裝」即可順利安裝。



為 Flutter App 設定正式 Release 簽署金鑰(Keystore)需要四個主要步驟:建立金鑰、設定環境變數/憑證檔、在 Flutter 專案中引用,以及修改 Gradle 設定。

步驟 1:建立金鑰檔案 (Keystore)

請開啟 PowerShell 或 命令提示字元 (CMD),執行 keytool 命令來產生簽署金鑰檔(.jks):

PowerShell
keytool -genkey -v -keystore C:\Users\YourUsername\upload-keystore.jks -storetype JKS -keyalg RSA -keysize 2048 -validity 10000 -alias upload

註:請將 YourUsername 替換為你的 Windows 使用者名稱,或是指定你想要的儲存路徑。

在執行過程中,系統會要求你設定:

  1. Keystore 密碼(請務必牢記!)

  2. 個人與組織資訊(公司名稱、部門等,可直接按 Enter 略過)

  3. Key 密碼(通常直接設為與 Keystore 密碼相同)

步驟 2:建立 key.properties 憑證設定檔

為了保護金鑰密碼不被上傳到 Git 版控,建議將密碼獨立寫在設定檔中:

在 Flutter 專案的 android/ 目錄下建立一個名為 key.properties 的檔案,並輸入以下內容:

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 { ... } 區塊進行修改:

Groovy
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:測試打包

完成設定後,執行以下指令打包:

PowerShell
flutter clean
flutter build apk --release

產出的 app-release.apk 就不再是預設的除錯簽署(Debug Key),而是使用你個人專屬金鑰簽署的正式 Release 版本。

沒有留言:

張貼留言

RFID Access Command Center(RFID 門禁控制中控台)

RFID Access Command Center (RFID 門禁控制中控台) 整理完整且包含 手機 UI 最佳化 、 SQLite 本地儲存 、 MQTT 非同步監聯 以及 Android 明文網路權限的 Flutter 專案完整程式碼與設定檔。 1. pubspec.ya...