Compare commits
5 Commits
v0.1.5-flu
...
v0.2.0-flu
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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_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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
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;
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,14 @@
|
||||
import 'dart:math' as math;
|
||||
import 'dart:isolate';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_map/flutter_map.dart';
|
||||
import 'package:latlong2/latlong.dart';
|
||||
import 'package:lbjconsole/models/merged_record.dart';
|
||||
import 'package:lbjconsole/services/database_service.dart';
|
||||
import 'package:lbjconsole/models/train_record.dart';
|
||||
import 'package:lbjconsole/services/merge_service.dart';
|
||||
import '../models/merged_record.dart';
|
||||
import '../services/database_service.dart';
|
||||
import '../models/train_record.dart';
|
||||
import '../services/merge_service.dart';
|
||||
import '../models/map_state.dart';
|
||||
import '../services/map_state_service.dart';
|
||||
|
||||
class HistoryScreen extends StatefulWidget {
|
||||
final Function(bool isEditing) onEditModeChanged;
|
||||
@@ -29,6 +33,10 @@ class HistoryScreenState extends State<HistoryScreen> {
|
||||
final ScrollController _scrollController = ScrollController();
|
||||
bool _isAtTop = true;
|
||||
MergeSettings _mergeSettings = MergeSettings();
|
||||
double _itemHeightCache = 0.0;
|
||||
|
||||
final Map<String, double> _mapOptimalZoom = {};
|
||||
final Map<String, bool> _mapCalculating = {};
|
||||
|
||||
int getSelectedCount() => _selectedRecords.length;
|
||||
Set<String> getSelectedRecordIds() => _selectedRecords;
|
||||
@@ -48,7 +56,6 @@ class HistoryScreenState extends State<HistoryScreen> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
loadRecords();
|
||||
_scrollController.addListener(() {
|
||||
if (_scrollController.position.atEdge) {
|
||||
if (_scrollController.position.pixels == 0) {
|
||||
@@ -58,6 +65,9 @@ class HistoryScreenState extends State<HistoryScreen> {
|
||||
if (_isAtTop) setState(() => _isAtTop = false);
|
||||
}
|
||||
});
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted) loadRecords();
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -67,7 +77,6 @@ class HistoryScreenState extends State<HistoryScreen> {
|
||||
}
|
||||
|
||||
Future<void> loadRecords({bool scrollToTop = true}) async {
|
||||
if (mounted) setState(() => _isLoading = true);
|
||||
try {
|
||||
final allRecords = await DatabaseService.instance.getAllRecords();
|
||||
final settingsMap = await DatabaseService.instance.getAllSettings() ?? {};
|
||||
@@ -75,15 +84,22 @@ class HistoryScreenState extends State<HistoryScreen> {
|
||||
final items = MergeService.getMixedList(allRecords, _mergeSettings);
|
||||
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_displayItems.clear();
|
||||
_displayItems.addAll(items);
|
||||
_isLoading = false;
|
||||
});
|
||||
if (scrollToTop && (_isAtTop) && _scrollController.hasClients) {
|
||||
_scrollController.animateTo(0.0,
|
||||
duration: const Duration(milliseconds: 300),
|
||||
curve: Curves.easeOut);
|
||||
final hasDataChanged = _hasDataChanged(items);
|
||||
|
||||
if (hasDataChanged) {
|
||||
setState(() {
|
||||
_displayItems.clear();
|
||||
_displayItems.addAll(items);
|
||||
_isLoading = false;
|
||||
});
|
||||
|
||||
if (scrollToTop && _isAtTop && _scrollController.hasClients) {
|
||||
_scrollController.jumpTo(0.0);
|
||||
}
|
||||
} else {
|
||||
if (_isLoading) {
|
||||
setState(() => _isLoading = false);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
@@ -91,9 +107,83 @@ class HistoryScreenState extends State<HistoryScreen> {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> addNewRecord(TrainRecord newRecord) async {
|
||||
try {
|
||||
final settingsMap = await DatabaseService.instance.getAllSettings() ?? {};
|
||||
_mergeSettings = MergeSettings.fromMap(settingsMap);
|
||||
|
||||
final isNewRecord = !_displayItems.any((item) {
|
||||
if (item is TrainRecord) {
|
||||
return item.uniqueId == newRecord.uniqueId;
|
||||
} else if (item is MergedTrainRecord) {
|
||||
return item.records.any((r) => r.uniqueId == newRecord.uniqueId);
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
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);
|
||||
|
||||
setState(() {
|
||||
_displayItems.clear();
|
||||
_displayItems.addAll(items);
|
||||
});
|
||||
|
||||
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) {}
|
||||
}
|
||||
|
||||
double _getEstimatedItemHeight() {
|
||||
if (_itemHeightCache > 0) {
|
||||
return _itemHeightCache;
|
||||
}
|
||||
return 85.0;
|
||||
}
|
||||
|
||||
bool _hasDataChanged(List<Object> newItems) {
|
||||
if (_displayItems.length != newItems.length) return true;
|
||||
|
||||
for (int i = 0; i < _displayItems.length; i++) {
|
||||
final oldItem = _displayItems[i];
|
||||
final newItem = newItems[i];
|
||||
|
||||
if (oldItem.runtimeType != newItem.runtimeType) return true;
|
||||
|
||||
if (oldItem is TrainRecord && newItem is TrainRecord) {
|
||||
if (oldItem.uniqueId != newItem.uniqueId) return true;
|
||||
} else if (oldItem is MergedTrainRecord && newItem is MergedTrainRecord) {
|
||||
if (oldItem.groupKey != newItem.groupKey) return true;
|
||||
if (oldItem.records.length != newItem.records.length) return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (_isLoading) {
|
||||
if (_isLoading && _displayItems.isEmpty) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
if (_displayItems.isEmpty) {
|
||||
@@ -151,8 +241,19 @@ class HistoryScreenState extends State<HistoryScreen> {
|
||||
widget.onSelectionChanged();
|
||||
});
|
||||
} else {
|
||||
setState(
|
||||
() => _expandedStates[mergedRecord.groupKey] = !isExpanded);
|
||||
if (isExpanded) {
|
||||
final mapId =
|
||||
mergedRecord.records.map((r) => r.uniqueId).join('_');
|
||||
setState(() {
|
||||
_expandedStates[mergedRecord.groupKey] = false;
|
||||
_mapOptimalZoom.remove(mapId);
|
||||
_mapCalculating.remove(mapId);
|
||||
});
|
||||
} else {
|
||||
setState(() {
|
||||
_expandedStates[mergedRecord.groupKey] = true;
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
onLongPress: () {
|
||||
@@ -181,7 +282,7 @@ class HistoryScreenState extends State<HistoryScreen> {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_buildExpandedMapForAll(mergedRecord.records),
|
||||
_buildExpandedMapForAll(mergedRecord.records, mergedRecord.groupKey),
|
||||
const Divider(color: Colors.white24, height: 24),
|
||||
...mergedRecord.records.map((record) => _buildSubRecordItem(
|
||||
record, mergedRecord.latestRecord, _mergeSettings.groupBy)),
|
||||
@@ -256,6 +357,72 @@ class HistoryScreenState extends State<HistoryScreen> {
|
||||
}
|
||||
}
|
||||
|
||||
double _calculateOptimalZoom(List<LatLng> positions,
|
||||
{double containerWidth = 400, double containerHeight = 220}) {
|
||||
if (positions.length == 1) return 17.0;
|
||||
|
||||
double minLat = positions[0].latitude;
|
||||
double maxLat = positions[0].latitude;
|
||||
double minLng = positions[0].longitude;
|
||||
double maxLng = positions[0].longitude;
|
||||
|
||||
for (final pos in positions) {
|
||||
minLat = math.min(minLat, pos.latitude);
|
||||
maxLat = math.max(maxLat, pos.latitude);
|
||||
minLng = math.min(minLng, pos.longitude);
|
||||
maxLng = math.max(maxLng, pos.longitude);
|
||||
}
|
||||
|
||||
double latToY(double lat) {
|
||||
final latRad = lat * math.pi / 180.0;
|
||||
return math.log(math.tan(latRad) + 1.0 / math.cos(latRad));
|
||||
}
|
||||
|
||||
double lngToX(double lng) {
|
||||
return lng * math.pi / 180.0;
|
||||
}
|
||||
|
||||
final minX = lngToX(minLng);
|
||||
final maxX = lngToX(maxLng);
|
||||
final minY = latToY(minLat);
|
||||
final maxY = latToY(maxLat);
|
||||
|
||||
const worldSize = 2.0 * math.pi;
|
||||
|
||||
final widthWorld = (maxX - minX) / worldSize;
|
||||
final heightWorld = (maxY - minY) / worldSize;
|
||||
|
||||
const paddingRatio = 0.8;
|
||||
|
||||
final widthZoom =
|
||||
math.log((containerWidth * paddingRatio) / (widthWorld * 256.0)) /
|
||||
math.log(2.0);
|
||||
final heightZoom =
|
||||
math.log((containerHeight * paddingRatio) / (heightWorld * 256.0)) /
|
||||
math.log(2.0);
|
||||
|
||||
final optimalZoom = math.min(widthZoom, heightZoom);
|
||||
|
||||
return math.max(1.0, math.min(20.0, optimalZoom));
|
||||
}
|
||||
|
||||
double _calculateDistance(LatLng pos1, LatLng pos2) {
|
||||
const earthRadius = 6371000;
|
||||
final lat1 = pos1.latitude * math.pi / 180;
|
||||
final lat2 = pos2.latitude * math.pi / 180;
|
||||
final deltaLat = (pos2.latitude - pos1.latitude) * math.pi / 180;
|
||||
final deltaLng = (pos2.longitude - pos1.longitude) * math.pi / 180;
|
||||
|
||||
final a = math.sin(deltaLat / 2) * math.sin(deltaLat / 2) +
|
||||
math.cos(lat1) *
|
||||
math.cos(lat2) *
|
||||
math.sin(deltaLng / 2) *
|
||||
math.sin(deltaLng / 2);
|
||||
final c = 2 * math.atan2(math.sqrt(a), math.sqrt(1 - a));
|
||||
|
||||
return earthRadius * c;
|
||||
}
|
||||
|
||||
String _getLocationInfo(TrainRecord record) {
|
||||
List<String> parts = [];
|
||||
if (record.route.isNotEmpty && record.route != "<NUL>")
|
||||
@@ -263,19 +430,59 @@ class HistoryScreenState extends State<HistoryScreen> {
|
||||
if (record.direction != 0) parts.add(record.direction == 1 ? "下" : "上");
|
||||
if (record.position.isNotEmpty && record.position != "<NUL>") {
|
||||
final position = record.position;
|
||||
final cleanPosition = position.endsWith('.') ? position.substring(0, position.length - 1) : position;
|
||||
final cleanPosition = position.endsWith('.')
|
||||
? position.substring(0, position.length - 1)
|
||||
: position;
|
||||
parts.add("${cleanPosition}K");
|
||||
}
|
||||
return parts.join(' ');
|
||||
}
|
||||
|
||||
Widget _buildExpandedMapForAll(List<TrainRecord> records) {
|
||||
Widget _buildExpandedMapForAll(List<TrainRecord> records, String groupKey) {
|
||||
final positions = records
|
||||
.map((record) => _parsePosition(record.positionInfo))
|
||||
.whereType<LatLng>()
|
||||
.toList();
|
||||
if (positions.isEmpty) return const SizedBox.shrink();
|
||||
|
||||
final mapId = records.map((r) => r.uniqueId).join('_');
|
||||
final bounds = LatLngBounds.fromPoints(positions);
|
||||
|
||||
if (!_mapOptimalZoom.containsKey(mapId) &&
|
||||
!(_mapCalculating[mapId] ?? false)) {
|
||||
_mapCalculating[mapId] = true;
|
||||
|
||||
_calculateOptimalZoomAsync(positions,
|
||||
containerWidth: 400, containerHeight: 220)
|
||||
.then((optimalZoom) {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_mapOptimalZoom[mapId] = optimalZoom;
|
||||
_mapCalculating[mapId] = false;
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (!_mapOptimalZoom.containsKey(mapId)) {
|
||||
return const Column(
|
||||
children: [
|
||||
SizedBox(height: 8),
|
||||
SizedBox(
|
||||
height: 228,
|
||||
child: Center(
|
||||
child: CircularProgressIndicator(
|
||||
color: Colors.blue,
|
||||
strokeWidth: 2,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
final zoomLevel = _mapOptimalZoom[mapId]!;
|
||||
|
||||
return Column(children: [
|
||||
const SizedBox(height: 8),
|
||||
Container(
|
||||
@@ -283,42 +490,31 @@ class HistoryScreenState extends State<HistoryScreen> {
|
||||
margin: const EdgeInsets.symmetric(vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(8), color: Colors.grey[900]),
|
||||
child: FlutterMap(
|
||||
options: MapOptions(
|
||||
initialCenter: bounds.center,
|
||||
initialZoom: 10,
|
||||
minZoom: 5,
|
||||
maxZoom: 18,
|
||||
cameraConstraint: CameraConstraint.contain(bounds: bounds)),
|
||||
children: [
|
||||
TileLayer(
|
||||
urlTemplate:
|
||||
'https://tile.openstreetmap.org/{z}/{x}/{y}.png',
|
||||
userAgentPackageName: 'org.noxylva.lbjconsole'),
|
||||
MarkerLayer(
|
||||
markers: positions
|
||||
.map((pos) => Marker(
|
||||
point: pos,
|
||||
width: 40,
|
||||
height: 40,
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.red.withOpacity(0.8),
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(
|
||||
color: Colors.white, width: 2)),
|
||||
child: const Icon(Icons.train,
|
||||
color: Colors.white, size: 20))))
|
||||
.toList())
|
||||
]))
|
||||
child: _DelayedMultiMarkerMap(
|
||||
key: ValueKey('multi_map_${mapId}_$zoomLevel'),
|
||||
positions: positions,
|
||||
center: bounds.center,
|
||||
zoom: zoomLevel,
|
||||
groupKey: groupKey,
|
||||
))
|
||||
]);
|
||||
}
|
||||
|
||||
double _getDefaultZoom(List<LatLng> positions) {
|
||||
if (positions.length == 1) return 15.0;
|
||||
if (positions.length < 10) return 12.0;
|
||||
return 10.0;
|
||||
}
|
||||
|
||||
Widget _buildRecordCard(TrainRecord record, {bool isSubCard = false}) {
|
||||
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),
|
||||
@@ -344,7 +540,17 @@ class HistoryScreenState extends State<HistoryScreen> {
|
||||
widget.onSelectionChanged();
|
||||
});
|
||||
} else if (!isSubCard) {
|
||||
setState(() => _expandedStates[record.uniqueId] = !isExpanded);
|
||||
if (isExpanded) {
|
||||
setState(() {
|
||||
_expandedStates[record.uniqueId] = false;
|
||||
_mapOptimalZoom.remove(record.uniqueId);
|
||||
_mapCalculating.remove(record.uniqueId);
|
||||
});
|
||||
} else {
|
||||
setState(() {
|
||||
_expandedStates[record.uniqueId] = true;
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
onLongPress: () {
|
||||
@@ -364,6 +570,20 @@ class HistoryScreenState extends State<HistoryScreen> {
|
||||
_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}) {
|
||||
@@ -481,7 +701,8 @@ class HistoryScreenState extends State<HistoryScreen> {
|
||||
if (isValidRoute && isValidPosition) const SizedBox(width: 4),
|
||||
if (isValidPosition)
|
||||
Flexible(
|
||||
child: Text("${position.trim().endsWith('.') ? position.trim().substring(0, position.trim().length - 1) : position.trim()}K",
|
||||
child: Text(
|
||||
"${position.trim().endsWith('.') ? position.trim().substring(0, position.trim().length - 1) : position.trim()}K",
|
||||
style:
|
||||
const TextStyle(fontSize: 16, color: Colors.white),
|
||||
overflow: TextOverflow.ellipsis))
|
||||
@@ -495,40 +716,60 @@ class HistoryScreenState extends State<HistoryScreen> {
|
||||
|
||||
Widget _buildExpandedContent(TrainRecord record) {
|
||||
final position = _parsePosition(record.positionInfo);
|
||||
return Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
if (position != null)
|
||||
Column(children: [
|
||||
const SizedBox(height: 8),
|
||||
Container(
|
||||
height: 220,
|
||||
margin: const EdgeInsets.symmetric(vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
color: Colors.grey[900]),
|
||||
child: FlutterMap(
|
||||
options:
|
||||
MapOptions(initialCenter: position, initialZoom: 15.0),
|
||||
children: [
|
||||
TileLayer(
|
||||
urlTemplate:
|
||||
'https://tile.openstreetmap.org/{z}/{x}/{y}.png',
|
||||
userAgentPackageName: 'org.noxylva.lbjconsole'),
|
||||
MarkerLayer(markers: [
|
||||
Marker(
|
||||
point: position,
|
||||
width: 40,
|
||||
height: 40,
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.red,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
border: Border.all(
|
||||
color: Colors.white, width: 2)),
|
||||
child: const Icon(Icons.train,
|
||||
color: Colors.white, size: 20)))
|
||||
])
|
||||
]))
|
||||
])
|
||||
final mapId = record.uniqueId;
|
||||
|
||||
if (position == null) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
if (!_mapOptimalZoom.containsKey(mapId) &&
|
||||
!(_mapCalculating[mapId] ?? false)) {
|
||||
_mapCalculating[mapId] = true;
|
||||
|
||||
_calculateOptimalZoomAsync([position],
|
||||
containerWidth: 400, containerHeight: 220)
|
||||
.then((optimalZoom) {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_mapOptimalZoom[mapId] = optimalZoom;
|
||||
_mapCalculating[mapId] = false;
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (!_mapOptimalZoom.containsKey(mapId)) {
|
||||
return const Column(
|
||||
children: [
|
||||
SizedBox(height: 8),
|
||||
SizedBox(
|
||||
height: 228,
|
||||
child: Center(
|
||||
child: CircularProgressIndicator(
|
||||
color: Colors.blue,
|
||||
strokeWidth: 2,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
final zoomLevel = _mapOptimalZoom[mapId]!;
|
||||
|
||||
return Column(children: [
|
||||
const SizedBox(height: 8),
|
||||
Container(
|
||||
height: 220,
|
||||
margin: const EdgeInsets.symmetric(vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(8), color: Colors.grey[900]),
|
||||
child: _DelayedMapWithMarker(
|
||||
key: ValueKey('map_${mapId}_$zoomLevel'),
|
||||
position: position,
|
||||
zoom: zoomLevel,
|
||||
recordId: record.uniqueId,
|
||||
))
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -566,4 +807,325 @@ class HistoryScreenState extends State<HistoryScreen> {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
Future<_BoundaryBox> _calculateBoundaryBoxParallel(
|
||||
List<LatLng> positions) async {
|
||||
if (positions.length < 100) {
|
||||
return _calculateBoundaryBoxIsolate(positions);
|
||||
}
|
||||
|
||||
final chunkSize = (positions.length / 4).ceil();
|
||||
final chunks = <List<LatLng>>[];
|
||||
|
||||
for (int i = 0; i < positions.length; i += chunkSize) {
|
||||
final end = math.min(i + chunkSize, positions.length);
|
||||
chunks.add(positions.sublist(i, end));
|
||||
}
|
||||
|
||||
final results = await Future.wait(chunks.map(
|
||||
(chunk) => Isolate.run(() => _calculateBoundaryBoxIsolate(chunk))));
|
||||
|
||||
double minLat = results[0].minLat;
|
||||
double maxLat = results[0].maxLat;
|
||||
double minLng = results[0].minLng;
|
||||
double maxLng = results[0].maxLng;
|
||||
|
||||
for (final box in results.skip(1)) {
|
||||
minLat = math.min(minLat, box.minLat);
|
||||
maxLat = math.max(maxLat, box.maxLat);
|
||||
minLng = math.min(minLng, box.minLng);
|
||||
maxLng = math.max(maxLng, box.maxLng);
|
||||
}
|
||||
|
||||
return _BoundaryBox(minLat, maxLat, minLng, maxLng);
|
||||
}
|
||||
|
||||
Future<double> _calculateOptimalZoomAsync(List<LatLng> positions,
|
||||
{required double containerWidth, required double containerHeight}) async {
|
||||
if (positions.length == 1) return 17.0;
|
||||
|
||||
final boundaryBox = await _calculateBoundaryBoxParallel(positions);
|
||||
|
||||
double latToY(double lat) {
|
||||
final latRad = lat * math.pi / 180.0;
|
||||
return math.log(math.tan(latRad) + 1.0 / math.cos(latRad));
|
||||
}
|
||||
|
||||
double lngToX(double lng) {
|
||||
return lng * math.pi / 180.0;
|
||||
}
|
||||
|
||||
final minX = lngToX(boundaryBox.minLng);
|
||||
final maxX = lngToX(boundaryBox.maxLng);
|
||||
final minY = latToY(boundaryBox.minLat);
|
||||
final maxY = latToY(boundaryBox.maxLat);
|
||||
|
||||
const worldSize = 2.0 * math.pi;
|
||||
|
||||
final widthWorld = (maxX - minX) / worldSize;
|
||||
final heightWorld = (maxY - minY) / worldSize;
|
||||
|
||||
const paddingRatio = 0.8;
|
||||
|
||||
final widthZoom =
|
||||
math.log((containerWidth * paddingRatio) / (widthWorld * 256.0)) /
|
||||
math.log(2.0);
|
||||
final heightZoom =
|
||||
math.log((containerHeight * paddingRatio) / (heightWorld * 256.0)) /
|
||||
math.log(2.0);
|
||||
|
||||
final optimalZoom = math.min(widthZoom, heightZoom);
|
||||
|
||||
return math.max(5.0, math.min(18.0, optimalZoom));
|
||||
}
|
||||
}
|
||||
|
||||
class _BoundaryBox {
|
||||
final double minLat;
|
||||
final double maxLat;
|
||||
final double minLng;
|
||||
final double maxLng;
|
||||
|
||||
_BoundaryBox(this.minLat, this.maxLat, this.minLng, this.maxLng);
|
||||
}
|
||||
|
||||
_BoundaryBox _calculateBoundaryBoxIsolate(List<LatLng> positions) {
|
||||
double minLat = positions[0].latitude;
|
||||
double maxLat = positions[0].latitude;
|
||||
double minLng = positions[0].longitude;
|
||||
double maxLng = positions[0].longitude;
|
||||
|
||||
for (final pos in positions) {
|
||||
minLat = math.min(minLat, pos.latitude);
|
||||
maxLat = math.max(maxLat, pos.latitude);
|
||||
minLng = math.min(minLng, pos.longitude);
|
||||
maxLng = math.max(maxLng, pos.longitude);
|
||||
}
|
||||
|
||||
return _BoundaryBox(minLat, maxLat, minLng, maxLng);
|
||||
}
|
||||
|
||||
class _DelayedMapWithMarker extends StatefulWidget {
|
||||
final LatLng position;
|
||||
final double zoom;
|
||||
final String recordId;
|
||||
|
||||
const _DelayedMapWithMarker({
|
||||
Key? key,
|
||||
required this.position,
|
||||
required this.zoom,
|
||||
required this.recordId,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
State<_DelayedMapWithMarker> createState() => _DelayedMapWithMarkerState();
|
||||
}
|
||||
|
||||
class _DelayedMapWithMarkerState extends State<_DelayedMapWithMarker> {
|
||||
late final MapController _mapController;
|
||||
late final String _mapKey;
|
||||
bool _isInitializing = true;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_mapController = MapController();
|
||||
_mapKey = MapStateService.instance.getSingleRecordMapKey(widget.recordId);
|
||||
_initializeMapState();
|
||||
}
|
||||
|
||||
Future<void> _initializeMapState() async {
|
||||
final savedState = await MapStateService.instance.getMapState(_mapKey);
|
||||
if (savedState != null && mounted) {
|
||||
_mapController.move(
|
||||
LatLng(savedState.centerLat, savedState.centerLng),
|
||||
savedState.zoom,
|
||||
);
|
||||
if (savedState.bearing != 0.0) {
|
||||
_mapController.rotate(savedState.bearing);
|
||||
}
|
||||
}
|
||||
setState(() {
|
||||
_isInitializing = false;
|
||||
});
|
||||
}
|
||||
|
||||
void _onCameraMove() {
|
||||
if (_isInitializing) return;
|
||||
|
||||
final camera = _mapController.camera;
|
||||
final state = MapState(
|
||||
zoom: camera.zoom,
|
||||
centerLat: camera.center.latitude,
|
||||
centerLng: camera.center.longitude,
|
||||
bearing: camera.rotation,
|
||||
);
|
||||
|
||||
MapStateService.instance.saveMapState(_mapKey, state);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_mapController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (_isInitializing) {
|
||||
return FlutterMap(
|
||||
options: MapOptions(
|
||||
initialCenter: widget.position,
|
||||
initialZoom: widget.zoom,
|
||||
onPositionChanged: (position, hasGesture) => _onCameraMove(),
|
||||
),
|
||||
mapController: _mapController,
|
||||
children: [
|
||||
TileLayer(
|
||||
urlTemplate: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png',
|
||||
userAgentPackageName: 'org.noxylva.lbjconsole'),
|
||||
MarkerLayer(markers: [
|
||||
Marker(
|
||||
point: widget.position,
|
||||
width: 40,
|
||||
height: 40,
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.red,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
border: Border.all(color: Colors.white, width: 2)),
|
||||
child:
|
||||
const Icon(Icons.train, color: Colors.white, size: 20)))
|
||||
])
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
return FlutterMap(
|
||||
options: MapOptions(
|
||||
onPositionChanged: (position, hasGesture) => _onCameraMove(),
|
||||
),
|
||||
mapController: _mapController,
|
||||
children: [
|
||||
TileLayer(
|
||||
urlTemplate: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png',
|
||||
userAgentPackageName: 'org.noxylva.lbjconsole'),
|
||||
MarkerLayer(markers: [
|
||||
Marker(
|
||||
point: widget.position,
|
||||
width: 40,
|
||||
height: 40,
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.red,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
border: Border.all(color: Colors.white, width: 2)),
|
||||
child:
|
||||
const Icon(Icons.train, color: Colors.white, size: 20)))
|
||||
])
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _DelayedMultiMarkerMap extends StatefulWidget {
|
||||
final List<LatLng> positions;
|
||||
final LatLng center;
|
||||
final double zoom;
|
||||
final String groupKey;
|
||||
|
||||
const _DelayedMultiMarkerMap({
|
||||
Key? key,
|
||||
required this.positions,
|
||||
required this.center,
|
||||
required this.zoom,
|
||||
required this.groupKey,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
State<_DelayedMultiMarkerMap> createState() => _DelayedMultiMarkerMapState();
|
||||
}
|
||||
|
||||
class _DelayedMultiMarkerMapState extends State<_DelayedMultiMarkerMap> {
|
||||
late final MapController _mapController;
|
||||
late final String _mapKey;
|
||||
bool _isInitializing = true;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_mapController = MapController();
|
||||
_mapKey = MapStateService.instance.getMergedRecordMapKey(widget.groupKey);
|
||||
_initializeMapState();
|
||||
}
|
||||
|
||||
Future<void> _initializeMapState() async {
|
||||
final savedState = await MapStateService.instance.getMapState(_mapKey);
|
||||
if (savedState != null && mounted) {
|
||||
_mapController.move(
|
||||
LatLng(savedState.centerLat, savedState.centerLng),
|
||||
savedState.zoom,
|
||||
);
|
||||
if (savedState.bearing != 0.0) {
|
||||
_mapController.rotate(savedState.bearing);
|
||||
}
|
||||
} else if (mounted) {
|
||||
_mapController.move(widget.center, widget.zoom);
|
||||
}
|
||||
setState(() {
|
||||
_isInitializing = false;
|
||||
});
|
||||
}
|
||||
|
||||
void _onCameraMove() {
|
||||
if (_isInitializing) return;
|
||||
|
||||
final camera = _mapController.camera;
|
||||
final state = MapState(
|
||||
zoom: camera.zoom,
|
||||
centerLat: camera.center.latitude,
|
||||
centerLng: camera.center.longitude,
|
||||
bearing: camera.rotation,
|
||||
);
|
||||
|
||||
MapStateService.instance.saveMapState(_mapKey, state);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_mapController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return FlutterMap(
|
||||
options: MapOptions(
|
||||
onPositionChanged: (position, hasGesture) => _onCameraMove(),
|
||||
minZoom: 5,
|
||||
maxZoom: 18,
|
||||
),
|
||||
mapController: _mapController,
|
||||
children: [
|
||||
TileLayer(
|
||||
urlTemplate: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png',
|
||||
userAgentPackageName: 'org.noxylva.lbjconsole',
|
||||
),
|
||||
MarkerLayer(
|
||||
markers: widget.positions
|
||||
.map((pos) => Marker(
|
||||
point: pos,
|
||||
width: 40,
|
||||
height: 40,
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.red.withOpacity(0.8),
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(color: Colors.white, width: 2)),
|
||||
child: const Icon(Icons.train,
|
||||
color: Colors.white, size: 20))))
|
||||
.toList()),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
@@ -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,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import 'dart:async';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_map/flutter_map.dart';
|
||||
import 'package:latlong2/latlong.dart';
|
||||
@@ -26,8 +27,8 @@ class _MapScreenState extends State<MapScreen> {
|
||||
bool _isMapInitialized = false;
|
||||
bool _isFollowingLocation = false;
|
||||
bool _isLocationPermissionGranted = false;
|
||||
Timer? _locationTimer;
|
||||
|
||||
static const LatLng _defaultPosition = LatLng(39.9042, 116.4074);
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -35,12 +36,13 @@ class _MapScreenState extends State<MapScreen> {
|
||||
_initializeMap();
|
||||
_loadTrainRecords();
|
||||
_loadSettings();
|
||||
_requestLocationPermission();
|
||||
_startLocationUpdates();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_saveSettings();
|
||||
_locationTimer?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@@ -49,6 +51,9 @@ class _MapScreenState extends State<MapScreen> {
|
||||
Future<void> _requestLocationPermission() async {
|
||||
bool serviceEnabled = await Geolocator.isLocationServiceEnabled();
|
||||
if (!serviceEnabled) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('请开启定位服务')),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -58,6 +63,9 @@ class _MapScreenState extends State<MapScreen> {
|
||||
}
|
||||
|
||||
if (permission == LocationPermission.deniedForever) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('定位权限被拒绝,请在设置中开启')),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -78,12 +86,39 @@ class _MapScreenState extends State<MapScreen> {
|
||||
_userLocation = LatLng(position.latitude, position.longitude);
|
||||
});
|
||||
|
||||
if (!_isMapInitialized && _userLocation != null) {
|
||||
_mapController.move(_userLocation!, _currentZoom);
|
||||
}
|
||||
} catch (e) {}
|
||||
} 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 {
|
||||
try {
|
||||
final settings = await DatabaseService.instance.getAllSettings();
|
||||
@@ -159,13 +194,12 @@ class _MapScreenState extends State<MapScreen> {
|
||||
} else if (_lastTrainLocation != null) {
|
||||
targetLocation = _lastTrainLocation;
|
||||
} else {
|
||||
targetLocation = _defaultPosition;
|
||||
_isMapInitialized = true;
|
||||
return;
|
||||
}
|
||||
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
_centerMap(targetLocation!, zoom: _currentZoom);
|
||||
_isMapInitialized = true;
|
||||
});
|
||||
_centerMap(targetLocation!, zoom: _currentZoom);
|
||||
_isMapInitialized = true;
|
||||
}
|
||||
|
||||
void _centerMap(LatLng location, {double? zoom}) {
|
||||
@@ -313,7 +347,7 @@ class _MapScreenState extends State<MapScreen> {
|
||||
}
|
||||
|
||||
void _centerToMyLocation() {
|
||||
_centerMap(_lastTrainLocation ?? _defaultPosition, zoom: 15.0);
|
||||
_centerMap(_lastTrainLocation ?? const LatLng(39.9042, 116.4074), zoom: 15.0);
|
||||
}
|
||||
|
||||
void _centerToLastTrain() {
|
||||
@@ -537,11 +571,12 @@ class _MapScreenState extends State<MapScreen> {
|
||||
FlutterMap(
|
||||
mapController: _mapController,
|
||||
options: MapOptions(
|
||||
initialCenter: _lastTrainLocation ?? _defaultPosition,
|
||||
initialCenter: _lastTrainLocation ?? const LatLng(39.9042, 116.4074),
|
||||
initialZoom: _currentZoom,
|
||||
initialRotation: _currentRotation,
|
||||
minZoom: 4.0,
|
||||
maxZoom: 18.0,
|
||||
|
||||
onPositionChanged: (MapCamera camera, bool hasGesture) {
|
||||
if (hasGesture) {
|
||||
setState(() {
|
||||
@@ -552,28 +587,6 @@ class _MapScreenState extends State<MapScreen> {
|
||||
_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: [
|
||||
TileLayer(
|
||||
@@ -622,10 +635,7 @@ class _MapScreenState extends State<MapScreen> {
|
||||
heroTag: 'myLocation',
|
||||
backgroundColor: const Color(0xFF1E1E1E),
|
||||
onPressed: () {
|
||||
_getCurrentLocation();
|
||||
if (_userLocation != null) {
|
||||
_centerMap(_userLocation!, zoom: 15.0);
|
||||
}
|
||||
_forceUpdateLocation();
|
||||
},
|
||||
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/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';
|
||||
|
||||
@@ -196,11 +197,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,
|
||||
),
|
||||
@@ -388,9 +395,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 +510,6 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
Future<void> _shareData() async {
|
||||
final scaffoldMessenger = ScaffoldMessenger.of(context);
|
||||
|
||||
@@ -530,7 +535,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 +740,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);
|
||||
}
|
||||
},
|
||||
),
|
||||
|
||||
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();
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -61,7 +61,7 @@ class NotificationService {
|
||||
return;
|
||||
}
|
||||
|
||||
final String title = '列车信息更新';
|
||||
final String title = '列车信息';
|
||||
final String body = _buildNotificationContent(record);
|
||||
|
||||
final AndroidNotificationDetails androidPlatformChannelSpecifics =
|
||||
|
||||
@@ -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
|
||||
|
||||
32
pubspec.lock
32
pubspec.lock
@@ -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:
|
||||
|
||||
@@ -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.5-flutter
|
||||
version: 0.2.0-flutter+20 # versionName: 0.2.0-flutter, versionCode: 3
|
||||
|
||||
environment:
|
||||
sdk: ^3.5.4
|
||||
@@ -52,6 +52,7 @@ dependencies:
|
||||
file_picker: ^8.1.2
|
||||
package_info_plus: ^8.1.2
|
||||
msix: ^3.16.12
|
||||
flutter_background_service: ^5.1.0
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
|
||||
Reference in New Issue
Block a user