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> results = await db.query(appSettingsTable, columns: ['rtlTcpEnabled'], where: 'id = 1'); + if (results.isNotEmpty) { + final int rtlTcpEnabled = results.first['rtlTcpEnabled'] as int? ?? 0; + if (rtlTcpEnabled == 1) { + await db.update( + appSettingsTable, + {'inputSource': 'rtlTcp'}, + where: 'id = 1' + ); + developer.log('Migrated V8 settings: inputSource set to rtlTcp', name: 'Database'); + } + } + } catch (e) { + developer.log('Migration V8->V9 data update failed: $e', name: 'Database'); + } + } } Future _onCreate(Database db, int version) async { @@ -150,7 +180,8 @@ class DatabaseService { mapSettingsTimestamp INTEGER, rtlTcpEnabled INTEGER NOT NULL DEFAULT 0, rtlTcpHost TEXT NOT NULL DEFAULT '127.0.0.1', - rtlTcpPort TEXT NOT NULL DEFAULT '14423' + rtlTcpPort TEXT NOT NULL DEFAULT '14423', + inputSource TEXT NOT NULL DEFAULT 'bluetooth' ) '''); @@ -177,11 +208,12 @@ class DatabaseService { 'groupBy': 'trainAndLoco', 'timeWindow': 'unlimited', 'mapTimeFilter': 'unlimited', - 'hideUngroupableRecords': 0, - 'mapSettingsTimestamp': null, - 'rtlTcpEnabled': 0, - 'rtlTcpHost': '127.0.0.1', - 'rtlTcpPort': '14423', + 'hideUngroupableRecords': 0, + 'mapSettingsTimestamp': null, + 'rtlTcpEnabled': 0, + 'rtlTcpHost': '127.0.0.1', + 'rtlTcpPort': '14423', + 'inputSource': 'bluetooth', }); } @@ -409,14 +441,13 @@ class DatabaseService { StreamSubscription onSettingsChanged( Function(Map) listener) { _settingsListeners.add(listener); - return Stream.value(null).listen((_) {}) - ..onData((_) {}) - ..onDone(() { - _settingsListeners.remove(listener); - }); + return _SettingsListenerSubscription(() { + _settingsListeners.remove(listener); + }); } void _notifySettingsChanged(Map settings) { + print('[Database] Notifying ${_settingsListeners.length} settings listeners'); for (final listener in _settingsListeners) { listener(settings); } @@ -499,3 +530,39 @@ class DatabaseService { } } } + +class _SettingsListenerSubscription implements StreamSubscription { + final void Function() _onCancel; + bool _isCanceled = false; + + _SettingsListenerSubscription(this._onCancel); + + @override + Future cancel() async { + if (!_isCanceled) { + _isCanceled = true; + _onCancel(); + } + } + + @override + void onData(void Function(void data)? handleData) {} + + @override + void onDone(void Function()? handleDone) {} + + @override + void onError(Function? handleError) {} + + @override + void pause([Future? resumeSignal]) {} + + @override + void resume() {} + + @override + bool get isPaused => false; + + @override + Future asFuture([E? futureValue]) => Future.value(futureValue); +} \ No newline at end of file