feat: optimize Bluetooth connection status display

This commit is contained in:
Nedifinita
2025-09-25 00:44:03 +08:00
parent 23ab5ec746
commit ba373f749a
9 changed files with 258 additions and 32 deletions

View File

@@ -19,7 +19,7 @@
<uses-feature android:name="android.hardware.bluetooth_le" android:required="true"/> <uses-feature android:name="android.hardware.bluetooth_le" android:required="true"/>
<application <application
android:label="lbjconsole" android:label="LBJ Console"
android:name="${applicationName}" android:name="${applicationName}"
android:icon="@mipmap/ic_launcher"> android:icon="@mipmap/ic_launcher">
<activity <activity

View File

@@ -5,7 +5,7 @@
<key>CFBundleDevelopmentRegion</key> <key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string> <string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key> <key>CFBundleDisplayName</key>
<string>Lbjconsole</string> <string>LBJ Console</string>
<key>CFBundleExecutable</key> <key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string> <string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key> <key>CFBundleIdentifier</key>
@@ -13,7 +13,7 @@
<key>CFBundleInfoDictionaryVersion</key> <key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string> <string>6.0</string>
<key>CFBundleName</key> <key>CFBundleName</key>
<string>lbjconsole</string> <string>LBJ Console</string>
<key>CFBundlePackageType</key> <key>CFBundlePackageType</key>
<string>APPL</string> <string>APPL</string>
<key>CFBundleShortVersionString</key> <key>CFBundleShortVersionString</key>

View File

