7 Commits

Author SHA1 Message Date
Nedifinita
64401a6ce9 feat: add the function to hide records that are only valid for time. 2025-09-25 22:52:19 +08:00
Nedifinita
72f9dfe17b fix: repair record processing logic 2025-09-25 22:25:51 +08:00
Nedifinita
bf850eed38 refactor: optimize record card rendering logic and animation effects 2025-09-25 22:01:25 +08:00
Nedifinita
56689fc993 feat: optimize record list 2025-09-25 21:45:52 +08:00
Nedifinita
ba373f749a feat: optimize Bluetooth connection status display 2025-09-25 00:44:03 +08:00
Nedifinita
23ab5ec746 feat: add background services and map status management 2025-09-24 23:36:55 +08:00
Nedifinita
10825171fd fix: solve the map state management problem when expanding/collapsed. 2025-09-24 17:39:52 +08:00
17 changed files with 1440 additions and 225 deletions

View File

@@ -1,4 +1,5 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.BLUETOOTH"/>
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN"/>
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" android:usesPermissionFlags="neverForLocation"/>
@@ -14,10 +15,11 @@
<uses-permission android:name="android.permission.WAKE_LOCK"/>
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC"/>
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_CONNECTED_DEVICE"/>
<uses-feature android:name="android.hardware.bluetooth_le" android:required="true"/>
<application
android:label="lbjconsole"
android:label="LBJ Console"
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher">
<activity
@@ -47,6 +49,15 @@
<meta-data
android:name="flutterEmbedding"
android:value="2" />
<!-- 前台服务配置 -->
<service
android:name="id.flutter.flutter_background_service.BackgroundService"
android:foregroundServiceType="connectedDevice|dataSync"
android:exported="false"
android:stopWithTask="false"
android:enabled="true"
tools:replace="android:exported"/>
</application>
<!-- Required to query activities that can process text, see:
https://developer.android.com/training/package-visibility and

View File

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

View File

@@ -1,14 +1,20 @@
import 'package:flutter/material.dart';
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
import 'package:lbjconsole/screens/main_screen.dart';
import 'package:lbjconsole/util/train_type_util.dart';
import 'package:lbjconsole/util/loco_info_util.dart';
import 'package:lbjconsole/util/loco_type_util.dart';
import 'package:lbjconsole/services/loco_type_service.dart';
import 'package:lbjconsole/services/database_service.dart';
import 'package:lbjconsole/services/background_service.dart';
void main() async {
WidgetsFlutterBinding.ensureInitialized();
await _initializeNotifications();
await BackgroundService.initialize();
await Future.wait([
TrainTypeUtil.initialize(),
LocoInfoUtil.initialize(),
@@ -18,6 +24,19 @@ void main() async {
runApp(const LBJReceiverApp());
}
Future<void> _initializeNotifications() async {
final flutterLocalNotificationsPlugin = FlutterLocalNotificationsPlugin();
const AndroidInitializationSettings initializationSettingsAndroid =
AndroidInitializationSettings('@mipmap/ic_launcher');
const InitializationSettings initializationSettings = InitializationSettings(
android: initializationSettingsAndroid,
);
await flutterLocalNotificationsPlugin.initialize(initializationSettings);
}
class LBJReceiverApp extends StatelessWidget {
const LBJReceiverApp({super.key});

65
lib/models/map_state.dart Normal file
View File

@@ -0,0 +1,65 @@
class MapState {
final double zoom;
final double centerLat;
final double centerLng;
final double bearing;
MapState({
required this.zoom,
required this.centerLat,
required this.centerLng,
required this.bearing,
});
Map<String, dynamic> toJson() {
return {
'zoom': zoom,
'centerLat': centerLat,
'centerLng': centerLng,
'bearing': bearing,
};
}
factory MapState.fromJson(Map<String, dynamic> json) {
return MapState(
zoom: json['zoom']?.toDouble() ?? 10.0,
centerLat: json['centerLat']?.toDouble() ?? 39.9042,
centerLng: json['centerLng']?.toDouble() ?? 116.4074,
bearing: json['bearing']?.toDouble() ?? 0.0,
);
}
MapState copyWith({
double? zoom,
double? centerLat,
double? centerLng,
double? bearing,
}) {
return MapState(
zoom: zoom ?? this.zoom,
centerLat: centerLat ?? this.centerLat,
centerLng: centerLng ?? this.centerLng,
bearing: bearing ?? this.bearing,
);
}
@override
String toString() {
return 'MapState(zoom: ' + zoom.toString() + ', centerLat: ' + centerLat.toString() + ', centerLng: ' + centerLng.toString() + ', bearing: ' + bearing.toString() + ')';
}
@override
bool operator ==(Object other) {
if (identical(this, other)) return true;
return other is MapState &&
other.zoom == zoom &&
other.centerLat == centerLat &&
other.centerLng == centerLng &&
other.bearing == bearing;
}
@override
int get hashCode {
return zoom.hashCode ^ centerLat.hashCode ^ centerLng.hashCode ^ bearing.hashCode;
}
}

View File

@@ -149,7 +149,7 @@ class TrainRecord {
final lbjClassValue = lbjClass.trim();
final trainValue = train.trim();
if (trainValue == "<NUL>") {
if (trainValue == "<NUL>" || trainValue.contains("-----")) {
return "";
}

File diff suppressed because it is too large Load Diff

View File

@@ -10,8 +10,161 @@ import 'package:lbjconsole/screens/settings_screen.dart';
import 'package:lbjconsole/services/ble_service.dart';
import 'package:lbjconsole/services/database_service.dart';
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});
@@ -27,7 +180,8 @@ class _MainScreenState extends State<MainScreen> with WidgetsBindingObserver {
StreamSubscription? _connectionSubscription;
StreamSubscription? _dataSubscription;
StreamSubscription? _lastReceivedTimeSubscription;
DateTime? _lastReceivedTime;
bool _isHistoryEditMode = false;
final GlobalKey<HistoryScreenState> _historyScreenKey =
GlobalKey<HistoryScreenState>();
@@ -39,12 +193,36 @@ class _MainScreenState extends State<MainScreen> with WidgetsBindingObserver {
_bleService = BLEService();
_bleService.initialize();
_initializeServices();
_checkAndStartBackgroundService();
_setupLastReceivedTimeListener();
}
Future<void> _checkAndStartBackgroundService() async {
final settings = await DatabaseService.instance.getAllSettings() ?? {};
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();
}
@@ -59,14 +237,10 @@ class _MainScreenState extends State<MainScreen> with WidgetsBindingObserver {
Future<void> _initializeServices() async {
await _notificationService.initialize();
_connectionSubscription = _bleService.connectionStream.listen((_) {
if (mounted) setState(() {});
});
_dataSubscription = _bleService.dataStream.listen((record) {
_notificationService.showTrainNotification(record);
if (_historyScreenKey.currentState != null) {
_historyScreenKey.currentState!.loadRecords(scrollToTop: true);
_historyScreenKey.currentState!.addNewRecord(record);
}
});
}
@@ -122,17 +296,10 @@ class _MainScreenState extends State<MainScreen> 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,
@@ -230,7 +397,7 @@ class _MainScreenState extends State<MainScreen> with WidgetsBindingObserver {
selectedIndex: _currentIndex,
onDestinationSelected: (index) {
if (_currentIndex == 2 && index == 0) {
_historyScreenKey.currentState?.loadRecords();
_historyScreenKey.currentState?.reloadRecords();
}
setState(() {
if (_isHistoryEditMode) _isHistoryEditMode = false;
@@ -263,6 +430,8 @@ class _PixelPerfectBluetoothDialogState
List<BluetoothDevice> _devices = [];
_ScanState _scanState = _ScanState.initial;
StreamSubscription? _connectionSubscription;
StreamSubscription? _lastReceivedTimeSubscription;
DateTime? _lastReceivedTime;
@override
void initState() {
super.initState();
@@ -277,6 +446,7 @@ class _PixelPerfectBluetoothDialogState
@override
void dispose() {
_connectionSubscription?.cancel();
_lastReceivedTimeSubscription?.cancel();
super.dispose();
}
@@ -306,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;
@@ -342,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,

View File

@@ -5,6 +5,7 @@ import 'dart:io';
import 'package:lbjconsole/models/merged_record.dart';
import 'package:lbjconsole/services/database_service.dart';
import 'package:lbjconsole/services/ble_service.dart';
import 'package:lbjconsole/services/background_service.dart';
import 'package:lbjconsole/themes/app_theme.dart';
import 'package:url_launcher/url_launcher.dart';
@@ -31,6 +32,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
bool _notificationsEnabled = true;
int _recordCount = 0;
bool _mergeRecordsEnabled = false;
bool _hideTimeOnlyRecords = false;
GroupBy _groupBy = GroupBy.trainAndLoco;
TimeWindow _timeWindow = TimeWindow.unlimited;
@@ -60,6 +62,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
(settingsMap['backgroundServiceEnabled'] ?? 0) == 1;
_notificationsEnabled = (settingsMap['notificationEnabled'] ?? 1) == 1;
_mergeRecordsEnabled = settings.enabled;
_hideTimeOnlyRecords = (settingsMap['hideTimeOnlyRecords'] ?? 0) == 1;
_groupBy = settings.groupBy;
_timeWindow = settings.timeWindow;
});
@@ -81,6 +84,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
'backgroundServiceEnabled': _backgroundServiceEnabled ? 1 : 0,
'notificationEnabled': _notificationsEnabled ? 1 : 0,
'mergeRecordsEnabled': _mergeRecordsEnabled ? 1 : 0,
'hideTimeOnlyRecords': _hideTimeOnlyRecords ? 1 : 0,
'groupBy': _groupBy.name,
'timeWindow': _timeWindow.name,
});
@@ -196,11 +200,17 @@ class _SettingsScreenState extends State<SettingsScreen> {
),
Switch(
value: _backgroundServiceEnabled,
onChanged: (value) {
onChanged: (value) async {
setState(() {
_backgroundServiceEnabled = value;
});
_saveSettings();
await _saveSettings();
if (value) {
await BackgroundService.startService();
} else {
await BackgroundService.stopService();
}
},
activeColor: Theme.of(context).colorScheme.primary,
),
@@ -229,6 +239,29 @@ class _SettingsScreenState extends State<SettingsScreen> {
),
],
),
const SizedBox(height: 16),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('隐藏只有时间有效的记录', style: AppTheme.bodyLarge),
Text('不显示只有时间信息的记录', style: AppTheme.caption),
],
),
Switch(
value: _hideTimeOnlyRecords,
onChanged: (value) {
setState(() {
_hideTimeOnlyRecords = value;
});
_saveSettings();
},
activeColor: Theme.of(context).colorScheme.primary,
),
],
),
],
),
),
@@ -388,9 +421,9 @@ class _SettingsScreenState extends State<SettingsScreen> {
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),
@@ -503,8 +536,6 @@ class _SettingsScreenState extends State<SettingsScreen> {
}
}
Future<void> _shareData() async {
final scaffoldMessenger = ScaffoldMessenger.of(context);
@@ -530,7 +561,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
if (exportedPath != null) {
final file = File(exportedPath);
final fileName = file.path.split(Platform.pathSeparator).last;
await Share.shareXFiles(
[XFile(file.path)],
subject: 'LBJ Console Data',
@@ -735,7 +766,8 @@ class _SettingsScreenState extends State<SettingsScreen> {
if (snapshot.hasData) {
return Text(snapshot.data!, style: AppTheme.bodyMedium);
} else {
return const Text('v0.1.3-flutter', style: AppTheme.bodyMedium);
return const Text('v0.1.3-flutter',
style: AppTheme.bodyMedium);
}
},
),

View File

@@ -0,0 +1,211 @@
import 'dart:async';
import 'dart:io';
import 'dart:ui';
import 'package:flutter_background_service/flutter_background_service.dart';
import 'package:flutter_background_service_android/flutter_background_service_android.dart';
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
import 'package:lbjconsole/services/ble_service.dart';
const String _notificationChannelId = 'lbj_console_channel';
const String _notificationChannelName = 'LBJ Console 后台服务';
const String _notificationChannelDescription = '保持蓝牙连接稳定';
const int _notificationId = 114514;
class BackgroundService {
static final FlutterBackgroundService _service = FlutterBackgroundService();
static bool _isInitialized = false;
static Future<void> initialize() async {
if (_isInitialized) return;
final service = FlutterBackgroundService();
final flutterLocalNotificationsPlugin = FlutterLocalNotificationsPlugin();
if (Platform.isAndroid) {
const AndroidNotificationChannel channel = AndroidNotificationChannel(
_notificationChannelId,
_notificationChannelName,
description: _notificationChannelDescription,
importance: Importance.low,
enableLights: false,
enableVibration: false,
playSound: false,
);
await flutterLocalNotificationsPlugin.resolvePlatformSpecificImplementation<
AndroidFlutterLocalNotificationsPlugin>()?.createNotificationChannel(channel);
}
await service.configure(
androidConfiguration: AndroidConfiguration(
onStart: _onStart,
autoStart: true,
isForegroundMode: true,
notificationChannelId: _notificationChannelId,
initialNotificationTitle: 'LBJ Console',
initialNotificationContent: '蓝牙连接监控中',
foregroundServiceNotificationId: _notificationId,
),
iosConfiguration: IosConfiguration(
autoStart: true,
onForeground: _onStart,
onBackground: _onIosBackground,
),
);
_isInitialized = true;
}
@pragma('vm:entry-point')
static void _onStart(ServiceInstance service) async {
DartPluginRegistrant.ensureInitialized();
if (service is AndroidServiceInstance) {
service.on('setAsForeground').listen((event) {
service.setAsForegroundService();
});
service.on('setAsBackground').listen((event) {
service.setAsBackgroundService();
});
}
service.on('stopService').listen((event) {
service.stopSelf();
});
BLEService().initialize();
if (service is AndroidServiceInstance) {
await Future.delayed(const Duration(seconds: 1));
if (await service.isForegroundService()) {
final flutterLocalNotificationsPlugin = FlutterLocalNotificationsPlugin();
try {
const AndroidNotificationChannel channel = AndroidNotificationChannel(
_notificationChannelId,
_notificationChannelName,
description: _notificationChannelDescription,
importance: Importance.low,
enableLights: false,
enableVibration: false,
playSound: false,
);
await flutterLocalNotificationsPlugin.resolvePlatformSpecificImplementation<
AndroidFlutterLocalNotificationsPlugin>()?.createNotificationChannel(channel);
await flutterLocalNotificationsPlugin.show(
_notificationId,
'LBJ Console',
'蓝牙连接监控中',
NotificationDetails(
android: AndroidNotificationDetails(
_notificationChannelId,
_notificationChannelName,
channelDescription: _notificationChannelDescription,
icon: '@mipmap/ic_launcher',
ongoing: true,
autoCancel: false,
importance: Importance.low,
priority: Priority.low,
enableLights: false,
enableVibration: false,
playSound: false,
onlyAlertOnce: true,
setAsGroupSummary: false,
groupKey: 'lbj_console_group',
visibility: NotificationVisibility.public,
category: AndroidNotificationCategory.service,
),
),
);
print('前台服务通知显示成功');
} catch (e) {
print('前台服务通知显示失败: $e');
}
}
}
Timer.periodic(const Duration(seconds: 30), (timer) async {
if (service is AndroidServiceInstance) {
if (await service.isForegroundService()) {
try {
final bleService = BLEService();
final isConnected = bleService.isConnected;
final deviceStatus = bleService.deviceStatus;
final flutterLocalNotificationsPlugin = FlutterLocalNotificationsPlugin();
await flutterLocalNotificationsPlugin.show(
_notificationId,
'LBJ Console',
isConnected ? '蓝牙已连接 - $deviceStatus' : '蓝牙未连接 - 自动重连中',
NotificationDetails(
android: AndroidNotificationDetails(
_notificationChannelId,
_notificationChannelName,
channelDescription: _notificationChannelDescription,
icon: '@mipmap/ic_launcher',
ongoing: true,
autoCancel: false,
importance: Importance.low,
priority: Priority.low,
enableLights: false,
enableVibration: false,
playSound: false,
onlyAlertOnce: true,
setAsGroupSummary: false,
groupKey: 'lbj_console_group',
visibility: NotificationVisibility.public,
category: AndroidNotificationCategory.service,
),
),
);
} catch (e) {
print('前台服务通知更新失败: $e');
}
}
}
});
}
@pragma('vm:entry-point')
static Future<bool> _onIosBackground(ServiceInstance service) async {
return true;
}
static Future<void> startService() async {
await initialize();
final service = FlutterBackgroundService();
if (Platform.isAndroid) {
final isRunning = await service.isRunning();
if (!isRunning) {
service.startService();
}
} else if (Platform.isIOS) {
service.startService();
}
}
static Future<void> stopService() async {
final service = FlutterBackgroundService();
service.invoke('stopService');
}
static Future<bool> isRunning() async {
final service = FlutterBackgroundService();
return await service.isRunning();
}
static void setForegroundMode(bool isForeground) {
final service = FlutterBackgroundService();
if (isForeground) {
service.invoke('setAsForeground');
} else {
service.invoke('setAsBackground');
}
}
}

View File

@@ -27,14 +27,19 @@ class BLEService {
StreamController<TrainRecord>.broadcast();
final StreamController<bool> _connectionController =
StreamController<bool>.broadcast();
final StreamController<DateTime?> _lastReceivedTimeController =
StreamController<DateTime?>.broadcast();
Stream<String> get statusStream => _statusController.stream;
Stream<TrainRecord> get dataStream => _dataController.stream;
Stream<bool> get connectionStream => _connectionController.stream;
Stream<DateTime?> 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();
}
}

View File

@@ -13,7 +13,7 @@ class DatabaseService {
DatabaseService._internal();
static const String _databaseName = 'train_database';
static const _databaseVersion = 1;
static const _databaseVersion = 2;
static const String trainRecordsTable = 'train_records';
static const String appSettingsTable = 'app_settings';
@@ -34,9 +34,17 @@ class DatabaseService {
path,
version: _databaseVersion,
onCreate: _onCreate,
onUpgrade: _onUpgrade,
);
}
Future<void> _onUpgrade(Database db, int oldVersion, int newVersion) async {
if (oldVersion < 2) {
await db.execute(
'ALTER TABLE $appSettingsTable ADD COLUMN hideTimeOnlyRecords INTEGER NOT NULL DEFAULT 0');
}
}
Future<void> _onCreate(Database db, int version) async {
await db.execute('''
CREATE TABLE IF NOT EXISTS $trainRecordsTable (
@@ -79,6 +87,7 @@ class DatabaseService {
backgroundServiceEnabled INTEGER NOT NULL DEFAULT 0,
notificationEnabled INTEGER NOT NULL DEFAULT 0,
mergeRecordsEnabled INTEGER NOT NULL DEFAULT 0,
hideTimeOnlyRecords INTEGER NOT NULL DEFAULT 0,
groupBy TEXT NOT NULL DEFAULT 'trainAndLoco',
timeWindow TEXT NOT NULL DEFAULT 'unlimited'
)
@@ -102,6 +111,7 @@ class DatabaseService {
'backgroundServiceEnabled': 0,
'notificationEnabled': 0,
'mergeRecordsEnabled': 0,
'hideTimeOnlyRecords': 0,
'groupBy': 'trainAndLoco',
'timeWindow': 'unlimited',
});

View File

@@ -0,0 +1,113 @@
import 'dart:convert';
import 'package:sqflite/sqflite.dart';
import 'package:lbjconsole/models/map_state.dart';
import 'database_service.dart';
class MapStateService {
static final MapStateService instance = MapStateService._internal();
factory MapStateService() => instance;
MapStateService._internal();
static const String _tableName = 'record_map_states';
final Map<String, MapState> _memoryCache = {};
Future<void> _ensureTableExists() async {
final db = await DatabaseService.instance.database;
await db.execute('''
CREATE TABLE IF NOT EXISTS $_tableName (
key TEXT PRIMARY KEY,
state TEXT NOT NULL,
updated_at INTEGER NOT NULL
)
''');
}
String getSingleRecordMapKey(String recordId) {
return "${recordId}_record_map";
}
String getMergedRecordMapKey(String groupKey) {
return "${groupKey}_group_map";
}
Future<void> saveMapState(String key, MapState state) async {
try {
_memoryCache[key] = state;
final db = await DatabaseService.instance.database;
await _ensureTableExists();
await db.insert(
_tableName,
{
'key': key,
'state': jsonEncode(state.toJson()),
'updated_at': DateTime.now().millisecondsSinceEpoch,
},
conflictAlgorithm: ConflictAlgorithm.replace,
);
} catch (e) {
print('保存地图状态失败: $e');
}
}
Future<MapState?> getMapState(String key) async {
if (_memoryCache.containsKey(key)) {
return _memoryCache[key];
}
try {
final db = await DatabaseService.instance.database;
await _ensureTableExists();
final result = await db.query(
_tableName,
where: 'key = ?',
whereArgs: [key],
limit: 1,
);
if (result.isNotEmpty) {
final stateJson = jsonDecode(result.first['state'] as String);
final state = MapState.fromJson(stateJson);
_memoryCache[key] = state;
return state;
}
} catch (e) {
print('读取地图状态失败: $e');
}
return null;
}
Future<void> deleteMapState(String key) async {
_memoryCache.remove(key);
try {
final db = await DatabaseService.instance.database;
await db.delete(
_tableName,
where: 'key = ?',
whereArgs: [key],
);
} catch (e) {
print('删除地图状态失败: $e');
}
}
Future<void> clearAllMapStates() async {
_memoryCache.clear();
try {
final db = await DatabaseService.instance.database;
await db.delete(_tableName);
} catch (e) {
print('清空地图状态失败: $e');
}
}
void clearMemoryCache() {
_memoryCache.clear();
}
}

View File

@@ -5,7 +5,7 @@ class MergeService {
static String? _generateGroupKey(TrainRecord record, GroupBy groupBy) {
final train = record.train.trim();
final loco = record.loco.trim();
final hasTrain = train.isNotEmpty && train != "<NUL>";
final hasTrain = train.isNotEmpty && train != "<NUL>" && !train.contains("-----");
final hasLoco = loco.isNotEmpty && loco != "<NUL>";
switch (groupBy) {

View File

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

View File

@@ -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

View File

@@ -278,6 +278,38 @@ packages:
description: flutter
source: sdk
version: "0.0.0"
flutter_background_service:
dependency: "direct main"
description:
name: flutter_background_service
sha256: "70a1c185b1fa1a44f8f14ecd6c86f6e50366e3562f00b2fa5a54df39b3324d3d"
url: "https://pub.flutter-io.cn"
source: hosted
version: "5.1.0"
flutter_background_service_android:
dependency: transitive
description:
name: flutter_background_service_android
sha256: ca0793d4cd19f1e194a130918401a3d0b1076c81236f7273458ae96987944a87
url: "https://pub.flutter-io.cn"
source: hosted
version: "6.3.1"
flutter_background_service_ios:
dependency: transitive
description:
name: flutter_background_service_ios
sha256: "6037ffd45c4d019dab0975c7feb1d31012dd697e25edc05505a4a9b0c7dc9fba"
url: "https://pub.flutter-io.cn"
source: hosted
version: "5.0.3"
flutter_background_service_platform_interface:
dependency: transitive
description:
name: flutter_background_service_platform_interface
sha256: ca74aa95789a8304f4d3f57f07ba404faa86bed6e415f83e8edea6ad8b904a41
url: "https://pub.flutter-io.cn"
source: hosted
version: "5.1.2"
flutter_blue_plus:
dependency: "direct main"
description:
@@ -888,6 +920,14 @@ packages:
url: "https://pub.flutter-io.cn"
source: hosted
version: "0.28.0"
scrollview_observer:
dependency: "direct main"
description:
name: scrollview_observer
sha256: c2f713509f18f88f637b2084b47a90c91fb1ef066d5d82d2cf3194d8509dc6ab
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.26.2"
share_plus:
dependency: "direct main"
description:

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
# 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.6-flutter
version: 0.3.0-flutter+30
environment:
sdk: ^3.5.4
@@ -52,6 +52,8 @@ dependencies:
file_picker: ^8.1.2
package_info_plus: ^8.1.2
msix: ^3.16.12
flutter_background_service: ^5.1.0
scrollview_observer: ^1.20.0
dev_dependencies:
flutter_test: