diff --git a/.gitignore b/.gitignore
index ab7da7b..ccf9e58 100644
--- a/.gitignore
+++ b/.gitignore
@@ -164,4 +164,5 @@ fabric.properties
!/gradle/wrapper/gradle-wrapper.jar
macos/Flutter/ephemeral/flutter_export_environment.sh
macos/Flutter/ephemeral/Flutter-Generated.xcconfig
-*.py
\ No newline at end of file
+*.py
+PDW
\ No newline at end of file
diff --git a/README.md b/README.md
index 2d1a2f0..b1ccc97 100644
--- a/README.md
+++ b/README.md
@@ -1,12 +1,8 @@
# LBJ_Console
-LBJ Console 是一个应用程序,用于通过 BLE 从 [SX1276_Receive_LBJ](https://github.com/undef-i/SX1276_Receive_LBJ) 设备接收并显示列车预警消息,功能包括:
+LBJ Console 是一个应用程序,用于接收并显示列车预警消息,功能包括:
-- 接收列车预警消息,支持可选的手机推送通知。
-- 监控指定列车的轨迹,在地图上显示。
-- 在地图上显示预警消息的 GPS 信息。
-- 基于内置数据文件显示机车配属,机车类型和车次类型。
-- 连接 RTL-TCP 服务器获取预警消息。
+应用程序支持从 SX1276_Receive_LBJ 获取 BLE 预警数据,或直接连接 RTL-TCP 服务器从 RTL-SDR 接收预警消息。在可视化方面,软件能够在地图上标注预警消息的 GPS 位置,并支持绘制指定列车的运行轨迹。此外,程序内置了机车数据文件,可根据数据内容匹配并显示机车配属、机车类型以及车次类型。
[android](https://github.com/undef-i/LBJ_Console/tree/android) 分支包含项目早期基于 Android 平台的实现代码,已实现基本功能,现已停止开发。
@@ -26,6 +22,7 @@ LBJ Console 依赖以下数据文件,位于 `assets` 目录,用于支持机
- 集成 ESP-Touch 协议,实现设备 WiFi 凭证的配置。
- 从设备端拉取历史数据记录。
+- 从音频流解析预警消息。
# 致谢
diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
index 40c2721..88a4a5d 100644
--- a/android/app/src/main/AndroidManifest.xml
+++ b/android/app/src/main/AndroidManifest.xml
@@ -1,5 +1,8 @@
+
+
+
@@ -31,10 +34,6 @@
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
android:hardwareAccelerated="true"
android:windowSoftInputMode="adjustResize">
-
-
-
-
-
+
\ No newline at end of file
diff --git a/android/app/src/main/cpp/demod.cpp b/android/app/src/main/cpp/demod.cpp
index 9dffcde..c98b2c3 100644
--- a/android/app/src/main/cpp/demod.cpp
+++ b/android/app/src/main/cpp/demod.cpp
@@ -14,17 +14,29 @@ uint32_t bits;
uint32_t code_words[PAGERDEMOD_BATCH_WORDS];
bool code_words_bch_error[PAGERDEMOD_BATCH_WORDS];
+static bool hysteresis_state = false;
+static bool dsp_initialized = false;
+
std::string numeric_msg, alpha_msg;
int function_bits;
uint32_t address;
-uint32_t alpha_bit_buffer; // Bit buffer to 7-bit chars spread across codewords
-int alpha_bit_buffer_bits; // Count of bits in alpha_bit_buffer
-int parity_errors; // Count of parity errors in current message
-int bch_errors; // Count of BCH errors in current message
-int batch_num; // Count of batches in current transmission
+uint32_t alpha_bit_buffer;
+int alpha_bit_buffer_bits;
+int parity_errors;
+int bch_errors;
+int batch_num;
double magsqRaw;
+void ensureDSPInitialized() {
+ if (dsp_initialized) return;
+
+ lowpassBaud.create(301, SAMPLE_RATE, BAUD_RATE * 5.0f);
+ phaseDiscri.setFMScaling(SAMPLE_RATE / (2.0f * DEVIATION));
+
+ dsp_initialized = true;
+}
+
int pop_cnt(uint32_t cw)
{
int cnt = 0;
@@ -39,10 +51,9 @@ int pop_cnt(uint32_t cw)
uint32_t bchEncode(const uint32_t cw)
{
uint32_t bit = 0;
- uint32_t localCW = cw & 0xFFFFF800; // Mask off BCH parity and even parity bits
+ uint32_t localCW = cw & 0xFFFFF800;
uint32_t cwE = localCW;
- // Calculate BCH bits
for (bit = 1; bit <= 21; bit++)
{
if (cwE & 0x80000000)
@@ -56,38 +67,28 @@ uint32_t bchEncode(const uint32_t cw)
return localCW;
}
-// Use BCH decoding to try to fix any bit errors
-// Returns true if able to be decode/repair successful
-// See: https://www.eevblog.com/forum/microcontrollers/practical-guides-to-bch-fec/
bool bchDecode(const uint32_t cw, uint32_t &correctedCW)
{
- // Calculate syndrome
- // We do this by recalculating the BCH parity bits and XORing them against the received ones
uint32_t syndrome = ((bchEncode(cw) ^ cw) >> 1) & 0x3FF;
if (syndrome == 0)
{
- // Syndrome of zero indicates no repair required
correctedCW = cw;
return true;
}
- // Meggitt decoder
-
uint32_t result = 0;
uint32_t damagedCW = cw;
- // Calculate BCH bits
for (uint32_t xbit = 0; xbit < 31; xbit++)
{
- // Produce the next corrected bit in the high bit of the result
result <<= 1;
- if ((syndrome == 0x3B4) || // 0x3B4: Syndrome when a single error is detected in the MSB
- (syndrome == 0x26E) || // 0x26E: Two adjacent errors
- (syndrome == 0x359) || // 0x359: Two errors, one OK bit between
- (syndrome == 0x076) || // 0x076: Two errors, two OK bits between
- (syndrome == 0x255) || // 0x255: Two errors, three OK bits between
- (syndrome == 0x0F0) || // 0x0F0: Two errors, four OK bits between
+ if ((syndrome == 0x3B4) ||
+ (syndrome == 0x26E) ||
+ (syndrome == 0x359) ||
+ (syndrome == 0x076) ||
+ (syndrome == 0x255) ||
+ (syndrome == 0x0F0) ||
(syndrome == 0x216) ||
(syndrome == 0x365) ||
(syndrome == 0x068) ||
@@ -114,36 +115,29 @@ bool bchDecode(const uint32_t cw, uint32_t &correctedCW)
(syndrome == 0x3B6) ||
(syndrome == 0x3B5))
{
- // Syndrome matches an error in the MSB
- // Correct that error and adjust the syndrome to account for it
syndrome ^= 0x3B4;
result |= (~damagedCW & 0x80000000) >> 30;
}
else
{
- // No error
result |= (damagedCW & 0x80000000) >> 30;
}
damagedCW <<= 1;
- // Handle syndrome shift register feedback
if (syndrome & 0x200)
{
syndrome <<= 1;
- syndrome ^= 0x769; // 0x769 = POCSAG generator polynomial -- x^10 + x^9 + x^8 + x^6 + x^5 + x^3 + 1
+ syndrome ^= 0x769;
}
else
{
syndrome <<= 1;
}
- // Mask off bits which fall off the end of the syndrome shift register
syndrome &= 0x3FF;
}
- // Check if error correction was successful
if (syndrome != 0)
{
- // Syndrome nonzero at end indicates uncorrectable errors
correctedCW = cw;
return false;
}
@@ -162,13 +156,11 @@ int xorBits(uint32_t word, int firstBit, int lastBit)
return x;
}
-// Check for even parity
bool evenParity(uint32_t word, int firstBit, int lastBit, int parityBit)
{
return xorBits(word, firstBit, lastBit) == parityBit;
}
-// Reverse order of bits
uint32_t reverse(uint32_t x)
{
x = (((x & 0xaaaaaaaa) >> 1) | ((x & 0x55555555) << 1));
@@ -178,10 +170,6 @@ uint32_t reverse(uint32_t x)
return ((x >> 16) | (x << 16));
}
-// Decode a batch of codewords to addresses and messages
-// Messages may be spreadout over multiple batches
-// https://www.itu.int/dms_pubrec/itu-r/rec/m/R-REC-M.584-1-198607-S!!PDF-E.pdf
-// https://www.itu.int/dms_pubrec/itu-r/rec/m/R-REC-M.584-2-199711-I!!PDF-E.pdf
void decodeBatch()
{
int i = 1;
@@ -190,17 +178,13 @@ void decodeBatch()
for (int word = 0; word < PAGERDEMOD_CODEWORDS_PER_FRAME; word++)
{
bool addressCodeWord = ((code_words[i] >> 31) & 1) == 0;
-
- // Check parity bit
bool parityError = !evenParity(code_words[i], 1, 31, code_words[i] & 0x1);
if (code_words[i] == PAGERDEMOD_POCSAG_IDLECODE)
{
- // Idle
}
else if (addressCodeWord)
{
- // Address
function_bits = (code_words[i] >> 11) & 0x3;
int addressBits = (code_words[i] >> 13) & 0x3ffff;
address = (addressBits << 3) | frame;
@@ -213,44 +197,30 @@ void decodeBatch()
}
else
{
- // Message - decode as both numeric and ASCII - not all operators use functionBits to indidcate encoding
int messageBits = (code_words[i] >> 11) & 0xfffff;
- if (parityError)
- {
- parity_errors++;
- }
- if (code_words_bch_error[i])
- {
- bch_errors++;
- }
+ if (parityError) parity_errors++;
+ if (code_words_bch_error[i]) bch_errors++;
- // Numeric format
for (int j = 16; j >= 0; j -= 4)
{
uint32_t numericBits = (messageBits >> j) & 0xf;
numericBits = reverse(numericBits) >> (32 - 4);
- // Spec has 0xa as 'spare', but other decoders treat is as .
const char numericChars[] = {
'0', '1', '2', '3', '4', '5', '6', '7', '8', '9', '.', 'U', ' ', '-', ')', '('};
char numericChar = numericChars[numericBits];
numeric_msg.push_back(numericChar);
}
- // 7-bit ASCII alpnanumeric format
alpha_bit_buffer = (alpha_bit_buffer << 20) | messageBits;
alpha_bit_buffer_bits += 20;
while (alpha_bit_buffer_bits >= 7)
{
- // Extract next 7-bit character from bit buffer
char c = (alpha_bit_buffer >> (alpha_bit_buffer_bits - 7)) & 0x7f;
- // Reverse bit ordering
c = reverse(c) >> (32 - 7);
- // Add to received message string (excluding, null, end of text, end ot transmission)
if (c != 0 && c != 0x3 && c != 0x4)
{
alpha_msg.push_back(c);
}
- // Remove from bit buffer
alpha_bit_buffer_bits -= 7;
if (alpha_bit_buffer_bits == 0)
{
@@ -262,25 +232,16 @@ void decodeBatch()
}
}
}
-
- // Move to next codeword
i++;
}
}
}
-void processOneSample(int8_t i, int8_t q)
+void processBasebandSample(double sample)
{
- float fi = ((float)i) / 128.0f;
- float fq = ((float)q) / 128.0f;
+ ensureDSPInitialized();
- std::complex iq(fi, fq);
-
- float deviation;
- double fmDemod = phaseDiscri.phaseDiscriminatorDelta(iq, magsqRaw, deviation);
- // printf("fmDemod: %.3f\n", fmDemod);
-
- double filt = lowpassBaud.filter(fmDemod);
+ double filt = lowpassBaud.filter(sample);
if (!got_SC)
{
@@ -288,54 +249,49 @@ void processOneSample(int8_t i, int8_t q)
dc_offset = preambleMovingAverage.asDouble();
}
- bool data = (filt - dc_offset) >= 0.0;
- // printf("filt - dc: %.3f\n", filt - dc_offset);
+ double sample_val = filt - dc_offset;
+ double threshold = 0.05;
+
+ if (sample_val > threshold)
+ {
+ hysteresis_state = true;
+ }
+ else if (sample_val < -threshold)
+ {
+ hysteresis_state = false;
+ }
+
+ bool data = hysteresis_state;
if (data != prev_data)
{
- sync_cnt = SAMPLES_PER_SYMBOL / 2; // reset
+ sync_cnt = SAMPLES_PER_SYMBOL / 2;
}
else
{
- sync_cnt--; // wait until next bit's midpoint
+ sync_cnt--;
if (sync_cnt <= 0)
{
- if (bit_inverted)
- {
- data_bit = data;
- }
- else
- {
- data_bit = !data;
- }
-
- // printf("%d", data_bit);
+ if (bit_inverted) data_bit = data;
+ else data_bit = !data;
bits = (bits << 1) | data_bit;
bit_cnt++;
- if (bit_cnt > 32)
- {
- bit_cnt = 32;
- }
+ if (bit_cnt > 32) bit_cnt = 32;
if (bit_cnt == 32 && !got_SC)
{
- // printf("pop count: %d\n", pop_cnt(bits ^ POCSAG_SYNCCODE));
- // printf("pop count inv: %d\n", pop_cnt(bits ^ POCSAG_SYNCCODE_INV));
-
if (bits == POCSAG_SYNCCODE)
{
got_SC = true;
bit_inverted = false;
- printf("\nSync code found\n");
}
else if (bits == POCSAG_SYNCCODE_INV)
{
got_SC = true;
bit_inverted = true;
- printf("\nSync code found\n");
}
else if (pop_cnt(bits ^ POCSAG_SYNCCODE) <= 3)
{
@@ -344,9 +300,7 @@ void processOneSample(int8_t i, int8_t q)
{
got_SC = true;
bit_inverted = false;
- printf("\nSync code found\n");
}
- // else printf("\nSync code not found\n");
}
else if (pop_cnt(bits ^ POCSAG_SYNCCODE_INV) <= 3)
{
@@ -355,9 +309,7 @@ void processOneSample(int8_t i, int8_t q)
{
got_SC = true;
bit_inverted = true;
- printf("\nSync code found\n");
}
- // else printf("\nSync code not found\n");
}
if (got_SC)
@@ -394,7 +346,6 @@ void processOneSample(int8_t i, int8_t q)
if (address > 0 && !numeric_msg.empty())
{
is_message_ready = true;
- printf("Addr: %d | Numeric: %s | Alpha: %s\n", address, numeric_msg.c_str(), alpha_msg.c_str());
}
else
{
@@ -408,3 +359,16 @@ void processOneSample(int8_t i, int8_t q)
prev_data = data;
}
+
+void processOneSample(int8_t i, int8_t q)
+{
+ float fi = ((float)i) / 128.0f;
+ float fq = ((float)q) / 128.0f;
+
+ std::complex iq(fi, fq);
+
+ float deviation;
+ double fmDemod = phaseDiscri.phaseDiscriminatorDelta(iq, magsqRaw, deviation);
+
+ processBasebandSample(fmDemod);
+}
\ No newline at end of file
diff --git a/android/app/src/main/cpp/demod.h b/android/app/src/main/cpp/demod.h
index 57a720d..34d81f1 100644
--- a/android/app/src/main/cpp/demod.h
+++ b/android/app/src/main/cpp/demod.h
@@ -34,6 +34,8 @@ extern Lowpass lowpassBaud;
extern MovingAverageUtil preambleMovingAverage;
extern double magsqRaw;
+void ensureDSPInitialized();
void processOneSample(int8_t i, int8_t q);
+void processBasebandSample(double sample);
#endif
\ No newline at end of file
diff --git a/android/app/src/main/cpp/native-lib.cpp b/android/app/src/main/cpp/native-lib.cpp
index ab5e931..ea0df16 100644
--- a/android/app/src/main/cpp/native-lib.cpp
+++ b/android/app/src/main/cpp/native-lib.cpp
@@ -8,6 +8,8 @@
#include
#include
#include
+#include
+#include
#include
#include
#include
@@ -84,6 +86,40 @@ Java_org_noxylva_lbjconsole_flutter_RtlTcpChannelHandler_startClientAsync(
env->ReleaseStringUTFChars(port_, portStr);
}
+extern "C" JNIEXPORT void JNICALL
+Java_org_noxylva_lbjconsole_flutter_AudioInputHandler_nativePushAudio(
+ JNIEnv *env, jobject thiz, jshortArray audioData, jint size) {
+
+ ensureDSPInitialized();
+
+ jshort *samples = env->GetShortArrayElements(audioData, NULL);
+
+ std::lock_guard demodLock(demodDataMutex);
+
+ for (int i = 0; i < size; i++) {
+ double sample = (double)samples[i] / 32768.0;
+
+ sample *= 5.0;
+
+ processBasebandSample(sample);
+ }
+
+ env->ReleaseShortArrayElements(audioData, samples, 0);
+
+ if (is_message_ready) {
+ std::ostringstream ss;
+ std::lock_guard msgLock(msgMutex);
+
+ std::string message_content = alpha_msg.empty() ? numeric_msg : alpha_msg;
+ ss << "[MSG]" << address << "|" << function_bits << "|" << message_content;
+ messageBuffer.push_back(ss.str());
+
+ is_message_ready = false;
+ numeric_msg.clear();
+ alpha_msg.clear();
+ }
+}
+
extern "C" JNIEXPORT jdouble JNICALL
Java_org_noxylva_lbjconsole_flutter_RtlTcpChannelHandler_getSignalStrength(JNIEnv *, jobject)
{
@@ -171,8 +207,8 @@ void clientThread(std::string host, int port)
goto cleanup;
}
- lowpassBaud.create(301, SAMPLE_RATE, BAUD_RATE * 5.0f);
- phaseDiscri.setFMScaling(SAMPLE_RATE / (2.0f * DEVIATION));
+ ensureDSPInitialized();
+
sockfd_atomic.store(localSockfd);
{
std::lock_guard lock(msgMutex);
diff --git a/android/app/src/main/kotlin/org/noxylva/lbjconsole/flutter/AudioInputHandler.kt b/android/app/src/main/kotlin/org/noxylva/lbjconsole/flutter/AudioInputHandler.kt
new file mode 100644
index 0000000..df633f4
--- /dev/null
+++ b/android/app/src/main/kotlin/org/noxylva/lbjconsole/flutter/AudioInputHandler.kt
@@ -0,0 +1,126 @@
+package org.noxylva.lbjconsole.flutter
+
+import android.Manifest
+import android.content.Context
+import android.content.pm.PackageManager
+import android.media.AudioFormat
+import android.media.AudioRecord
+import android.media.MediaRecorder
+import android.util.Log
+import androidx.core.content.ContextCompat
+import io.flutter.embedding.engine.FlutterEngine
+import io.flutter.plugin.common.MethodCall
+import io.flutter.plugin.common.MethodChannel
+import java.util.concurrent.atomic.AtomicBoolean
+
+class AudioInputHandler(private val context: Context) : MethodChannel.MethodCallHandler {
+ private var audioRecord: AudioRecord? = null
+ private val isRecording = AtomicBoolean(false)
+ private var recordingThread: Thread? = null
+
+ private val sampleRate = 48000
+ private val bufferSize = AudioRecord.getMinBufferSize(
+ sampleRate,
+ AudioFormat.CHANNEL_IN_MONO,
+ AudioFormat.ENCODING_PCM_16BIT
+ ) * 2
+
+ companion object {
+ private const val CHANNEL = "org.noxylva.lbjconsole/audio_input"
+ private const val TAG = "AudioInputHandler"
+
+ fun registerWith(flutterEngine: FlutterEngine, context: Context) {
+ val channel = MethodChannel(flutterEngine.dartExecutor.binaryMessenger, CHANNEL)
+ channel.setMethodCallHandler(AudioInputHandler(context))
+ }
+ }
+
+ private external fun nativePushAudio(data: ShortArray, size: Int)
+
+ override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
+ when (call.method) {
+ "start" -> {
+ if (startRecording()) {
+ result.success(null)
+ } else {
+ result.error("AUDIO_ERROR", "Failed to start audio recording", null)
+ }
+ }
+ "stop" -> {
+ stopRecording()
+ result.success(null)
+ }
+ else -> result.notImplemented()
+ }
+ }
+
+ private fun startRecording(): Boolean {
+ if (isRecording.get()) return true
+
+ if (ContextCompat.checkSelfPermission(
+ context,
+ Manifest.permission.RECORD_AUDIO
+ ) != PackageManager.PERMISSION_GRANTED
+ ) {
+ Log.e(TAG, "Permission not granted")
+ return false
+ }
+
+ try {
+ val audioSource = MediaRecorder.AudioSource.UNPROCESSED
+
+ audioRecord = AudioRecord(
+ audioSource,
+ sampleRate,
+ AudioFormat.CHANNEL_IN_MONO,
+ AudioFormat.ENCODING_PCM_16BIT,
+ bufferSize
+ )
+
+ if (audioRecord?.state != AudioRecord.STATE_INITIALIZED) {
+ Log.e(TAG, "AudioRecord init failed")
+ return false
+ }
+
+ audioRecord?.startRecording()
+ isRecording.set(true)
+
+ recordingThread = Thread {
+ val buffer = ShortArray(bufferSize)
+ while (isRecording.get()) {
+ val readSize = audioRecord?.read(buffer, 0, buffer.size) ?: 0
+ if (readSize > 0) {
+ nativePushAudio(buffer, readSize)
+ }
+ }
+ }
+ recordingThread?.priority = Thread.MAX_PRIORITY
+ recordingThread?.start()
+ return true
+ } catch (e: Exception) {
+ Log.e(TAG, "Start recording exception", e)
+ stopRecording()
+ return false
+ }
+ }
+
+ private fun stopRecording() {
+ isRecording.set(false)
+ try {
+ recordingThread?.join(1000)
+ } catch (e: InterruptedException) {
+ e.printStackTrace()
+ }
+
+ try {
+ if (audioRecord?.recordingState == AudioRecord.RECORDSTATE_RECORDING) {
+ audioRecord?.stop()
+ }
+ audioRecord?.release()
+ } catch (e: Exception) {
+ Log.e(TAG, "Stop recording exception", e)
+ }
+ audioRecord = null
+ recordingThread = null
+ }
+}
\ No newline at end of file
diff --git a/android/app/src/main/kotlin/org/noxylva/lbjconsole/flutter/MainActivity.kt b/android/app/src/main/kotlin/org/noxylva/lbjconsole/flutter/MainActivity.kt
index 2ae3f81..1b3e721 100644
--- a/android/app/src/main/kotlin/org/noxylva/lbjconsole/flutter/MainActivity.kt
+++ b/android/app/src/main/kotlin/org/noxylva/lbjconsole/flutter/MainActivity.kt
@@ -7,5 +7,6 @@ class MainActivity: FlutterActivity() {
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
super.configureFlutterEngine(flutterEngine)
RtlTcpChannelHandler.registerWith(flutterEngine)
+ AudioInputHandler.registerWith(flutterEngine, applicationContext)
}
-}
+}
\ No newline at end of file
diff --git a/android/build.gradle b/android/build.gradle
index 47e8b3a..02ca5d6 100644
--- a/android/build.gradle
+++ b/android/build.gradle
@@ -7,13 +7,27 @@ allprojects {
}
rootProject.buildDir = "../build"
+
subprojects {
project.buildDir = "${rootProject.buildDir}/${project.name}"
}
+
subprojects {
project.evaluationDependsOn(":app")
}
+subprojects {
+ if (project.name != "app") {
+ project.afterEvaluate {
+ if (project.hasProperty("android")) {
+ project.android {
+ compileSdk 36
+ }
+ }
+ }
+ }
+}
+
tasks.register("clean", Delete) {
delete rootProject.buildDir
-}
+}
\ No newline at end of file
diff --git a/lib/screens/main_screen.dart b/lib/screens/main_screen.dart
index 69c73da..de67b0e 100644
--- a/lib/screens/main_screen.dart
+++ b/lib/screens/main_screen.dart
@@ -13,15 +13,15 @@ import 'package:lbjconsole/services/database_service.dart';
import 'package:lbjconsole/services/notification_service.dart';
import 'package:lbjconsole/services/background_service.dart';
import 'package:lbjconsole/services/rtl_tcp_service.dart';
+import 'package:lbjconsole/services/audio_input_service.dart';
import 'package:lbjconsole/themes/app_theme.dart';
-import 'dart:convert';
class _ConnectionStatusWidget extends StatefulWidget {
final BLEService bleService;
final RtlTcpService rtlTcpService;
final DateTime? lastReceivedTime;
final DateTime? rtlTcpLastReceivedTime;
- final bool rtlTcpEnabled;
+ final InputSource inputSource;
final bool rtlTcpConnected;
const _ConnectionStatusWidget({
@@ -29,7 +29,7 @@ class _ConnectionStatusWidget extends StatefulWidget {
required this.rtlTcpService,
required this.lastReceivedTime,
required this.rtlTcpLastReceivedTime,
- required this.rtlTcpEnabled,
+ required this.inputSource,
required this.rtlTcpConnected,
});
@@ -59,6 +59,15 @@ class _ConnectionStatusWidgetState extends State<_ConnectionStatusWidget> {
_deviceStatus = widget.bleService.deviceStatus;
}
+ @override
+ void didUpdateWidget(covariant _ConnectionStatusWidget oldWidget) {
+ super.didUpdateWidget(oldWidget);
+ if (oldWidget.inputSource != widget.inputSource ||
+ oldWidget.rtlTcpConnected != widget.rtlTcpConnected) {
+ setState(() {});
+ }
+ }
+
@override
void dispose() {
_connectionSubscription?.cancel();
@@ -67,18 +76,31 @@ class _ConnectionStatusWidgetState extends State<_ConnectionStatusWidget> {
@override
Widget build(BuildContext context) {
- final isRtlTcpMode = widget.rtlTcpEnabled;
- final rtlTcpConnected = widget.rtlTcpConnected;
-
- final isConnected = isRtlTcpMode ? rtlTcpConnected : _isConnected;
- final statusColor = isRtlTcpMode
- ? (rtlTcpConnected ? Colors.green : Colors.red)
- : (_isConnected ? Colors.green : Colors.red);
- final statusText = isRtlTcpMode
- ? (rtlTcpConnected ? '已连接' : '未连接')
- : _deviceStatus;
-
- final lastReceivedTime = isRtlTcpMode ? widget.rtlTcpLastReceivedTime : widget.lastReceivedTime;
+ bool isConnected;
+ Color statusColor;
+ String statusText;
+ DateTime? displayTime;
+
+ switch (widget.inputSource) {
+ case InputSource.rtlTcp:
+ isConnected = widget.rtlTcpConnected;
+ statusColor = isConnected ? Colors.green : Colors.red;
+ statusText = isConnected ? '已连接' : '未连接';
+ displayTime = widget.rtlTcpLastReceivedTime;
+ break;
+ case InputSource.audioInput:
+ isConnected = AudioInputService().isListening;
+ statusColor = isConnected ? Colors.green : Colors.red;
+ statusText = isConnected ? '监听中' : '已停止';
+ displayTime = widget.rtlTcpLastReceivedTime ?? widget.lastReceivedTime;
+ break;
+ case InputSource.bluetooth:
+ isConnected = _isConnected;
+ statusColor = isConnected ? Colors.green : Colors.red;
+ statusText = _deviceStatus;
+ displayTime = widget.lastReceivedTime;
+ break;
+ }
return Row(
children: [
@@ -86,12 +108,12 @@ class _ConnectionStatusWidgetState extends State<_ConnectionStatusWidget> {
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
- if (lastReceivedTime == null || !isConnected) ...[
+ if (displayTime == null || !isConnected) ...[
Text(statusText,
style: const TextStyle(color: Colors.white70, fontSize: 12)),
],
_LastReceivedTimeWidget(
- lastReceivedTime: lastReceivedTime,
+ lastReceivedTime: displayTime,
isConnected: isConnected,
),
],
@@ -215,7 +237,9 @@ class _MainScreenState extends State with WidgetsBindingObserver {
DateTime? _lastReceivedTime;
DateTime? _rtlTcpLastReceivedTime;
bool _isHistoryEditMode = false;
- bool _rtlTcpEnabled = false;
+
+ InputSource _inputSource = InputSource.bluetooth;
+
bool _rtlTcpConnected = false;
bool _isConnected = false;
final GlobalKey _historyScreenKey =
@@ -230,7 +254,7 @@ class _MainScreenState extends State with WidgetsBindingObserver {
_bleService = BLEService();
_rtlTcpService = RtlTcpService();
_bleService.initialize();
- _loadRtlTcpSettings();
+ _loadInputSettings();
_initializeServices();
_checkAndStartBackgroundService();
_setupConnectionListener();
@@ -248,23 +272,26 @@ class _MainScreenState extends State with WidgetsBindingObserver {
}
}
- void _loadRtlTcpSettings() async {
- developer.log('rtl_tcp: load_settings');
+ void _loadInputSettings() async {
final settings = await _databaseService.getAllSettings();
- developer.log('rtl_tcp: settings_loaded: enabled=${(settings?['rtlTcpEnabled'] ?? 0) == 1}, host=${settings?['rtlTcpHost']?.toString() ?? '127.0.0.1'}, port=${settings?['rtlTcpPort']?.toString() ?? '14423'}');
+ final sourceStr = settings?['inputSource'] as String? ?? 'bluetooth';
+
if (mounted) {
setState(() {
- _rtlTcpEnabled = (settings?['rtlTcpEnabled'] ?? 0) == 1;
+ _inputSource = InputSource.values.firstWhere(
+ (e) => e.name == sourceStr,
+ orElse: () => InputSource.bluetooth,
+ );
_rtlTcpConnected = _rtlTcpService.isConnected;
});
- if (_rtlTcpEnabled && !_rtlTcpConnected) {
+ if (_inputSource == InputSource.rtlTcp && !_rtlTcpConnected) {
final host = settings?['rtlTcpHost']?.toString() ?? '127.0.0.1';
final port = settings?['rtlTcpPort']?.toString() ?? '14423';
- developer.log('rtl_tcp: auto_connect');
_connectToRtlTcp(host, port);
- } else {
- developer.log('rtl_tcp: skip_connect: enabled=$_rtlTcpEnabled, connected=$_rtlTcpConnected');
+ } else if (_inputSource == InputSource.audioInput) {
+ await AudioInputService().startListening();
+ setState(() {});
}
}
}
@@ -292,41 +319,47 @@ class _MainScreenState extends State with WidgetsBindingObserver {
_rtlTcpLastReceivedTimeSubscription =
_rtlTcpService.lastReceivedTimeStream.listen((time) {
if (mounted) {
- if (_rtlTcpEnabled) {
- setState(() {
- _rtlTcpLastReceivedTime = time;
- });
- }
+ setState(() {
+ _rtlTcpLastReceivedTime = time;
+ });
}
});
}
void _setupSettingsListener() {
- developer.log('rtl_tcp: setup_listener');
_settingsSubscription =
DatabaseService.instance.onSettingsChanged((settings) {
- developer.log('rtl_tcp: settings_changed: enabled=${(settings['rtlTcpEnabled'] ?? 0) == 1}, host=${settings['rtlTcpHost']?.toString() ?? '127.0.0.1'}, port=${settings['rtlTcpPort']?.toString() ?? '14423'}');
if (mounted) {
- final rtlTcpEnabled = (settings['rtlTcpEnabled'] ?? 0) == 1;
- if (rtlTcpEnabled != _rtlTcpEnabled) {
- setState(() {
- _rtlTcpEnabled = rtlTcpEnabled;
- });
-
- if (rtlTcpEnabled) {
- final host = settings['rtlTcpHost']?.toString() ?? '127.0.0.1';
- final port = settings['rtlTcpPort']?.toString() ?? '14423';
- _connectToRtlTcp(host, port);
- } else {
- _rtlTcpConnectionSubscription?.cancel();
- _rtlTcpDataSubscription?.cancel();
- _rtlTcpLastReceivedTimeSubscription?.cancel();
- _rtlTcpService.disconnect();
- setState(() {
- _rtlTcpConnected = false;
- _rtlTcpLastReceivedTime = null;
- });
- }
+ final sourceStr = settings['inputSource'] as String? ?? 'bluetooth';
+ print('[MainScreen] Settings changed: inputSource=$sourceStr');
+ final newInputSource = InputSource.values.firstWhere(
+ (e) => e.name == sourceStr,
+ orElse: () => InputSource.bluetooth,
+ );
+
+ print('[MainScreen] Current: $_inputSource, New: $newInputSource');
+
+ setState(() {
+ _inputSource = newInputSource;
+ });
+
+ switch (newInputSource) {
+ case InputSource.rtlTcp:
+ setState(() {
+ _rtlTcpConnected = _rtlTcpService.isConnected;
+ });
+ print('[MainScreen] RTL-TCP mode, connected: $_rtlTcpConnected');
+ break;
+ case InputSource.audioInput:
+ setState(() {});
+ break;
+ case InputSource.bluetooth:
+ _rtlTcpService.disconnect();
+ setState(() {
+ _rtlTcpConnected = false;
+ _rtlTcpLastReceivedTime = null;
+ });
+ break;
}
if (_currentIndex == 1) {
@@ -347,20 +380,16 @@ class _MainScreenState extends State with WidgetsBindingObserver {
_rtlTcpConnectionSubscription = _rtlTcpService.connectionStream.listen((connected) {
if (mounted) {
- if (_rtlTcpEnabled) {
- setState(() {
- _rtlTcpConnected = connected;
- });
- }
+ setState(() {
+ _rtlTcpConnected = connected;
+ });
}
});
}
Future _connectToRtlTcp(String host, String port) async {
- developer.log('rtl_tcp: connect: $host:$port');
try {
await _rtlTcpService.connect(host: host, port: port);
- developer.log('rtl_tcp: connect_req_sent');
} catch (e) {
developer.log('rtl_tcp: connect_fail: $e');
}
@@ -391,38 +420,37 @@ class _MainScreenState extends State with WidgetsBindingObserver {
await _notificationService.initialize();
_dataSubscription = _bleService.dataStream.listen((record) {
- _notificationService.showTrainNotification(record);
- if (_historyScreenKey.currentState != null) {
- _historyScreenKey.currentState!.addNewRecord(record);
- }
- if (_realtimeScreenKey.currentState != null) {
- _realtimeScreenKey.currentState!.addNewRecord(record);
+ if (_inputSource == InputSource.bluetooth) {
+ _processRecord(record);
}
});
_rtlTcpDataSubscription = _rtlTcpService.dataStream.listen((record) {
- developer.log('rtl_tcp: recv_data: train=${record.train}');
- developer.log('rtl_tcp: recv_json: ${jsonEncode(record.toJson())}');
- _notificationService.showTrainNotification(record);
- if (_historyScreenKey.currentState != null) {
- _historyScreenKey.currentState!.addNewRecord(record);
- }
- if (_realtimeScreenKey.currentState != null) {
- _realtimeScreenKey.currentState!.addNewRecord(record);
+ if (_inputSource != InputSource.bluetooth) {
+ _processRecord(record);
}
});
}
+ void _processRecord(record) {
+ _notificationService.showTrainNotification(record);
+ _historyScreenKey.currentState?.addNewRecord(record);
+ _realtimeScreenKey.currentState?.addNewRecord(record);
+ }
+
void _showConnectionDialog() {
_bleService.setAutoConnectBlocked(true);
showDialog(
context: context,
barrierDismissible: true,
builder: (context) =>
- _PixelPerfectBluetoothDialog(bleService: _bleService, rtlTcpEnabled: _rtlTcpEnabled),
+ _PixelPerfectBluetoothDialog(
+ bleService: _bleService,
+ inputSource: _inputSource
+ ),
).then((_) {
_bleService.setAutoConnectBlocked(false);
- if (!_bleService.isManualDisconnect) {
+ if (_inputSource == InputSource.bluetooth && !_bleService.isManualDisconnect) {
_bleService.ensureConnection();
}
});
@@ -452,6 +480,12 @@ class _MainScreenState extends State with WidgetsBindingObserver {
);
}
+ final IconData statusIcon = switch (_inputSource) {
+ InputSource.rtlTcp => Icons.wifi,
+ InputSource.audioInput => Icons.mic,
+ InputSource.bluetooth => Icons.bluetooth,
+ };
+
return AppBar(
backgroundColor: AppTheme.primaryBlack,
elevation: 0,
@@ -469,12 +503,12 @@ class _MainScreenState extends State with WidgetsBindingObserver {
rtlTcpService: _rtlTcpService,
lastReceivedTime: _lastReceivedTime,
rtlTcpLastReceivedTime: _rtlTcpLastReceivedTime,
- rtlTcpEnabled: _rtlTcpEnabled,
+ inputSource: _inputSource,
rtlTcpConnected: _rtlTcpConnected,
),
IconButton(
icon: Icon(
- _rtlTcpEnabled ? Icons.wifi : Icons.bluetooth,
+ statusIcon,
color: Colors.white,
),
onPressed: _showConnectionDialog,
@@ -562,7 +596,6 @@ class _MainScreenState extends State with WidgetsBindingObserver {
SettingsScreen(
onSettingsChanged: () {
_loadMapType();
- _loadRtlTcpSettings();
},
),
];
@@ -609,8 +642,8 @@ enum _ScanState { initial, scanning, finished }
class _PixelPerfectBluetoothDialog extends StatefulWidget {
final BLEService bleService;
- final bool rtlTcpEnabled;
- const _PixelPerfectBluetoothDialog({required this.bleService, required this.rtlTcpEnabled});
+ final InputSource inputSource;
+ const _PixelPerfectBluetoothDialog({required this.bleService, required this.inputSource});
@override
State<_PixelPerfectBluetoothDialog> createState() =>
_PixelPerfectBluetoothDialogState();
@@ -625,6 +658,7 @@ class _PixelPerfectBluetoothDialogState
DateTime? _lastReceivedTime;
StreamSubscription? _rtlTcpConnectionSubscription;
bool _rtlTcpConnected = false;
+
@override
void initState() {
super.initState();
@@ -640,11 +674,11 @@ class _PixelPerfectBluetoothDialogState
}
});
- if (widget.rtlTcpEnabled && widget.bleService.rtlTcpService != null) {
+ if (widget.inputSource == InputSource.rtlTcp && widget.bleService.rtlTcpService != null) {
_rtlTcpConnected = widget.bleService.rtlTcpService!.isConnected;
}
- if (!widget.bleService.isConnected && !widget.rtlTcpEnabled) {
+ if (!widget.bleService.isConnected && widget.inputSource == InputSource.bluetooth) {
_startScan();
}
}
@@ -684,31 +718,24 @@ class _PixelPerfectBluetoothDialogState
await widget.bleService.disconnect();
}
- void _setupLastReceivedTimeListener() {
- _lastReceivedTimeSubscription =
- widget.bleService.lastReceivedTimeStream.listen((timestamp) {
- if (mounted) {
- setState(() {
- _lastReceivedTime = timestamp;
- });
- }
- });
- }
-
@override
Widget build(BuildContext context) {
- final isConnected = widget.bleService.isConnected;
+ final (String title, Widget content) = switch (widget.inputSource) {
+ InputSource.rtlTcp => ('RTL-TCP 服务器', _buildRtlTcpView(context)),
+ InputSource.audioInput => ('音频输入', _buildAudioInputView(context)),
+ InputSource.bluetooth => (
+ '蓝牙设备',
+ widget.bleService.isConnected
+ ? _buildConnectedView(context, widget.bleService.connectedDevice)
+ : _buildDisconnectedView(context)
+ ),
+ };
+
return AlertDialog(
- title: Text(widget.rtlTcpEnabled ? 'RTL-TCP 服务器' : '蓝牙设备'),
+ title: Text(title),
content: SizedBox(
width: double.maxFinite,
- child: SingleChildScrollView(
- child: widget.rtlTcpEnabled
- ? _buildRtlTcpView(context)
- : (isConnected
- ? _buildConnectedView(context, widget.bleService.connectedDevice)
- : _buildDisconnectedView(context)),
- ),
+ child: SingleChildScrollView(child: content),
),
actions: [
TextButton(
@@ -733,13 +760,6 @@ class _PixelPerfectBluetoothDialogState
Text(device?.remoteId.str ?? '',
style: Theme.of(context).textTheme.bodySmall,
textAlign: TextAlign.center),
- if (_lastReceivedTime != null) ...[
- const SizedBox(height: 8),
- _LastReceivedTimeWidget(
- lastReceivedTime: _lastReceivedTime,
- isConnected: widget.bleService.isConnected,
- ),
- ],
const SizedBox(height: 16),
ElevatedButton.icon(
onPressed: _disconnect,
@@ -785,13 +805,21 @@ class _PixelPerfectBluetoothDialogState
const SizedBox(height: 8),
Text(currentAddress,
style: TextStyle(color: isConnected ? Colors.green : Colors.grey)),
+ ]);
+ }
+
+ Widget _buildAudioInputView(BuildContext context) {
+ return Column(mainAxisSize: MainAxisSize.min, children: [
+ const Icon(Icons.mic, size: 48, color: Colors.blue),
const SizedBox(height: 16),
- if (_lastReceivedTime != null && isConnected) ...[
- _LastReceivedTimeWidget(
- lastReceivedTime: _lastReceivedTime,
- isConnected: isConnected,
- ),
- ],
+ Text('监听中',
+ style: Theme.of(context)
+ .textTheme
+ .titleMedium
+ ?.copyWith(fontWeight: FontWeight.bold)),
+ const SizedBox(height: 8),
+ const Text("请使用音频线连接设备",
+ style: TextStyle(color: Colors.grey)),
]);
}
@@ -818,4 +846,4 @@ class _PixelPerfectBluetoothDialogState
),
);
}
-}
+}
\ No newline at end of file
diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart
index ecd8f7e..4f39e85 100644
--- a/lib/screens/settings_screen.dart
+++ b/lib/screens/settings_screen.dart
@@ -5,6 +5,8 @@ import 'dart:io';
import 'package:lbjconsole/models/merged_record.dart';
import 'package:lbjconsole/services/database_service.dart';
import 'package:lbjconsole/services/background_service.dart';
+import 'package:lbjconsole/services/audio_input_service.dart';
+import 'package:lbjconsole/services/rtl_tcp_service.dart';
import 'package:lbjconsole/themes/app_theme.dart';
import 'package:url_launcher/url_launcher.dart';
@@ -37,7 +39,9 @@ class _SettingsScreenState extends State {
GroupBy _groupBy = GroupBy.trainAndLoco;
TimeWindow _timeWindow = TimeWindow.unlimited;
String _mapType = 'map';
- bool _rtlTcpEnabled = false;
+
+ InputSource _inputSource = InputSource.bluetooth;
+
String _rtlTcpHost = '127.0.0.1';
String _rtlTcpPort = '14423';
@@ -52,131 +56,6 @@ class _SettingsScreenState extends State {
_loadRecordCount();
}
- Widget _buildRtlTcpSettings() {
- return Card(
- color: AppTheme.tertiaryBlack,
- elevation: 0,
- shape: RoundedRectangleBorder(
- borderRadius: BorderRadius.circular(16.0),
- ),
- child: Padding(
- padding: const EdgeInsets.all(20.0),
- child: Column(
- crossAxisAlignment: CrossAxisAlignment.start,
- children: [
- Row(
- children: [
- Icon(Icons.wifi,
- color: Theme.of(context).colorScheme.primary),
- const SizedBox(width: 12),
- const Text('RTL-TCP 源', style: AppTheme.titleMedium),
- ],
- ),
- const SizedBox(height: 16),
- Row(
- mainAxisAlignment: MainAxisAlignment.spaceBetween,
- children: [
- const Column(
- crossAxisAlignment: CrossAxisAlignment.start,
- children: [
- Text('启用 RTL-TCP 源', style: AppTheme.bodyLarge),
- ],
- ),
- Switch(
- value: _rtlTcpEnabled,
- onChanged: (value) {
- setState(() {
- _rtlTcpEnabled = value;
- });
- _saveSettings();
- },
- activeThumbColor: Theme.of(context).colorScheme.primary,
- ),
- ],
- ),
- Visibility(
- visible: _rtlTcpEnabled,
- child: Column(
- crossAxisAlignment: CrossAxisAlignment.start,
- children: [
- const SizedBox(height: 16),
- TextField(
- decoration: InputDecoration(
- labelText: '服务器地址',
- hintText: '输入RTL-TCP服务器地址',
- labelStyle: const TextStyle(color: Colors.white70),
- hintStyle: const TextStyle(color: Colors.white54),
- border: OutlineInputBorder(
- borderSide: const BorderSide(color: Colors.white54),
- borderRadius: BorderRadius.circular(12.0),
- ),
- enabledBorder: OutlineInputBorder(
- borderSide: const BorderSide(color: Colors.white54),
- borderRadius: BorderRadius.circular(12.0),
- ),
- focusedBorder: OutlineInputBorder(
- borderSide:
- BorderSide(color: Theme.of(context).colorScheme.primary),
- borderRadius: BorderRadius.circular(12.0),
- ),
- ),
- style: const TextStyle(color: Colors.white),
- controller: _rtlTcpHostController,
- onChanged: (value) {
- setState(() {
- _rtlTcpHost = value;
- });
- _saveSettings();
- },
- ),
- const SizedBox(height: 16),
- TextField(
- decoration: InputDecoration(
- labelText: '服务器端口',
- hintText: '输入RTL-TCP服务器端口',
- labelStyle: const TextStyle(color: Colors.white70),
- hintStyle: const TextStyle(color: Colors.white54),
- border: OutlineInputBorder(
- borderSide: const BorderSide(color: Colors.white54),
- borderRadius: BorderRadius.circular(12.0),
- ),
- enabledBorder: OutlineInputBorder(
- borderSide: const BorderSide(color: Colors.white54),
- borderRadius: BorderRadius.circular(12.0),
- ),
- focusedBorder: OutlineInputBorder(
- borderSide:
- BorderSide(color: Theme.of(context).colorScheme.primary),
- borderRadius: BorderRadius.circular(12.0),
- ),
- ),
- style: const TextStyle(color: Colors.white),
- controller: _rtlTcpPortController,
- keyboardType: TextInputType.number,
- onChanged: (value) {
- setState(() {
- _rtlTcpPort = value;
- });
- _saveSettings();
- },
- ),
- ],
- ),
- ),
- ],
- ),
- ),
- );
- }
-
- @override
- void dispose() {
- _deviceNameController.dispose();
- _rtlTcpHostController.dispose();
- _rtlTcpPortController.dispose();
- super.dispose();
- }
-
Future _loadSettings() async {
final settingsMap = await _databaseService.getAllSettings() ?? {};
final settings = MergeSettings.fromMap(settingsMap);
@@ -193,20 +72,17 @@ class _SettingsScreenState extends State {
_groupBy = settings.groupBy;
_timeWindow = settings.timeWindow;
_mapType = settingsMap['mapType']?.toString() ?? 'webview';
- _rtlTcpEnabled = (settingsMap['rtlTcpEnabled'] ?? 0) == 1;
+
_rtlTcpHost = settingsMap['rtlTcpHost']?.toString() ?? '127.0.0.1';
_rtlTcpPort = settingsMap['rtlTcpPort']?.toString() ?? '14423';
_rtlTcpHostController.text = _rtlTcpHost;
_rtlTcpPortController.text = _rtlTcpPort;
- });
- }
- }
- Future _loadRecordCount() async {
- final count = await _databaseService.getRecordCount();
- if (mounted) {
- setState(() {
- _recordCount = count;
+ final sourceStr = settingsMap['inputSource'] as String? ?? 'bluetooth';
+ _inputSource = InputSource.values.firstWhere(
+ (e) => e.name == sourceStr,
+ orElse: () => InputSource.bluetooth,
+ );
});
}
}
@@ -222,37 +98,43 @@ class _SettingsScreenState extends State {
'groupBy': _groupBy.name,
'timeWindow': _timeWindow.name,
'mapType': _mapType,
- 'rtlTcpEnabled': _rtlTcpEnabled ? 1 : 0,
+ 'inputSource': _inputSource.name,
'rtlTcpHost': _rtlTcpHost,
'rtlTcpPort': _rtlTcpPort,
});
widget.onSettingsChanged?.call();
}
- @override
- Widget build(BuildContext context) {
- return SingleChildScrollView(
- padding: const EdgeInsets.all(20.0),
- child: Column(
- crossAxisAlignment: CrossAxisAlignment.start,
- children: [
- _buildBluetoothSettings(),
- const SizedBox(height: 20),
- _buildAppSettings(),
- const SizedBox(height: 20),
- _buildRtlTcpSettings(),
- const SizedBox(height: 20),
- _buildMergeSettings(),
- const SizedBox(height: 20),
- _buildDataManagement(),
- const SizedBox(height: 20),
- _buildAboutSection(),
- ],
- ),
- );
+ Future _switchInputSource(InputSource newSource) async {
+ await AudioInputService().stopListening();
+ await RtlTcpService().disconnect();
+ setState(() {
+ _inputSource = newSource;
+ });
+
+ switch (newSource) {
+ case InputSource.audioInput:
+ await AudioInputService().startListening();
+ break;
+ case InputSource.rtlTcp:
+ RtlTcpService().connect(host: _rtlTcpHost, port: _rtlTcpPort);
+ break;
+ case InputSource.bluetooth:
+ break;
+ }
+
+ _saveSettings();
}
- Widget _buildBluetoothSettings() {
+ @override
+ void dispose() {
+ _deviceNameController.dispose();
+ _rtlTcpHostController.dispose();
+ _rtlTcpPortController.dispose();
+ super.dispose();
+ }
+
+ Widget _buildInputSourceSettings() {
return Card(
color: AppTheme.tertiaryBlack,
elevation: 0,
@@ -266,48 +148,213 @@ class _SettingsScreenState extends State {
children: [
Row(
children: [
- Icon(Icons.bluetooth,
- color: Theme.of(context).colorScheme.primary),
+ Icon(Icons.input, color: Theme.of(context).colorScheme.primary),
const SizedBox(width: 12),
- const Text('蓝牙设备', style: AppTheme.titleMedium),
+ const Text('信号源设置', style: AppTheme.titleMedium),
],
),
const SizedBox(height: 16),
- TextField(
- controller: _deviceNameController,
- decoration: InputDecoration(
- labelText: '设备名称',
- hintText: '输入设备名称',
- labelStyle: const TextStyle(color: Colors.white70),
- hintStyle: const TextStyle(color: Colors.white54),
- border: OutlineInputBorder(
- borderSide: const BorderSide(color: Colors.white54),
- borderRadius: BorderRadius.circular(12.0),
+
+
+ Row(
+ mainAxisAlignment: MainAxisAlignment.spaceBetween,
+ children: [
+ const Text('信号源', style: AppTheme.bodyLarge),
+ DropdownButton(
+ value: _inputSource,
+ items: const [
+ DropdownMenuItem(
+ value: InputSource.bluetooth,
+ child: Text('蓝牙设备', style: AppTheme.bodyMedium),
+ ),
+ DropdownMenuItem(
+ value: InputSource.rtlTcp,
+ child: Text('RTL-TCP', style: AppTheme.bodyMedium),
+ ),
+ DropdownMenuItem(
+ value: InputSource.audioInput,
+ child: Text('音频输入', style: AppTheme.bodyMedium),
+ ),
+ ],
+ onChanged: (value) {
+ if (value != null) {
+ _switchInputSource(value);
+ }
+ },
+ dropdownColor: AppTheme.secondaryBlack,
+ style: AppTheme.bodyMedium,
+ underline: Container(height: 0),
),
- enabledBorder: OutlineInputBorder(
- borderSide: const BorderSide(color: Colors.white54),
- borderRadius: BorderRadius.circular(12.0),
+ ],
+ ),
+
+ if (_inputSource == InputSource.bluetooth) ...[
+ const SizedBox(height: 16),
+ TextField(
+ controller: _deviceNameController,
+ decoration: InputDecoration(
+ labelText: '蓝牙设备名称',
+ hintText: '输入设备名称',
+ labelStyle: const TextStyle(color: Colors.white70),
+ hintStyle: const TextStyle(color: Colors.white54),
+ border: OutlineInputBorder(
+ borderSide: const BorderSide(color: Colors.white54),
+ borderRadius: BorderRadius.circular(12.0),
+ ),
+ enabledBorder: OutlineInputBorder(
+ borderSide: const BorderSide(color: Colors.white54),
+ borderRadius: BorderRadius.circular(12.0),
+ ),
+ focusedBorder: OutlineInputBorder(
+ borderSide: BorderSide(
+ color: Theme.of(context).colorScheme.primary),
+ borderRadius: BorderRadius.circular(12.0),
+ ),
),
- focusedBorder: OutlineInputBorder(
- borderSide:
- BorderSide(color: Theme.of(context).colorScheme.primary),
- borderRadius: BorderRadius.circular(12.0),
+ style: const TextStyle(color: Colors.white),
+ onChanged: (value) {
+ setState(() {
+ _deviceName = value;
+ });
+ _saveSettings();
+ },
+ ),
+ ],
+
+ if (_inputSource == InputSource.rtlTcp) ...[
+ const SizedBox(height: 16),
+ TextField(
+ decoration: InputDecoration(
+ labelText: '服务器地址',
+ hintText: '127.0.0.1',
+ labelStyle: const TextStyle(color: Colors.white70),
+ hintStyle: const TextStyle(color: Colors.white54),
+ border: OutlineInputBorder(
+ borderSide: const BorderSide(color: Colors.white54),
+ borderRadius: BorderRadius.circular(12.0),
+ ),
+ enabledBorder: OutlineInputBorder(
+ borderSide: const BorderSide(color: Colors.white54),
+ borderRadius: BorderRadius.circular(12.0),
+ ),
+ focusedBorder: OutlineInputBorder(
+ borderSide: BorderSide(
+ color: Theme.of(context).colorScheme.primary),
+ borderRadius: BorderRadius.circular(12.0),
+ ),
+ ),
+ style: const TextStyle(color: Colors.white),
+ controller: _rtlTcpHostController,
+ onChanged: (value) {
+ setState(() {
+ _rtlTcpHost = value;
+ });
+ _saveSettings();
+ },
+ ),
+ const SizedBox(height: 16),
+ TextField(
+ decoration: InputDecoration(
+ labelText: '服务器端口',
+ hintText: '14423',
+ labelStyle: const TextStyle(color: Colors.white70),
+ hintStyle: const TextStyle(color: Colors.white54),
+ border: OutlineInputBorder(
+ borderSide: const BorderSide(color: Colors.white54),
+ borderRadius: BorderRadius.circular(12.0),
+ ),
+ enabledBorder: OutlineInputBorder(
+ borderSide: const BorderSide(color: Colors.white54),
+ borderRadius: BorderRadius.circular(12.0),
+ ),
+ focusedBorder: OutlineInputBorder(
+ borderSide: BorderSide(
+ color: Theme.of(context).colorScheme.primary),
+ borderRadius: BorderRadius.circular(12.0),
+ ),
+ ),
+ style: const TextStyle(color: Colors.white),
+ controller: _rtlTcpPortController,
+ keyboardType: TextInputType.number,
+ onChanged: (value) {
+ setState(() {
+ _rtlTcpPort = value;
+ });
+ _saveSettings();
+ },
+ ),
+ const SizedBox(height: 16),
+ SizedBox(
+ width: double.infinity,
+ child: ElevatedButton.icon(
+ onPressed: () {
+ RtlTcpService()
+ .connect(host: _rtlTcpHost, port: _rtlTcpPort);
+ },
+ icon: const Icon(Icons.refresh),
+ label: const Text("重新连接 RTL-TCP"),
+ style: ElevatedButton.styleFrom(
+ backgroundColor: AppTheme.secondaryBlack,
+ foregroundColor: Colors.white,
+ padding: const EdgeInsets.symmetric(vertical: 12),
+ shape: RoundedRectangleBorder(
+ borderRadius: BorderRadius.circular(12),
+ ),
+ ),
+ ),
+ )
+ ],
+
+ if (_inputSource == InputSource.audioInput) ...[
+ const SizedBox(height: 16),
+ Container(
+ padding: const EdgeInsets.all(12),
+ decoration: BoxDecoration(
+ color: Colors.blue.withOpacity(0.1),
+ borderRadius: BorderRadius.circular(8),
+ border: Border.all(color: Colors.blue.withOpacity(0.3)),
+ ),
+ child: const Row(
+ children: [
+ Icon(Icons.mic, color: Colors.blue),
+ SizedBox(width: 12),
+ Expanded(
+ child: Text(
+ '音频解调已启用。请通过音频线 (Line-in) 或麦克风输入信号。',
+ style: TextStyle(color: Colors.white70, fontSize: 13),
+ ),
+ ),
+ ],
),
),
- style: const TextStyle(color: Colors.white),
- onChanged: (value) {
- setState(() {
- _deviceName = value;
- });
- _saveSettings();
- },
- ),
+ ],
],
),
),
);
}
+ @override
+ Widget build(BuildContext context) {
+ return SingleChildScrollView(
+ padding: const EdgeInsets.all(20.0),
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ _buildInputSourceSettings(),
+ const SizedBox(height: 20),
+ _buildAppSettings(),
+ const SizedBox(height: 20),
+ _buildMergeSettings(),
+ const SizedBox(height: 20),
+ _buildDataManagement(),
+ const SizedBox(height: 20),
+ _buildAboutSection(),
+ ],
+ ),
+ );
+ }
+
Widget _buildAppSettings() {
return Card(
color: AppTheme.tertiaryBlack,
@@ -490,86 +537,80 @@ class _SettingsScreenState extends State {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SizedBox(height: 16),
- const Text('分组方式', style: AppTheme.bodyLarge),
- const SizedBox(height: 8),
- DropdownButtonFormField(
- initialValue: _groupBy,
- items: const [
- DropdownMenuItem(
- value: GroupBy.trainOnly,
- child: Text('仅车次号', style: AppTheme.bodyMedium)),
- DropdownMenuItem(
- value: GroupBy.locoOnly,
- child: Text('仅机车号', style: AppTheme.bodyMedium)),
- DropdownMenuItem(
- value: GroupBy.trainOrLoco,
- child: Text('车次号或机车号', style: AppTheme.bodyMedium)),
- DropdownMenuItem(
- value: GroupBy.trainAndLoco,
- child: Text('车次号与机车号', style: AppTheme.bodyMedium)),
- ],
- onChanged: (value) {
- if (value != null) {
- setState(() {
- _groupBy = value;
- });
- _saveSettings();
- }
- },
- decoration: InputDecoration(
- filled: true,
- fillColor: AppTheme.secondaryBlack,
- border: OutlineInputBorder(
- borderRadius: BorderRadius.circular(12.0),
- borderSide: BorderSide.none,
+ Row(
+ mainAxisAlignment: MainAxisAlignment.spaceBetween,
+ children: [
+ const Text('分组方式', style: AppTheme.bodyLarge),
+ DropdownButton(
+ value: _groupBy,
+ items: const [
+ DropdownMenuItem(
+ value: GroupBy.trainOnly,
+ child: Text('仅车次号', style: AppTheme.bodyMedium)),
+ DropdownMenuItem(
+ value: GroupBy.locoOnly,
+ child: Text('仅机车号', style: AppTheme.bodyMedium)),
+ DropdownMenuItem(
+ value: GroupBy.trainOrLoco,
+ child: Text('车次号或机车号', style: AppTheme.bodyMedium)),
+ DropdownMenuItem(
+ value: GroupBy.trainAndLoco,
+ child: Text('车次号与机车号', style: AppTheme.bodyMedium)),
+ ],
+ onChanged: (value) {
+ if (value != null) {
+ setState(() {
+ _groupBy = value;
+ });
+ _saveSettings();
+ }
+ },
+ dropdownColor: AppTheme.secondaryBlack,
+ style: AppTheme.bodyMedium,
+ underline: Container(height: 0),
),
- ),
- dropdownColor: AppTheme.secondaryBlack,
- style: AppTheme.bodyMedium,
+ ],
),
const SizedBox(height: 16),
- const Text('时间窗口', style: AppTheme.bodyLarge),
- const SizedBox(height: 8),
- DropdownButtonFormField(
- initialValue: _timeWindow,
- items: const [
- DropdownMenuItem(
- value: TimeWindow.oneHour,
- child: Text('1小时内', style: AppTheme.bodyMedium)),
- DropdownMenuItem(
- value: TimeWindow.twoHours,
- child: Text('2小时内', style: AppTheme.bodyMedium)),
- DropdownMenuItem(
- value: TimeWindow.sixHours,
- child: Text('6小时内', style: AppTheme.bodyMedium)),
- DropdownMenuItem(
- value: TimeWindow.twelveHours,
- child: Text('12小时内', style: AppTheme.bodyMedium)),
- DropdownMenuItem(
- value: TimeWindow.oneDay,
- child: Text('24小时内', style: AppTheme.bodyMedium)),
- DropdownMenuItem(
- value: TimeWindow.unlimited,
- child: Text('不限时间', style: AppTheme.bodyMedium)),
- ],
- onChanged: (value) {
- if (value != null) {
- setState(() {
- _timeWindow = value;
- });
- _saveSettings();
- }
- },
- decoration: InputDecoration(
- filled: true,
- fillColor: AppTheme.secondaryBlack,
- border: OutlineInputBorder(
- borderRadius: BorderRadius.circular(12.0),
- borderSide: BorderSide.none,
+ Row(
+ mainAxisAlignment: MainAxisAlignment.spaceBetween,
+ children: [
+ const Text('时间窗口', style: AppTheme.bodyLarge),
+ DropdownButton(
+ value: _timeWindow,
+ items: const [
+ DropdownMenuItem(
+ value: TimeWindow.oneHour,
+ child: Text('1小时内', style: AppTheme.bodyMedium)),
+ DropdownMenuItem(
+ value: TimeWindow.twoHours,
+ child: Text('2小时内', style: AppTheme.bodyMedium)),
+ DropdownMenuItem(
+ value: TimeWindow.sixHours,
+ child: Text('6小时内', style: AppTheme.bodyMedium)),
+ DropdownMenuItem(
+ value: TimeWindow.twelveHours,
+ child: Text('12小时内', style: AppTheme.bodyMedium)),
+ DropdownMenuItem(
+ value: TimeWindow.oneDay,
+ child: Text('24小时内', style: AppTheme.bodyMedium)),
+ DropdownMenuItem(
+ value: TimeWindow.unlimited,
+ child: Text('不限时间', style: AppTheme.bodyMedium)),
+ ],
+ onChanged: (value) {
+ if (value != null) {
+ setState(() {
+ _timeWindow = value;
+ });
+ _saveSettings();
+ }
+ },
+ dropdownColor: AppTheme.secondaryBlack,
+ style: AppTheme.bodyMedium,
+ underline: Container(height: 0),
),
- ),
- dropdownColor: AppTheme.secondaryBlack,
- style: AppTheme.bodyMedium,
+ ],
),
const SizedBox(height: 16),
Row(
@@ -712,15 +753,13 @@ class _SettingsScreenState extends State {
);
}
- String _formatFileSize(int bytes) {
- if (bytes < 1024) return '$bytes B';
- if (bytes < 1024 * 1024) return '${(bytes / 1024).toStringAsFixed(1)} KB';
- return '${(bytes / 1024 / 1024).toStringAsFixed(1)} MB';
- }
-
- String _formatDateTime(DateTime dateTime) {
- return '${dateTime.year}-${dateTime.month.toString().padLeft(2, '0')}-${dateTime.day.toString().padLeft(2, '0')} '
- '${dateTime.hour.toString().padLeft(2, '0')}:${dateTime.minute.toString().padLeft(2, '0')}';
+ Future _loadRecordCount() async {
+ final count = await _databaseService.getRecordCount();
+ if (mounted) {
+ setState(() {
+ _recordCount = count;
+ });
+ }
}
Future _getAppVersion() async {
@@ -910,6 +949,7 @@ class _SettingsScreenState extends State {
'mergeRecordsEnabled': 0,
'groupBy': 'trainAndLoco',
'timeWindow': 'unlimited',
+ 'inputSource': 'bluetooth',
});
Navigator.pop(context);
diff --git a/lib/services/audio_input_service.dart b/lib/services/audio_input_service.dart
new file mode 100644
index 0000000..6bed1c7
--- /dev/null
+++ b/lib/services/audio_input_service.dart
@@ -0,0 +1,49 @@
+import 'package:flutter/services.dart';
+import 'package:permission_handler/permission_handler.dart';
+import 'dart:developer' as developer;
+
+class AudioInputService {
+ static final AudioInputService _instance = AudioInputService._internal();
+ factory AudioInputService() => _instance;
+ AudioInputService._internal();
+
+ static const _methodChannel = MethodChannel('org.noxylva.lbjconsole/audio_input');
+
+ bool _isListening = false;
+ bool get isListening => _isListening;
+
+ Future startListening() async {
+ if (_isListening) return true;
+
+ var status = await Permission.microphone.status;
+ if (!status.isGranted) {
+ status = await Permission.microphone.request();
+ if (!status.isGranted) {
+ developer.log('Microphone permission denied', name: 'AudioInput');
+ return false;
+ }
+ }
+
+ try {
+ await _methodChannel.invokeMethod('start');
+ _isListening = true;
+ developer.log('Audio input started', name: 'AudioInput');
+ return true;
+ } on PlatformException catch (e) {
+ developer.log('Failed to start audio input: ${e.message}', name: 'AudioInput');
+ return false;
+ }
+ }
+
+ Future stopListening() async {
+ if (!_isListening) return;
+
+ try {
+ await _methodChannel.invokeMethod('stop');
+ _isListening = false;
+ developer.log('Audio input stopped', name: 'AudioInput');
+ } catch (e) {
+ developer.log('Error stopping audio input: $e', name: 'AudioInput');
+ }
+ }
+}
\ No newline at end of file
diff --git a/lib/services/database_service.dart b/lib/services/database_service.dart
index d009b62..e8e3a94 100644
--- a/lib/services/database_service.dart
+++ b/lib/services/database_service.dart
@@ -4,16 +4,23 @@ import 'package:path/path.dart';
import 'package:path_provider/path_provider.dart';
import 'dart:io';
import 'dart:convert';
+import 'dart:developer' as developer;
import 'package:lbjconsole/models/train_record.dart';
+enum InputSource {
+ bluetooth,
+ rtlTcp,
+ audioInput
+}
+
class DatabaseService {
static final DatabaseService instance = DatabaseService._internal();
factory DatabaseService() => instance;
DatabaseService._internal();
static const String _databaseName = 'train_database';
- static const _databaseVersion = 8;
+ static const _databaseVersion = 9;
static const String trainRecordsTable = 'train_records';
static const String appSettingsTable = 'app_settings';
@@ -63,6 +70,8 @@ class DatabaseService {
}
Future _onUpgrade(Database db, int oldVersion, int newVersion) async {
+ developer.log('Database upgrading from $oldVersion to $newVersion', name: 'Database');
+
if (oldVersion < 2) {
await db.execute(
'ALTER TABLE $appSettingsTable ADD COLUMN hideTimeOnlyRecords INTEGER NOT NULL DEFAULT 0');
@@ -97,6 +106,27 @@ class DatabaseService {
await db.execute(
'ALTER TABLE $appSettingsTable ADD COLUMN rtlTcpPort TEXT NOT NULL DEFAULT "14423"');
}
+ if (oldVersion < 9) {
+ await db.execute(
+ 'ALTER TABLE $appSettingsTable ADD COLUMN inputSource TEXT NOT NULL DEFAULT "bluetooth"');
+
+ try {
+ final List