@@ -33,6 +33,7 @@ class HistoryScreenState extends State<HistoryScreen> {
final ScrollController _scrollController = ScrollController(); final ScrollController _scrollController = ScrollController();
bool _isAtTop = true; bool _isAtTop = true;
MergeSettings _mergeSettings = MergeSettings(); MergeSettings _mergeSettings = MergeSettings();
double _itemHeightCache = 0.0;
final Map<String, double> _mapOptimalZoom = {}; final Map<String, double> _mapOptimalZoom = {};
final Map<String, bool> _mapCalculating = {}; final Map<String, bool> _mapCalculating = {};
@@ -123,6 +124,9 @@ class HistoryScreenState extends State<HistoryScreen> {
if (!isNewRecord) return; if (!isNewRecord) return;
if (mounted) { if (mounted) {
final previousScrollOffset = _scrollController.hasClients ? _scrollController.offset : 0.0;
final previousItemCount = _displayItems.length;
final allRecords = await DatabaseService.instance.getAllRecords(); final allRecords = await DatabaseService.instance.getAllRecords();
final items = MergeService.getMixedList(allRecords, _mergeSettings); final items = MergeService.getMixedList(allRecords, _mergeSettings);
@@ -131,14 +135,31 @@ class HistoryScreenState extends State<HistoryScreen> {
_displayItems.addAll(items); _displayItems.addAll(items);
}); });
if (_isAtTop && _scrollController.hasClients) { if (_scrollController.hasClients) {
if (_isAtTop) {
_scrollController.jumpTo(0.0); _scrollController.jumpTo(0.0);
} else {
final newItemCount = items.length;
final itemDifference = newItemCount - previousItemCount;
if (itemDifference > 0 && previousScrollOffset > 0) {
final itemHeight = _getEstimatedItemHeight();
final adjustedOffset = previousScrollOffset + (itemDifference * itemHeight);
_scrollController.jumpTo(adjustedOffset.clamp(0.0, _scrollController.position.maxScrollExtent));
} }
} }
} catch (e) {
print('添加新纪录失败: $e');
} }
} }
} catch (e) {}
}
double _getEstimatedItemHeight() {
if (_itemHeightCache > 0) {
return _itemHeightCache;
}
return 85.0;
}
bool _hasDataChanged(List<Object> newItems) { bool _hasDataChanged(List<Object> newItems) {
if (_displayItems.length != newItems.length) return true; if (_displayItems.length != newItems.length) return true;
@@ -489,7 +510,11 @@ class HistoryScreenState extends State<HistoryScreen> {
final isSelected = _selectedRecords.contains(record.uniqueId); final isSelected = _selectedRecords.contains(record.uniqueId);
final isExpanded = final isExpanded =
!isSubCard && (_expandedStates[record.uniqueId] ?? false); !isSubCard && (_expandedStates[record.uniqueId] ?? false);
return Card(
final GlobalKey itemKey = GlobalKey();
final Widget card = Card(
key: itemKey,
color: isSelected && _isEditMode color: isSelected && _isEditMode
? const Color(0xFF2E2E2E) ? const Color(0xFF2E2E2E)
: const Color(0xFF1E1E1E), : const Color(0xFF1E1E1E),
@@ -545,6 +570,20 @@ class HistoryScreenState extends State<HistoryScreen> {
_buildLocoInfo(record), _buildLocoInfo(record),
if (isExpanded) _buildExpandedContent(record) if (isExpanded) _buildExpandedContent(record)
])))); ]))));
WidgetsBinding.instance.addPostFrameCallback((_) {
if (_itemHeightCache <= 0 && itemKey.currentContext != null) {
final RenderBox renderBox = itemKey.currentContext!.findRenderObject() as RenderBox;
final double realHeight = renderBox.size.height;
if (realHeight > 0) {
setState(() {
_itemHeightCache = realHeight;
});
}
}
});
return card;
} }
Widget _buildRecordHeader(TrainRecord record, {bool isMerged = false}) { Widget _buildRecordHeader(TrainRecord record, {bool isMerged = false}) {

View File

@@ -13,6 +13,158 @@ import 'package:lbjconsole/services/notification_service.dart';
import 'package:lbjconsole/services/background_service.dart'; import 'package:lbjconsole/services/background_service.dart';
import 'package:lbjconsole/themes/app_theme.dart'; import 'package:lbjconsole/themes/app_theme.dart';
class _ConnectionStatusWidget extends StatefulWidget {
final BLEService bleService;
final DateTime? lastReceivedTime;
const _ConnectionStatusWidget({
required this.bleService,
required this.lastReceivedTime,
});
@override
State<_ConnectionStatusWidget> createState() =>
_ConnectionStatusWidgetState();
}
class _ConnectionStatusWidgetState extends State<_ConnectionStatusWidget> {
StreamSubscription? _connectionSubscription;
String _deviceStatus = "未连接";
bool _isConnected = false;
@override
void initState() {
super.initState();
_connectionSubscription =
widget.bleService.connectionStream.listen((connected) {
if (mounted) {
setState(() {
_isConnected = connected;
_deviceStatus = connected ? "已连接" : "未连接";
});
}
});
_isConnected = widget.bleService.isConnected;
_deviceStatus = widget.bleService.deviceStatus;
}
@override
void dispose() {
_connectionSubscription?.cancel();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Row(
children: [
Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (widget.lastReceivedTime == null || !_isConnected) ...[
Text(_deviceStatus,
style: const TextStyle(color: Colors.white70, fontSize: 12)),
],
_LastReceivedTimeWidget(
lastReceivedTime: widget.lastReceivedTime,
isConnected: _isConnected,
),
],
),
const SizedBox(width: 8),
Container(
width: 8,
height: 8,
decoration: BoxDecoration(
color: _isConnected ? Colors.green : Colors.red,
shape: BoxShape.circle,
),
),
],
);
}
}
class _LastReceivedTimeWidget extends StatefulWidget {
final DateTime? lastReceivedTime;
final bool isConnected;
const _LastReceivedTimeWidget({
required this.lastReceivedTime,
required this.isConnected,
});
@override
State<_LastReceivedTimeWidget> createState() =>
_LastReceivedTimeWidgetState();
}
class _LastReceivedTimeWidgetState extends State<_LastReceivedTimeWidget> {
Timer? _timer;
@override
void initState() {
super.initState();
_startTimer();
}
@override
void didUpdateWidget(_LastReceivedTimeWidget oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.lastReceivedTime != widget.lastReceivedTime ||
oldWidget.isConnected != widget.isConnected) {
_startTimer();
}
}
void _startTimer() {
_timer?.cancel();
if (widget.lastReceivedTime != null && widget.isConnected) {
_timer = Timer.periodic(const Duration(seconds: 1), (timer) {
if (mounted) {
setState(() {});
}
});
}
}
String _formatTime() {
if (widget.lastReceivedTime == null) return '';
final now = DateTime.now();
final difference = now.difference(widget.lastReceivedTime!);
if (difference.inDays > 0) {
return '${difference.inDays}天前';
} else if (difference.inHours > 0) {
return '${difference.inHours}小时前';
} else if (difference.inMinutes > 0) {
return '${difference.inMinutes}分钟前';
} else {
return '${difference.inSeconds}秒前';
}
}
@override
void dispose() {
_timer?.cancel();
super.dispose();
}
@override
Widget build(BuildContext context) {
if (widget.lastReceivedTime == null || !widget.isConnected) {
return const SizedBox.shrink();
}
return Text(
_formatTime(),
style: const TextStyle(color: Colors.white70, fontSize: 12),
);
}
}
class MainScreen extends StatefulWidget { class MainScreen extends StatefulWidget {
const MainScreen({super.key}); const MainScreen({super.key});
@@ -28,7 +180,8 @@ class _MainScreenState extends State<MainScreen> with WidgetsBindingObserver {
StreamSubscription? _connectionSubscription; StreamSubscription? _connectionSubscription;
StreamSubscription? _dataSubscription; StreamSubscription? _dataSubscription;
StreamSubscription? _lastReceivedTimeSubscription;
DateTime? _lastReceivedTime;
bool _isHistoryEditMode = false; bool _isHistoryEditMode = false;
final GlobalKey<HistoryScreenState> _historyScreenKey = final GlobalKey<HistoryScreenState> _historyScreenKey =
GlobalKey<HistoryScreenState>(); GlobalKey<HistoryScreenState>();
@@ -41,21 +194,35 @@ class _MainScreenState extends State<MainScreen> with WidgetsBindingObserver {
_bleService.initialize(); _bleService.initialize();
_initializeServices(); _initializeServices();
_checkAndStartBackgroundService(); _checkAndStartBackgroundService();
_setupLastReceivedTimeListener();
} }
Future<void> _checkAndStartBackgroundService() async { Future<void> _checkAndStartBackgroundService() async {
final settings = await DatabaseService.instance.getAllSettings() ?? {}; final settings = await DatabaseService.instance.getAllSettings() ?? {};
final backgroundServiceEnabled = (settings['backgroundServiceEnabled'] ?? 0) == 1; final backgroundServiceEnabled =
(settings['backgroundServiceEnabled'] ?? 0) == 1;
if (backgroundServiceEnabled) { if (backgroundServiceEnabled) {
await BackgroundService.startService(); await BackgroundService.startService();
} }
} }
void _setupLastReceivedTimeListener() {
_lastReceivedTimeSubscription =
_bleService.lastReceivedTimeStream.listen((time) {
if (mounted) {
setState(() {
_lastReceivedTime = time;
});
}
});
}
@override @override
void dispose() { void dispose() {
_connectionSubscription?.cancel(); _connectionSubscription?.cancel();
_dataSubscription?.cancel(); _dataSubscription?.cancel();
_lastReceivedTimeSubscription?.cancel();
WidgetsBinding.instance.removeObserver(this); WidgetsBinding.instance.removeObserver(this);
super.dispose(); super.dispose();
} }
@@ -70,10 +237,6 @@ class _MainScreenState extends State<MainScreen> with WidgetsBindingObserver {
Future<void> _initializeServices() async { Future<void> _initializeServices() async {
await _notificationService.initialize(); await _notificationService.initialize();
_connectionSubscription = _bleService.connectionStream.listen((_) {
if (mounted) setState(() {});
});
_dataSubscription = _bleService.dataStream.listen((record) { _dataSubscription = _bleService.dataStream.listen((record) {
_notificationService.showTrainNotification(record); _notificationService.showTrainNotification(record);
if (_historyScreenKey.currentState != null) { if (_historyScreenKey.currentState != null) {
@@ -133,17 +296,10 @@ class _MainScreenState extends State<MainScreen> with WidgetsBindingObserver {
actions: [ actions: [
Row( Row(
children: [ children: [
Container( _ConnectionStatusWidget(
width: 8, bleService: _bleService,
height: 8, lastReceivedTime: _lastReceivedTime,
decoration: BoxDecoration(
color: _bleService.isConnected ? Colors.green : Colors.red,
shape: BoxShape.circle,
), ),
),
const SizedBox(width: 8),
Text(_bleService.deviceStatus,
style: const TextStyle(color: Colors.white70)),
IconButton( IconButton(
icon: const Icon(Icons.bluetooth, color: Colors.white), icon: const Icon(Icons.bluetooth, color: Colors.white),
onPressed: _showConnectionDialog, onPressed: _showConnectionDialog,
@@ -274,6 +430,8 @@ class _PixelPerfectBluetoothDialogState
List<BluetoothDevice> _devices = []; List<BluetoothDevice> _devices = [];
_ScanState _scanState = _ScanState.initial; _ScanState _scanState = _ScanState.initial;
StreamSubscription? _connectionSubscription; StreamSubscription? _connectionSubscription;
StreamSubscription? _lastReceivedTimeSubscription;
DateTime? _lastReceivedTime;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
@@ -288,6 +446,7 @@ class _PixelPerfectBluetoothDialogState
@override @override
void dispose() { void dispose() {
_connectionSubscription?.cancel(); _connectionSubscription?.cancel();
_lastReceivedTimeSubscription?.cancel();
super.dispose(); super.dispose();
} }
@@ -317,6 +476,17 @@ class _PixelPerfectBluetoothDialogState
await widget.bleService.disconnect(); await widget.bleService.disconnect();
} }
void _setupLastReceivedTimeListener() {
_lastReceivedTimeSubscription =
widget.bleService.lastReceivedTimeStream.listen((timestamp) {
if (mounted) {
setState(() {
_lastReceivedTime = timestamp;
});
}
});
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final isConnected = widget.bleService.isConnected; final isConnected = widget.bleService.isConnected;
@@ -353,6 +523,13 @@ class _PixelPerfectBluetoothDialogState
Text(device?.remoteId.str ?? '', Text(device?.remoteId.str ?? '',
style: Theme.of(context).textTheme.bodySmall, style: Theme.of(context).textTheme.bodySmall,
textAlign: TextAlign.center), textAlign: TextAlign.center),
if (_lastReceivedTime != null) ...[
const SizedBox(height: 8),
_LastReceivedTimeWidget(
lastReceivedTime: _lastReceivedTime,
isConnected: widget.bleService.isConnected,
),
],
const SizedBox(height: 16), const SizedBox(height: 16),
ElevatedButton.icon( ElevatedButton.icon(
onPressed: _disconnect, onPressed: _disconnect,

View File

@@ -395,9 +395,9 @@ class _SettingsScreenState extends State<SettingsScreen> {
children: [ children: [
Row( Row(
children: [ children: [
Icon(Icons.share, color: Theme.of(context).colorScheme.primary), Icon(Icons.storage, color: Theme.of(context).colorScheme.primary),
const SizedBox(width: 12), const SizedBox(width: 12),
Text('数据分享', style: AppTheme.titleMedium), Text('数据管理', style: AppTheme.titleMedium),
], ],
), ),
const SizedBox(height: 16), const SizedBox(height: 16),

View File

@@ -27,14 +27,19 @@ class BLEService {
StreamController<TrainRecord>.broadcast(); StreamController<TrainRecord>.broadcast();
final StreamController<bool> _connectionController = final StreamController<bool> _connectionController =
StreamController<bool>.broadcast(); StreamController<bool>.broadcast();
final StreamController<DateTime?> _lastReceivedTimeController =
StreamController<DateTime?>.broadcast();
Stream<String> get statusStream => _statusController.stream; Stream<String> get statusStream => _statusController.stream;
Stream<TrainRecord> get dataStream => _dataController.stream; Stream<TrainRecord> get dataStream => _dataController.stream;
Stream<bool> get connectionStream => _connectionController.stream; Stream<bool> get connectionStream => _connectionController.stream;
Stream<DateTime?> get lastReceivedTimeStream =>
_lastReceivedTimeController.stream;
String _deviceStatus = "未连接"; String _deviceStatus = "未连接";
String? _lastKnownDeviceAddress; String? _lastKnownDeviceAddress;
String _targetDeviceName = "LBJReceiver"; String _targetDeviceName = "LBJReceiver";
DateTime? _lastReceivedTime;
bool _isConnecting = false; bool _isConnecting = false;
bool _isManualDisconnect = false; bool _isManualDisconnect = false;
@@ -69,8 +74,7 @@ class BLEService {
if (settings != null) { if (settings != null) {
_targetDeviceName = settings['deviceName'] ?? 'LBJReceiver'; _targetDeviceName = settings['deviceName'] ?? 'LBJReceiver';
} }
} catch (e) { } catch (e) {}
}
} }
void ensureConnection() { void ensureConnection() {
@@ -315,6 +319,9 @@ class BLEService {
'${now.millisecondsSinceEpoch}_${Random().nextInt(9999)}'; '${now.millisecondsSinceEpoch}_${Random().nextInt(9999)}';
recordData['receivedTimestamp'] = now.millisecondsSinceEpoch; recordData['receivedTimestamp'] = now.millisecondsSinceEpoch;
_lastReceivedTime = now;
_lastReceivedTimeController.add(_lastReceivedTime);
final trainRecord = TrainRecord.fromJson(recordData); final trainRecord = TrainRecord.fromJson(recordData);
_dataController.add(trainRecord); _dataController.add(trainRecord);
DatabaseService.instance.insertRecord(trainRecord); DatabaseService.instance.insertRecord(trainRecord);
@@ -331,6 +338,8 @@ class BLEService {
_deviceStatus = status; _deviceStatus = status;
_connectedDevice = null; _connectedDevice = null;
_characteristic = null; _characteristic = null;
_lastReceivedTime = null;
_lastReceivedTimeController.add(null);
} }
_statusController.add(_deviceStatus); _statusController.add(_deviceStatus);
_connectionController.add(connected); _connectionController.add(connected);
@@ -357,5 +366,6 @@ class BLEService {
_statusController.close(); _statusController.close();
_dataController.close(); _dataController.close();
_connectionController.close(); _connectionController.close();
_lastReceivedTimeController.close();
} }
} }

View File

@@ -61,7 +61,7 @@ class NotificationService {
return; return;
} }
final String title = '列车信息更新'; final String title = '列车信息';
final String body = _buildNotificationContent(record); final String body = _buildNotificationContent(record);
final AndroidNotificationDetails androidPlatformChannelSpecifics = final AndroidNotificationDetails androidPlatformChannelSpecifics =

View File

@@ -5,7 +5,7 @@
// 'flutter create' template. // 'flutter create' template.
// The application's name. By default this is also the title of the Flutter window. // The application's name. By default this is also the title of the Flutter window.
PRODUCT_NAME = lbjconsole PRODUCT_NAME = LBJ Console
// The application's bundle identifier // The application's bundle identifier
PRODUCT_BUNDLE_IDENTIFIER = org.noxylva.lbjconsole PRODUCT_BUNDLE_IDENTIFIER = org.noxylva.lbjconsole

View File

@@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
# In Windows, build-name is used as the major, minor, and patch parts # In Windows, build-name is used as the major, minor, and patch parts
# of the product and file versions while build-number is used as the build suffix. # of the product and file versions while build-number is used as the build suffix.
version: 0.1.7-flutter version: 0.2.0-flutter+20 # versionName: 0.2.0-flutter, versionCode: 3
environment: environment:
sdk: ^3.5.4 sdk: ^3.5.4