Compare commits
9 Commits
v0.1.5-flu
...
v0.3.0-flu
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
64401a6ce9 | ||
|
|
72f9dfe17b | ||
|
|
bf850eed38 | ||
|
|
56689fc993 | ||
|
|
ba373f749a | ||
|
|
23ab5ec746 | ||
|
|
10825171fd | ||
|
|
1b05a6092c | ||
|
|
5141af58ac |
@@ -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"/>
|
||||||
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN"/>
|
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN"/>
|
||||||
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" android:usesPermissionFlags="neverForLocation"/>
|
<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.WAKE_LOCK"/>
|
||||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
|
<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_DATA_SYNC"/>
|
||||||
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_CONNECTED_DEVICE"/>
|
||||||
|
|
||||||
<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
|
||||||
@@ -47,6 +49,15 @@
|
|||||||
<meta-data
|
<meta-data
|
||||||
android:name="flutterEmbedding"
|
android:name="flutterEmbedding"
|
||||||
android:value="2" />
|
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>
|
</application>
|
||||||
<!-- Required to query activities that can process text, see:
|
<!-- Required to query activities that can process text, see:
|
||||||
https://developer.android.com/training/package-visibility and
|
https://developer.android.com/training/package-visibility and
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -1,14 +1,20 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
|
||||||
import 'package:lbjconsole/screens/main_screen.dart';
|
import 'package:lbjconsole/screens/main_screen.dart';
|
||||||
import 'package:lbjconsole/util/train_type_util.dart';
|
import 'package:lbjconsole/util/train_type_util.dart';
|
||||||
import 'package:lbjconsole/util/loco_info_util.dart';
|
import 'package:lbjconsole/util/loco_info_util.dart';
|
||||||
import 'package:lbjconsole/util/loco_type_util.dart';
|
import 'package:lbjconsole/util/loco_type_util.dart';
|
||||||
import 'package:lbjconsole/services/loco_type_service.dart';
|
import 'package:lbjconsole/services/loco_type_service.dart';
|
||||||
import 'package:lbjconsole/services/database_service.dart';
|
import 'package:lbjconsole/services/database_service.dart';
|
||||||
|
import 'package:lbjconsole/services/background_service.dart';
|
||||||
|
|
||||||
void main() async {
|
void main() async {
|
||||||
WidgetsFlutterBinding.ensureInitialized();
|
WidgetsFlutterBinding.ensureInitialized();
|
||||||
|
|
||||||
|
await _initializeNotifications();
|
||||||
|
|
||||||
|
await BackgroundService.initialize();
|
||||||
|
|
||||||
await Future.wait([
|
await Future.wait([
|
||||||
TrainTypeUtil.initialize(),
|
TrainTypeUtil.initialize(),
|
||||||
LocoInfoUtil.initialize(),
|
LocoInfoUtil.initialize(),
|
||||||
@@ -18,6 +24,19 @@ void main() async {
|
|||||||
runApp(const LBJReceiverApp());
|
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 {
|
class LBJReceiverApp extends StatelessWidget {
|
||||||
const LBJReceiverApp({super.key});
|
const LBJReceiverApp({super.key});
|
||||||
|
|
||||||
|
|||||||
65
lib/models/map_state.dart
Normal file
65
lib/models/map_state.dart
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -149,7 +149,7 @@ class TrainRecord {
|
|||||||
final lbjClassValue = lbjClass.trim();
|
final lbjClassValue = lbjClass.trim();
|
||||||
final trainValue = train.trim();
|
final trainValue = train.trim();
|
||||||
|
|
||||||
if (trainValue == "<NUL>") {
|
if (trainValue == "<NUL>" || trainValue.contains("-----")) {
|
||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -10,8 +10,161 @@ import 'package:lbjconsole/screens/settings_screen.dart';
|
|||||||
import 'package:lbjconsole/services/ble_service.dart';
|
import 'package:lbjconsole/services/ble_service.dart';
|
||||||
import 'package:lbjconsole/services/database_service.dart';
|
import 'package:lbjconsole/services/database_service.dart';
|
||||||
import 'package:lbjconsole/services/notification_service.dart';
|
import 'package:lbjconsole/services/notification_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});
|
||||||
|
|
||||||
@@ -27,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>();
|
||||||
@@ -39,12 +193,36 @@ class _MainScreenState extends State<MainScreen> with WidgetsBindingObserver {
|
|||||||
_bleService = BLEService();
|
_bleService = BLEService();
|
||||||
_bleService.initialize();
|
_bleService.initialize();
|
||||||
_initializeServices();
|
_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
|
@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();
|
||||||
}
|
}
|
||||||
@@ -59,14 +237,10 @@ 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) {
|
||||||
_historyScreenKey.currentState!.loadRecords(scrollToTop: true);
|
_historyScreenKey.currentState!.addNewRecord(record);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -122,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,
|
||||||
@@ -230,7 +397,7 @@ class _MainScreenState extends State<MainScreen> with WidgetsBindingObserver {
|
|||||||
selectedIndex: _currentIndex,
|
selectedIndex: _currentIndex,
|
||||||
onDestinationSelected: (index) {
|
onDestinationSelected: (index) {
|
||||||
if (_currentIndex == 2 && index == 0) {
|
if (_currentIndex == 2 && index == 0) {
|
||||||
_historyScreenKey.currentState?.loadRecords();
|
_historyScreenKey.currentState?.reloadRecords();
|
||||||
}
|
}
|
||||||
setState(() {
|
setState(() {
|
||||||
if (_isHistoryEditMode) _isHistoryEditMode = false;
|
if (_isHistoryEditMode) _isHistoryEditMode = false;
|
||||||
@@ -263,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();
|
||||||
@@ -277,6 +446,7 @@ class _PixelPerfectBluetoothDialogState
|
|||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_connectionSubscription?.cancel();
|
_connectionSubscription?.cancel();
|
||||||
|
_lastReceivedTimeSubscription?.cancel();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -306,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;
|
||||||
@@ -342,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,
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import 'dart:async';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_map/flutter_map.dart';
|
import 'package:flutter_map/flutter_map.dart';
|
||||||
import 'package:latlong2/latlong.dart';
|
import 'package:latlong2/latlong.dart';
|
||||||
@@ -26,8 +27,8 @@ class _MapScreenState extends State<MapScreen> {
|
|||||||
bool _isMapInitialized = false;
|
bool _isMapInitialized = false;
|
||||||
bool _isFollowingLocation = false;
|
bool _isFollowingLocation = false;
|
||||||
bool _isLocationPermissionGranted = false;
|
bool _isLocationPermissionGranted = false;
|
||||||
|
Timer? _locationTimer;
|
||||||
|
|
||||||
static const LatLng _defaultPosition = LatLng(39.9042, 116.4074);
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
@@ -35,12 +36,13 @@ class _MapScreenState extends State<MapScreen> {
|
|||||||
_initializeMap();
|
_initializeMap();
|
||||||
_loadTrainRecords();
|
_loadTrainRecords();
|
||||||
_loadSettings();
|
_loadSettings();
|
||||||
_requestLocationPermission();
|
_startLocationUpdates();
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_saveSettings();
|
_saveSettings();
|
||||||
|
_locationTimer?.cancel();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -49,6 +51,9 @@ class _MapScreenState extends State<MapScreen> {
|
|||||||
Future<void> _requestLocationPermission() async {
|
Future<void> _requestLocationPermission() async {
|
||||||
bool serviceEnabled = await Geolocator.isLocationServiceEnabled();
|
bool serviceEnabled = await Geolocator.isLocationServiceEnabled();
|
||||||
if (!serviceEnabled) {
|
if (!serviceEnabled) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(content: Text('请开启定位服务')),
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -58,6 +63,9 @@ class _MapScreenState extends State<MapScreen> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (permission == LocationPermission.deniedForever) {
|
if (permission == LocationPermission.deniedForever) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(content: Text('定位权限被拒绝,请在设置中开启')),
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -78,12 +86,39 @@ class _MapScreenState extends State<MapScreen> {
|
|||||||
_userLocation = LatLng(position.latitude, position.longitude);
|
_userLocation = LatLng(position.latitude, position.longitude);
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!_isMapInitialized && _userLocation != null) {
|
} catch (e) {
|
||||||
_mapController.move(_userLocation!, _currentZoom);
|
}
|
||||||
}
|
|
||||||
} catch (e) {}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _startLocationUpdates() {
|
||||||
|
_requestLocationPermission();
|
||||||
|
|
||||||
|
_locationTimer = Timer.periodic(const Duration(seconds: 30), (timer) {
|
||||||
|
if (_isLocationPermissionGranted) {
|
||||||
|
_getCurrentLocation();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _forceUpdateLocation() async {
|
||||||
|
|
||||||
|
try {
|
||||||
|
Position position = await Geolocator.getCurrentPosition(
|
||||||
|
desiredAccuracy: LocationAccuracy.best,
|
||||||
|
);
|
||||||
|
|
||||||
|
final newLocation = LatLng(position.latitude, position.longitude);
|
||||||
|
|
||||||
|
setState(() {
|
||||||
|
_userLocation = newLocation;
|
||||||
|
});
|
||||||
|
|
||||||
|
_mapController.move(newLocation, 15.0);
|
||||||
|
} catch (e) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
Future<void> _loadSettings() async {
|
Future<void> _loadSettings() async {
|
||||||
try {
|
try {
|
||||||
final settings = await DatabaseService.instance.getAllSettings();
|
final settings = await DatabaseService.instance.getAllSettings();
|
||||||
@@ -159,13 +194,12 @@ class _MapScreenState extends State<MapScreen> {
|
|||||||
} else if (_lastTrainLocation != null) {
|
} else if (_lastTrainLocation != null) {
|
||||||
targetLocation = _lastTrainLocation;
|
targetLocation = _lastTrainLocation;
|
||||||
} else {
|
} else {
|
||||||
targetLocation = _defaultPosition;
|
_isMapInitialized = true;
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
_centerMap(targetLocation!, zoom: _currentZoom);
|
||||||
_centerMap(targetLocation!, zoom: _currentZoom);
|
_isMapInitialized = true;
|
||||||
_isMapInitialized = true;
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void _centerMap(LatLng location, {double? zoom}) {
|
void _centerMap(LatLng location, {double? zoom}) {
|
||||||
@@ -313,7 +347,7 @@ class _MapScreenState extends State<MapScreen> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void _centerToMyLocation() {
|
void _centerToMyLocation() {
|
||||||
_centerMap(_lastTrainLocation ?? _defaultPosition, zoom: 15.0);
|
_centerMap(_lastTrainLocation ?? const LatLng(39.9042, 116.4074), zoom: 15.0);
|
||||||
}
|
}
|
||||||
|
|
||||||
void _centerToLastTrain() {
|
void _centerToLastTrain() {
|
||||||
@@ -537,11 +571,12 @@ class _MapScreenState extends State<MapScreen> {
|
|||||||
FlutterMap(
|
FlutterMap(
|
||||||
mapController: _mapController,
|
mapController: _mapController,
|
||||||
options: MapOptions(
|
options: MapOptions(
|
||||||
initialCenter: _lastTrainLocation ?? _defaultPosition,
|
initialCenter: _lastTrainLocation ?? const LatLng(39.9042, 116.4074),
|
||||||
initialZoom: _currentZoom,
|
initialZoom: _currentZoom,
|
||||||
initialRotation: _currentRotation,
|
initialRotation: _currentRotation,
|
||||||
minZoom: 4.0,
|
minZoom: 4.0,
|
||||||
maxZoom: 18.0,
|
maxZoom: 18.0,
|
||||||
|
|
||||||
onPositionChanged: (MapCamera camera, bool hasGesture) {
|
onPositionChanged: (MapCamera camera, bool hasGesture) {
|
||||||
if (hasGesture) {
|
if (hasGesture) {
|
||||||
setState(() {
|
setState(() {
|
||||||
@@ -552,28 +587,6 @@ class _MapScreenState extends State<MapScreen> {
|
|||||||
_saveSettings();
|
_saveSettings();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onTap: (_, point) {
|
|
||||||
for (final record in _trainRecords) {
|
|
||||||
final coords = record.getCoordinates();
|
|
||||||
final dmsCoords = _parseDmsCoordinate(record.positionInfo);
|
|
||||||
LatLng? recordPosition;
|
|
||||||
|
|
||||||
if (dmsCoords != null) {
|
|
||||||
recordPosition = dmsCoords;
|
|
||||||
} else if (coords['lat'] != 0.0 && coords['lng'] != 0.0) {
|
|
||||||
recordPosition = LatLng(coords['lat']!, coords['lng']!);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (recordPosition != null) {
|
|
||||||
final distance = const Distance()
|
|
||||||
.as(LengthUnit.Meter, recordPosition, point);
|
|
||||||
if (distance < 50) {
|
|
||||||
_showTrainDetailsDialog(record, recordPosition);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
children: [
|
children: [
|
||||||
TileLayer(
|
TileLayer(
|
||||||
@@ -622,10 +635,7 @@ class _MapScreenState extends State<MapScreen> {
|
|||||||
heroTag: 'myLocation',
|
heroTag: 'myLocation',
|
||||||
backgroundColor: const Color(0xFF1E1E1E),
|
backgroundColor: const Color(0xFF1E1E1E),
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
_getCurrentLocation();
|
_forceUpdateLocation();
|
||||||
if (_userLocation != null) {
|
|
||||||
_centerMap(_userLocation!, zoom: 15.0);
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
child: const Icon(Icons.my_location, color: Colors.white),
|
child: const Icon(Icons.my_location, color: Colors.white),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import 'dart:io';
|
|||||||
import 'package:lbjconsole/models/merged_record.dart';
|
import 'package:lbjconsole/models/merged_record.dart';
|
||||||
import 'package:lbjconsole/services/database_service.dart';
|
import 'package:lbjconsole/services/database_service.dart';
|
||||||
import 'package:lbjconsole/services/ble_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:lbjconsole/themes/app_theme.dart';
|
||||||
import 'package:url_launcher/url_launcher.dart';
|
import 'package:url_launcher/url_launcher.dart';
|
||||||
|
|
||||||
@@ -31,6 +32,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
bool _notificationsEnabled = true;
|
bool _notificationsEnabled = true;
|
||||||
int _recordCount = 0;
|
int _recordCount = 0;
|
||||||
bool _mergeRecordsEnabled = false;
|
bool _mergeRecordsEnabled = false;
|
||||||
|
bool _hideTimeOnlyRecords = false;
|
||||||
GroupBy _groupBy = GroupBy.trainAndLoco;
|
GroupBy _groupBy = GroupBy.trainAndLoco;
|
||||||
TimeWindow _timeWindow = TimeWindow.unlimited;
|
TimeWindow _timeWindow = TimeWindow.unlimited;
|
||||||
|
|
||||||
@@ -60,6 +62,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
(settingsMap['backgroundServiceEnabled'] ?? 0) == 1;
|
(settingsMap['backgroundServiceEnabled'] ?? 0) == 1;
|
||||||
_notificationsEnabled = (settingsMap['notificationEnabled'] ?? 1) == 1;
|
_notificationsEnabled = (settingsMap['notificationEnabled'] ?? 1) == 1;
|
||||||
_mergeRecordsEnabled = settings.enabled;
|
_mergeRecordsEnabled = settings.enabled;
|
||||||
|
_hideTimeOnlyRecords = (settingsMap['hideTimeOnlyRecords'] ?? 0) == 1;
|
||||||
_groupBy = settings.groupBy;
|
_groupBy = settings.groupBy;
|
||||||
_timeWindow = settings.timeWindow;
|
_timeWindow = settings.timeWindow;
|
||||||
});
|
});
|
||||||
@@ -81,6 +84,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
'backgroundServiceEnabled': _backgroundServiceEnabled ? 1 : 0,
|
'backgroundServiceEnabled': _backgroundServiceEnabled ? 1 : 0,
|
||||||
'notificationEnabled': _notificationsEnabled ? 1 : 0,
|
'notificationEnabled': _notificationsEnabled ? 1 : 0,
|
||||||
'mergeRecordsEnabled': _mergeRecordsEnabled ? 1 : 0,
|
'mergeRecordsEnabled': _mergeRecordsEnabled ? 1 : 0,
|
||||||
|
'hideTimeOnlyRecords': _hideTimeOnlyRecords ? 1 : 0,
|
||||||
'groupBy': _groupBy.name,
|
'groupBy': _groupBy.name,
|
||||||
'timeWindow': _timeWindow.name,
|
'timeWindow': _timeWindow.name,
|
||||||
});
|
});
|
||||||
@@ -196,11 +200,17 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
),
|
),
|
||||||
Switch(
|
Switch(
|
||||||
value: _backgroundServiceEnabled,
|
value: _backgroundServiceEnabled,
|
||||||
onChanged: (value) {
|
onChanged: (value) async {
|
||||||
setState(() {
|
setState(() {
|
||||||
_backgroundServiceEnabled = value;
|
_backgroundServiceEnabled = value;
|
||||||
});
|
});
|
||||||
_saveSettings();
|
await _saveSettings();
|
||||||
|
|
||||||
|
if (value) {
|
||||||
|
await BackgroundService.startService();
|
||||||
|
} else {
|
||||||
|
await BackgroundService.stopService();
|
||||||
|
}
|
||||||
},
|
},
|
||||||
activeColor: Theme.of(context).colorScheme.primary,
|
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: [
|
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),
|
||||||
@@ -503,8 +536,6 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
Future<void> _shareData() async {
|
Future<void> _shareData() async {
|
||||||
final scaffoldMessenger = ScaffoldMessenger.of(context);
|
final scaffoldMessenger = ScaffoldMessenger.of(context);
|
||||||
|
|
||||||
@@ -735,7 +766,8 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
if (snapshot.hasData) {
|
if (snapshot.hasData) {
|
||||||
return Text(snapshot.data!, style: AppTheme.bodyMedium);
|
return Text(snapshot.data!, style: AppTheme.bodyMedium);
|
||||||
} else {
|
} else {
|
||||||
return const Text('v0.1.3-flutter', style: AppTheme.bodyMedium);
|
return const Text('v0.1.3-flutter',
|
||||||
|
style: AppTheme.bodyMedium);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
|||||||
211
lib/services/background_service.dart
Normal file
211
lib/services/background_service.dart
Normal 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');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ class DatabaseService {
|
|||||||
DatabaseService._internal();
|
DatabaseService._internal();
|
||||||
|
|
||||||
static const String _databaseName = 'train_database';
|
static const String _databaseName = 'train_database';
|
||||||
static const _databaseVersion = 1;
|
static const _databaseVersion = 2;
|
||||||
|
|
||||||
static const String trainRecordsTable = 'train_records';
|
static const String trainRecordsTable = 'train_records';
|
||||||
static const String appSettingsTable = 'app_settings';
|
static const String appSettingsTable = 'app_settings';
|
||||||
@@ -34,9 +34,17 @@ class DatabaseService {
|
|||||||
path,
|
path,
|
||||||
version: _databaseVersion,
|
version: _databaseVersion,
|
||||||
onCreate: _onCreate,
|
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 {
|
Future<void> _onCreate(Database db, int version) async {
|
||||||
await db.execute('''
|
await db.execute('''
|
||||||
CREATE TABLE IF NOT EXISTS $trainRecordsTable (
|
CREATE TABLE IF NOT EXISTS $trainRecordsTable (
|
||||||
@@ -79,6 +87,7 @@ class DatabaseService {
|
|||||||
backgroundServiceEnabled INTEGER NOT NULL DEFAULT 0,
|
backgroundServiceEnabled INTEGER NOT NULL DEFAULT 0,
|
||||||
notificationEnabled INTEGER NOT NULL DEFAULT 0,
|
notificationEnabled INTEGER NOT NULL DEFAULT 0,
|
||||||
mergeRecordsEnabled INTEGER NOT NULL DEFAULT 0,
|
mergeRecordsEnabled INTEGER NOT NULL DEFAULT 0,
|
||||||
|
hideTimeOnlyRecords INTEGER NOT NULL DEFAULT 0,
|
||||||
groupBy TEXT NOT NULL DEFAULT 'trainAndLoco',
|
groupBy TEXT NOT NULL DEFAULT 'trainAndLoco',
|
||||||
timeWindow TEXT NOT NULL DEFAULT 'unlimited'
|
timeWindow TEXT NOT NULL DEFAULT 'unlimited'
|
||||||
)
|
)
|
||||||
@@ -102,6 +111,7 @@ class DatabaseService {
|
|||||||
'backgroundServiceEnabled': 0,
|
'backgroundServiceEnabled': 0,
|
||||||
'notificationEnabled': 0,
|
'notificationEnabled': 0,
|
||||||
'mergeRecordsEnabled': 0,
|
'mergeRecordsEnabled': 0,
|
||||||
|
'hideTimeOnlyRecords': 0,
|
||||||
'groupBy': 'trainAndLoco',
|
'groupBy': 'trainAndLoco',
|
||||||
'timeWindow': 'unlimited',
|
'timeWindow': 'unlimited',
|
||||||
});
|
});
|
||||||
|
|||||||
113
lib/services/map_state_service.dart
Normal file
113
lib/services/map_state_service.dart
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,7 +5,7 @@ class MergeService {
|
|||||||
static String? _generateGroupKey(TrainRecord record, GroupBy groupBy) {
|
static String? _generateGroupKey(TrainRecord record, GroupBy groupBy) {
|
||||||
final train = record.train.trim();
|
final train = record.train.trim();
|
||||||
final loco = record.loco.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>";
|
final hasLoco = loco.isNotEmpty && loco != "<NUL>";
|
||||||
|
|
||||||
switch (groupBy) {
|
switch (groupBy) {
|
||||||
|
|||||||
@@ -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 =
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
40
pubspec.lock
40
pubspec.lock
@@ -278,6 +278,38 @@ packages:
|
|||||||
description: flutter
|
description: flutter
|
||||||
source: sdk
|
source: sdk
|
||||||
version: "0.0.0"
|
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:
|
flutter_blue_plus:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
@@ -888,6 +920,14 @@ packages:
|
|||||||
url: "https://pub.flutter-io.cn"
|
url: "https://pub.flutter-io.cn"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.28.0"
|
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:
|
share_plus:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
|
|||||||
@@ -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.5-flutter
|
version: 0.3.0-flutter+30
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
sdk: ^3.5.4
|
sdk: ^3.5.4
|
||||||
@@ -52,6 +52,8 @@ dependencies:
|
|||||||
file_picker: ^8.1.2
|
file_picker: ^8.1.2
|
||||||
package_info_plus: ^8.1.2
|
package_info_plus: ^8.1.2
|
||||||
msix: ^3.16.12
|
msix: ^3.16.12
|
||||||
|
flutter_background_service: ^5.1.0
|
||||||
|
scrollview_observer: ^1.20.0
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
flutter_test:
|
flutter_test:
|
||||||
|
|||||||
Reference in New Issue
Block a user