diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 60c4730..40c2721 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -19,7 +19,7 @@ CFBundleDevelopmentRegion $(DEVELOPMENT_LANGUAGE) CFBundleDisplayName - Lbjconsole + LBJ Console CFBundleExecutable $(EXECUTABLE_NAME) CFBundleIdentifier @@ -13,7 +13,7 @@ CFBundleInfoDictionaryVersion 6.0 CFBundleName - lbjconsole + LBJ Console CFBundlePackageType APPL CFBundleShortVersionString diff --git a/lib/screens/history_screen.dart b/lib/screens/history_screen.dart index 888da8e..db1d2c1 100644 --- a/lib/screens/history_screen.dart +++ b/lib/screens/history_screen.dart @@ -33,6 +33,7 @@ class HistoryScreenState extends State { final ScrollController _scrollController = ScrollController(); bool _isAtTop = true; MergeSettings _mergeSettings = MergeSettings(); + double _itemHeightCache = 0.0; final Map _mapOptimalZoom = {}; final Map _mapCalculating = {}; @@ -123,6 +124,9 @@ class HistoryScreenState extends State { if (!isNewRecord) return; if (mounted) { + final previousScrollOffset = _scrollController.hasClients ? _scrollController.offset : 0.0; + final previousItemCount = _displayItems.length; + final allRecords = await DatabaseService.instance.getAllRecords(); final items = MergeService.getMixedList(allRecords, _mergeSettings); @@ -131,13 +135,30 @@ class HistoryScreenState extends State { _displayItems.addAll(items); }); - if (_isAtTop && _scrollController.hasClients) { - _scrollController.jumpTo(0.0); + if (_scrollController.hasClients) { + if (_isAtTop) { + _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 newItems) { @@ -489,7 +510,11 @@ class HistoryScreenState extends State { final isSelected = _selectedRecords.contains(record.uniqueId); final isExpanded = !isSubCard && (_expandedStates[record.uniqueId] ?? false); - return Card( + + final GlobalKey itemKey = GlobalKey(); + + final Widget card = Card( + key: itemKey, color: isSelected && _isEditMode ? const Color(0xFF2E2E2E) : const Color(0xFF1E1E1E), @@ -545,6 +570,20 @@ class HistoryScreenState extends State { _buildLocoInfo(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}) { diff --git a/lib/screens/main_screen.dart b/lib/screens/main_screen.dart index 5d8b85e..6fc6646 100644 --- a/lib/screens/main_screen.dart +++ b/lib/screens/main_screen.dart @@ -13,6 +13,158 @@ import 'package:lbjconsole/services/notification_service.dart'; import 'package:lbjconsole/services/background_service.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 { const MainScreen({super.key}); @@ -28,7 +180,8 @@ class _MainScreenState extends State with WidgetsBindingObserver { StreamSubscription? _connectionSubscription; StreamSubscription? _dataSubscription; - + StreamSubscription? _lastReceivedTimeSubscription; + DateTime? _lastReceivedTime; bool _isHistoryEditMode = false; final GlobalKey _historyScreenKey = GlobalKey(); @@ -41,21 +194,35 @@ class _MainScreenState extends State with WidgetsBindingObserver { _bleService.initialize(); _initializeServices(); _checkAndStartBackgroundService(); + _setupLastReceivedTimeListener(); } Future _checkAndStartBackgroundService() async { final settings = await DatabaseService.instance.getAllSettings() ?? {}; - final backgroundServiceEnabled = (settings['backgroundServiceEnabled'] ?? 0) == 1; - + final backgroundServiceEnabled = + (settings['backgroundServiceEnabled'] ?? 0) == 1; + if (backgroundServiceEnabled) { await BackgroundService.startService(); } } + void _setupLastReceivedTimeListener() { + _lastReceivedTimeSubscription = + _bleService.lastReceivedTimeStream.listen((time) { + if (mounted) { + setState(() { + _lastReceivedTime = time; + }); + } + }); + } + @override void dispose() { _connectionSubscription?.cancel(); _dataSubscription?.cancel(); + _lastReceivedTimeSubscription?.cancel(); WidgetsBinding.instance.removeObserver(this); super.dispose(); } @@ -70,10 +237,6 @@ class _MainScreenState extends State with WidgetsBindingObserver { Future _initializeServices() async { await _notificationService.initialize(); - _connectionSubscription = _bleService.connectionStream.listen((_) { - if (mounted) setState(() {}); - }); - _dataSubscription = _bleService.dataStream.listen((record) { _notificationService.showTrainNotification(record); if (_historyScreenKey.currentState != null) { @@ -133,17 +296,10 @@ class _MainScreenState extends State with WidgetsBindingObserver { actions: [ Row( children: [ - Container( - width: 8, - height: 8, - decoration: BoxDecoration( - color: _bleService.isConnected ? Colors.green : Colors.red, - shape: BoxShape.circle, - ), + _ConnectionStatusWidget( + bleService: _bleService, + lastReceivedTime: _lastReceivedTime, ), - const SizedBox(width: 8), - Text(_bleService.deviceStatus, - style: const TextStyle(color: Colors.white70)), IconButton( icon: const Icon(Icons.bluetooth, color: Colors.white), onPressed: _showConnectionDialog, @@ -274,6 +430,8 @@ class _PixelPerfectBluetoothDialogState List _devices = []; _ScanState _scanState = _ScanState.initial; StreamSubscription? _connectionSubscription; + StreamSubscription? _lastReceivedTimeSubscription; + DateTime? _lastReceivedTime; @override void initState() { super.initState(); @@ -288,6 +446,7 @@ class _PixelPerfectBluetoothDialogState @override void dispose() { _connectionSubscription?.cancel(); + _lastReceivedTimeSubscription?.cancel(); super.dispose(); } @@ -317,6 +476,17 @@ 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; @@ -353,6 +523,13 @@ 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, diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart index 2d49027..7cb4866 100644 --- a/lib/screens/settings_screen.dart +++ b/lib/screens/settings_screen.dart @@ -395,9 +395,9 @@ class _SettingsScreenState extends State { children: [ Row( children: [ - Icon(Icons.share, color: Theme.of(context).colorScheme.primary), + Icon(Icons.storage, color: Theme.of(context).colorScheme.primary), const SizedBox(width: 12), - Text('数据分享', style: AppTheme.titleMedium), + Text('数据管理', style: AppTheme.titleMedium), ], ), const SizedBox(height: 16), diff --git a/lib/services/ble_service.dart b/lib/services/ble_service.dart index f9ddf8f..76aed99 100644 --- a/lib/services/ble_service.dart +++ b/lib/services/ble_service.dart @@ -27,14 +27,19 @@ class BLEService { StreamController.broadcast(); final StreamController _connectionController = StreamController.broadcast(); + final StreamController _lastReceivedTimeController = + StreamController.broadcast(); Stream get statusStream => _statusController.stream; Stream get dataStream => _dataController.stream; Stream get connectionStream => _connectionController.stream; + Stream get lastReceivedTimeStream => + _lastReceivedTimeController.stream; String _deviceStatus = "未连接"; String? _lastKnownDeviceAddress; String _targetDeviceName = "LBJReceiver"; + DateTime? _lastReceivedTime; bool _isConnecting = false; bool _isManualDisconnect = false; @@ -69,8 +74,7 @@ class BLEService { if (settings != null) { _targetDeviceName = settings['deviceName'] ?? 'LBJReceiver'; } - } catch (e) { - } + } catch (e) {} } void ensureConnection() { @@ -315,6 +319,9 @@ class BLEService { '${now.millisecondsSinceEpoch}_${Random().nextInt(9999)}'; recordData['receivedTimestamp'] = now.millisecondsSinceEpoch; + _lastReceivedTime = now; + _lastReceivedTimeController.add(_lastReceivedTime); + final trainRecord = TrainRecord.fromJson(recordData); _dataController.add(trainRecord); DatabaseService.instance.insertRecord(trainRecord); @@ -331,6 +338,8 @@ class BLEService { _deviceStatus = status; _connectedDevice = null; _characteristic = null; + _lastReceivedTime = null; + _lastReceivedTimeController.add(null); } _statusController.add(_deviceStatus); _connectionController.add(connected); @@ -357,5 +366,6 @@ class BLEService { _statusController.close(); _dataController.close(); _connectionController.close(); + _lastReceivedTimeController.close(); } } diff --git a/lib/services/notification_service.dart b/lib/services/notification_service.dart index 65bc2f2..6173793 100644 --- a/lib/services/notification_service.dart +++ b/lib/services/notification_service.dart @@ -61,7 +61,7 @@ class NotificationService { return; } - final String title = '列车信息更新'; + final String title = '列车信息'; final String body = _buildNotificationContent(record); final AndroidNotificationDetails androidPlatformChannelSpecifics = diff --git a/macos/Runner/Configs/AppInfo.xcconfig b/macos/Runner/Configs/AppInfo.xcconfig index 863ab52..ad8ae3d 100644 --- a/macos/Runner/Configs/AppInfo.xcconfig +++ b/macos/Runner/Configs/AppInfo.xcconfig @@ -5,7 +5,7 @@ // 'flutter create' template. // 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 PRODUCT_BUNDLE_IDENTIFIER = org.noxylva.lbjconsole diff --git a/pubspec.yaml b/pubspec.yaml index bf61489..821003f 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -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 # 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. -version: 0.1.7-flutter +version: 0.2.0-flutter+20 # versionName: 0.2.0-flutter, versionCode: 3 environment: sdk: ^3.5.4