6 Commits

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

@@ -1,12 +1,18 @@
import 'dart:math' as math;
import 'dart:isolate';
import 'dart:async';
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart';
import 'package:flutter_map/flutter_map.dart';
import 'package:latlong2/latlong.dart';
import 'package:scrollview_observer/scrollview_observer.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,8 +35,12 @@ class HistoryScreenState extends State<HistoryScreen> {
final Set<String> _selectedRecords = {};
final Map<String, bool> _expandedStates = {};
final ScrollController _scrollController = ScrollController();
final ListObserverController _observerController =
ListObserverController(controller: null)..cacheJumpIndexOffset = false;
late final ChatScrollObserver _chatObserver;
bool _isAtTop = true;
MergeSettings _mergeSettings = MergeSettings();
double _itemHeightCache = 0.0;
final Map<String, double> _mapOptimalZoom = {};
final Map<String, bool> _mapCalculating = {};
@@ -50,10 +60,17 @@ class HistoryScreenState extends State<HistoryScreen> {
});
}
Future<void> reloadRecords() async {
await loadRecords(scrollToTop: false);
}
@override
void initState() {
super.initState();
loadRecords();
_chatObserver = ChatScrollObserver(_observerController)
..toRebuildScrollViewCallback = () {
setState(() {});
};
_scrollController.addListener(() {
if (_scrollController.position.atEdge) {
if (_scrollController.position.pixels == 0) {
@@ -63,32 +80,106 @@ class HistoryScreenState extends State<HistoryScreen> {
if (_isAtTop) setState(() => _isAtTop = false);
}
});
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) loadRecords();
});
}
@override
void dispose() {
_scrollController.dispose();
_observerController.controller?.dispose();
super.dispose();
}
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() ?? {};
_mergeSettings = MergeSettings.fromMap(settingsMap);
final items = MergeService.getMixedList(allRecords, _mergeSettings);
List<TrainRecord> filteredRecords = allRecords;
if ((settingsMap['hideTimeOnlyRecords'] ?? 0) == 1) {
int hiddenCount = 0;
int shownCount = 0;
filteredRecords = allRecords.where((record) {
bool isFieldMeaningful(String field) {
if (field.isEmpty) return false;
String cleaned = field.replaceAll('<NUL>', '').trim();
if (cleaned.isEmpty) return false;
if (cleaned.runes
.every((r) => r == '*'.runes.first || r == ' '.runes.first))
return false;
return true;
}
final hasTrainNumber = isFieldMeaningful(record.fullTrainNumber) &&
!record.fullTrainNumber.contains("-----");
final hasDirection = record.direction == 1 || record.direction == 3;
final hasLocoInfo = isFieldMeaningful(record.locoType) ||
isFieldMeaningful(record.loco);
final hasRoute = isFieldMeaningful(record.route);
final hasPosition = isFieldMeaningful(record.position);
final hasSpeed =
isFieldMeaningful(record.speed) && record.speed != "NUL";
final hasPositionInfo = isFieldMeaningful(record.positionInfo);
final hasTrainType =
isFieldMeaningful(record.trainType) && record.trainType != "未知";
final hasLbjClass =
isFieldMeaningful(record.lbjClass) && record.lbjClass != "NA";
final hasTrain = isFieldMeaningful(record.train) &&
!record.train.contains("-----");
final shouldShow = hasTrainNumber ||
hasDirection ||
hasLocoInfo ||
hasRoute ||
hasPosition ||
hasSpeed ||
hasPositionInfo ||
hasTrainType ||
hasLbjClass ||
hasTrain;
if (!shouldShow) {
hiddenCount++;
} else {
shownCount++;
}
return shouldShow;
}).toList();
}
final items = MergeService.getMixedList(filteredRecords, _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) {
@@ -96,9 +187,120 @@ class HistoryScreenState extends State<HistoryScreen> {
}
}
Future<void> addNewRecord(TrainRecord newRecord) async {
try {
final settingsMap = await DatabaseService.instance.getAllSettings() ?? {};
_mergeSettings = MergeSettings.fromMap(settingsMap);
if ((settingsMap['hideTimeOnlyRecords'] ?? 0) == 1) {
bool isFieldMeaningful(String field) {
if (field.isEmpty) return false;
String cleaned = field.replaceAll('<NUL>', '').trim();
if (cleaned.isEmpty) return false;
if (cleaned.runes
.every((r) => r == '*'.runes.first || r == ' '.runes.first))
return false;
return true;
}
final hasTrainNumber = isFieldMeaningful(newRecord.fullTrainNumber) &&
!newRecord.fullTrainNumber.contains("-----");
final hasDirection =
newRecord.direction == 1 || newRecord.direction == 3;
final hasLocoInfo = isFieldMeaningful(newRecord.locoType) ||
isFieldMeaningful(newRecord.loco);
final hasRoute = isFieldMeaningful(newRecord.route);
final hasPosition = isFieldMeaningful(newRecord.position);
final hasSpeed =
isFieldMeaningful(newRecord.speed) && newRecord.speed != "NUL";
final hasPositionInfo = isFieldMeaningful(newRecord.positionInfo);
final hasTrainType = isFieldMeaningful(newRecord.trainType) &&
newRecord.trainType != "未知";
final hasLbjClass =
isFieldMeaningful(newRecord.lbjClass) && newRecord.lbjClass != "NA";
final hasTrain = isFieldMeaningful(newRecord.train) &&
!newRecord.train.contains("-----");
if (!hasTrainNumber &&
!hasDirection &&
!hasLocoInfo &&
!hasRoute &&
!hasPosition &&
!hasSpeed &&
!hasPositionInfo &&
!hasTrainType &&
!hasLbjClass &&
!hasTrain) {
return;
}
}
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;
final allRecords = await DatabaseService.instance.getAllRecords();
final items = MergeService.getMixedList(allRecords, _mergeSettings);
if (mounted) {
if (!_isAtTop) {
_chatObserver.standby();
}
final hasDataChanged = _hasDataChanged(items);
if (hasDataChanged) {
setState(() {
_displayItems.clear();
_displayItems.addAll(items);
});
}
if (_isAtTop && _scrollController.hasClients) {
_scrollController.jumpTo(0.0);
}
}
} catch (e) {}
}
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) {
@@ -109,8 +311,12 @@ class HistoryScreenState extends State<HistoryScreen> {
Text('暂无记录', style: TextStyle(color: Colors.white, fontSize: 18))
]));
}
return ListView.builder(
return ListViewObserver(
controller: _observerController,
child: ListView.builder(
controller: _scrollController,
physics: ChatObserverClampingScrollPhysics(observer: _chatObserver),
shrinkWrap: _chatObserver.isShrinkWrap,
padding: const EdgeInsets.all(16.0),
itemCount: _displayItems.length,
itemBuilder: (context, index) {
@@ -118,10 +324,12 @@ class HistoryScreenState extends State<HistoryScreen> {
if (item is MergedTrainRecord) {
return _buildMergedRecordCard(item);
} else if (item is TrainRecord) {
return _buildRecordCard(item);
return _buildRecordCard(item, key: ValueKey(item.uniqueId));
}
return const SizedBox.shrink();
});
},
),
);
}
Widget _buildMergedRecordCard(MergedTrainRecord mergedRecord) {
@@ -197,7 +405,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)),
@@ -353,7 +561,7 @@ class HistoryScreenState extends State<HistoryScreen> {
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>()
@@ -410,6 +618,7 @@ class HistoryScreenState extends State<HistoryScreen> {
positions: positions,
center: bounds.center,
zoom: zoomLevel,
groupKey: groupKey,
))
]);
}
@@ -420,11 +629,16 @@ class HistoryScreenState extends State<HistoryScreen> {
return 10.0;
}
Widget _buildRecordCard(TrainRecord record, {bool isSubCard = false}) {
Widget _buildRecordCard(TrainRecord record,
{bool isSubCard = false, Key? key}) {
final isSelected = _selectedRecords.contains(record.uniqueId);
final isExpanded =
!isSubCard && (_expandedStates[record.uniqueId] ?? false);
return Card(
final GlobalKey itemKey = GlobalKey();
final Widget card = Card(
key: key ?? itemKey,
color: isSelected && _isEditMode
? const Color(0xFF2E2E2E)
: const Color(0xFF1E1E1E),
@@ -451,15 +665,23 @@ class HistoryScreenState extends State<HistoryScreen> {
});
} else if (!isSubCard) {
if (isExpanded) {
setState(() {
_expandedStates[record.uniqueId] = false;
_mapOptimalZoom.remove(record.uniqueId);
_mapCalculating.remove(record.uniqueId);
});
final shouldUpdate =
_expandedStates[record.uniqueId] == true ||
_mapOptimalZoom.containsKey(record.uniqueId) ||
_mapCalculating.containsKey(record.uniqueId);
if (shouldUpdate) {
setState(() {
_expandedStates[record.uniqueId] = false;
_mapOptimalZoom.remove(record.uniqueId);
_mapCalculating.remove(record.uniqueId);
});
}
} else {
setState(() {
_expandedStates[record.uniqueId] = true;
});
if (_expandedStates[record.uniqueId] != true) {
setState(() {
_expandedStates[record.uniqueId] = true;
});
}
}
}
},
@@ -478,14 +700,27 @@ class HistoryScreenState extends State<HistoryScreen> {
_buildRecordHeader(record),
_buildPositionAndSpeed(record),
_buildLocoInfo(record),
if (isExpanded) _buildExpandedContent(record)
if (isExpanded) _buildExpandedContent(record),
]))));
WidgetsBinding.instance.addPostFrameCallback((_) {
if (_itemHeightCache <= 0 && itemKey.currentContext != null) {
final RenderBox renderBox =
itemKey.currentContext!.findRenderObject() as RenderBox;
final double realHeight = renderBox.size.height;
if (realHeight > 0) {
setState(() {
_itemHeightCache = realHeight;
});
}
}
});
return card;
}
Widget _buildRecordHeader(TrainRecord record, {bool isMerged = false}) {
final trainType = record.trainType;
final trainDisplay =
record.fullTrainNumber.isEmpty ? "未知列车" : record.fullTrainNumber;
String formattedLocoInfo = "";
if (record.locoType.isNotEmpty && record.loco.isNotEmpty) {
final shortLoco = record.loco.length > 5
@@ -497,6 +732,22 @@ class HistoryScreenState extends State<HistoryScreen> {
} else if (record.loco.isNotEmpty) {
formattedLocoInfo = record.loco;
}
if (record.fullTrainNumber.isEmpty && formattedLocoInfo.isEmpty) {
return Text(
(record.time == "<NUL>" || record.time.isEmpty)
? record.receivedTimestamp.toString().split(".")[0]
: record.time.split("\n")[0],
style: const TextStyle(fontSize: 11, color: Colors.grey),
overflow: TextOverflow.ellipsis);
}
final hasTrainNumber = record.fullTrainNumber.isNotEmpty;
final hasDirection = record.direction == 1 || record.direction == 3;
final hasLocoInfo =
formattedLocoInfo.isNotEmpty && formattedLocoInfo != "<NUL>";
final shouldShowTrainRow = hasTrainNumber || hasDirection || hasLocoInfo;
return Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Row(mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [
Flexible(
@@ -512,43 +763,47 @@ class HistoryScreenState extends State<HistoryScreen> {
style: const TextStyle(fontSize: 11, color: Colors.grey),
overflow: TextOverflow.ellipsis))
]),
const SizedBox(height: 2),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Flexible(
child: Row(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Flexible(
child: Text(trainDisplay,
style: const TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
color: Colors.white),
overflow: TextOverflow.ellipsis)),
const SizedBox(width: 6),
if (record.direction == 1 || record.direction == 3)
Container(
width: 20,
height: 20,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(2)),
child: Center(
child: Text(record.direction == 1 ? "" : "",
style: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.bold,
color: Colors.black))))
])),
if (formattedLocoInfo.isNotEmpty && formattedLocoInfo != "<NUL>")
Text(formattedLocoInfo,
style: const TextStyle(fontSize: 14, color: Colors.white70))
]),
const SizedBox(height: 2)
if (shouldShowTrainRow) ...[
const SizedBox(height: 2),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Flexible(
child: Row(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
if (hasTrainNumber)
Flexible(
child: Text(record.fullTrainNumber,
style: const TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
color: Colors.white),
overflow: TextOverflow.ellipsis)),
if (hasTrainNumber && hasDirection)
const SizedBox(width: 6),
if (hasDirection)
Container(
width: 20,
height: 20,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(2)),
child: Center(
child: Text(record.direction == 1 ? "" : "",
style: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.bold,
color: Colors.black))))
])),
if (hasLocoInfo)
Text(formattedLocoInfo,
style: const TextStyle(fontSize: 14, color: Colors.white70))
]),
const SizedBox(height: 2)
]
]);
}
@@ -664,6 +919,7 @@ class HistoryScreenState extends State<HistoryScreen> {
key: ValueKey('map_${mapId}_$zoomLevel'),
position: position,
zoom: zoomLevel,
recordId: record.uniqueId,
))
]);
}
@@ -803,11 +1059,13 @@ _BoundaryBox _calculateBoundaryBoxIsolate(List<LatLng> positions) {
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
@@ -815,11 +1073,90 @@ class _DelayedMapWithMarker extends StatefulWidget {
}
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(initialCenter: widget.position, initialZoom: widget.zoom),
options: MapOptions(
onPositionChanged: (position, hasGesture) => _onCameraMove(),
),
mapController: _mapController,
children: [
TileLayer(
urlTemplate: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png',
@@ -846,12 +1183,14 @@ 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
@@ -859,15 +1198,65 @@ class _DelayedMultiMarkerMap extends StatefulWidget {
}
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(
initialCenter: widget.center,
initialZoom: widget.zoom,
onPositionChanged: (position, hasGesture) => _onCameraMove(),
minZoom: 5,
maxZoom: 18,
),
mapController: _mapController,
children: [
TileLayer(
urlTemplate: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png',

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
# In Windows, build-name is used as the major, minor, and patch parts
# of the product and file versions while build-number is used as the build suffix.
version: 0.1.7-flutter
version: 0.3.0-flutter+30
environment:
sdk: ^3.5.4
@@ -52,6 +52,8 @@ dependencies:
file_picker: ^8.1.2
package_info_plus: ^8.1.2
msix: ^3.16.12
flutter_background_service: ^5.1.0
scrollview_observer: ^1.20.0
dev_dependencies:
flutter_test: