This commit is contained in:
Nedifinita
2025-08-29 13:28:14 +08:00
commit 25f66000cb
148 changed files with 10964 additions and 0 deletions

View File

@@ -0,0 +1,566 @@
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';
class HistoryScreen extends StatefulWidget {
final Function(bool isEditing) onEditModeChanged;
final Function() onSelectionChanged;
const HistoryScreen({
super.key,
required this.onEditModeChanged,
required this.onSelectionChanged,
});
@override
HistoryScreenState createState() => HistoryScreenState();
}
class HistoryScreenState extends State<HistoryScreen> {
final List<Object> _displayItems = [];
bool _isLoading = true;
bool _isEditMode = false;
final Set<String> _selectedRecords = {};
final Map<String, bool> _expandedStates = {};
final ScrollController _scrollController = ScrollController();
bool _isAtTop = true;
MergeSettings _mergeSettings = MergeSettings();
int getSelectedCount() => _selectedRecords.length;
Set<String> getSelectedRecordIds() => _selectedRecords;
List<Object> getDisplayItems() => _displayItems;
void clearSelection() => setState(() => _selectedRecords.clear());
void setEditMode(bool isEditing) {
setState(() {
_isEditMode = isEditing;
widget.onEditModeChanged(isEditing);
if (!isEditing) {
_selectedRecords.clear();
}
});
}
@override
void initState() {
super.initState();
loadRecords();
_scrollController.addListener(() {
if (_scrollController.position.atEdge) {
if (_scrollController.position.pixels == 0) {
if (!_isAtTop) setState(() => _isAtTop = true);
}
} else {
if (_isAtTop) setState(() => _isAtTop = false);
}
});
}
@override
void dispose() {
_scrollController.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);
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);
}
}
} catch (e) {
if (mounted) setState(() => _isLoading = false);
}
}
@override
Widget build(BuildContext context) {
if (_isLoading) {
return const Center(child: CircularProgressIndicator());
}
if (_displayItems.isEmpty) {
return const Center(
child: Column(mainAxisAlignment: MainAxisAlignment.center, children: [
Icon(Icons.history, size: 64, color: Colors.grey),
SizedBox(height: 16),
Text('暂无记录', style: TextStyle(color: Colors.white, fontSize: 18))
]));
}
return ListView.builder(
controller: _scrollController,
padding: const EdgeInsets.all(16.0),
itemCount: _displayItems.length,
itemBuilder: (context, index) {
final item = _displayItems[index];
if (item is MergedTrainRecord) {
return _buildMergedRecordCard(item);
} else if (item is TrainRecord) {
return _buildRecordCard(item);
}
return const SizedBox.shrink();
});
}
Widget _buildMergedRecordCard(MergedTrainRecord mergedRecord) {
final bool isSelected =
mergedRecord.records.any((r) => _selectedRecords.contains(r.uniqueId));
final isExpanded = _expandedStates[mergedRecord.groupKey] ?? false;
return Card(
color: isSelected && _isEditMode
? const Color(0xFF2E2E2E)
: const Color(0xFF1E1E1E),
elevation: 1,
margin: const EdgeInsets.only(bottom: 8.0),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8.0),
side: BorderSide(
color: isSelected && _isEditMode
? Colors.blue
: Colors.transparent,
width: 2.0)),
child: InkWell(
borderRadius: BorderRadius.circular(8.0),
onTap: () {
if (_isEditMode) {
setState(() {
final allIdsInGroup =
mergedRecord.records.map((r) => r.uniqueId).toSet();
if (isSelected) {
_selectedRecords.removeAll(allIdsInGroup);
} else {
_selectedRecords.addAll(allIdsInGroup);
}
widget.onSelectionChanged();
});
} else {
setState(
() => _expandedStates[mergedRecord.groupKey] = !isExpanded);
}
},
onLongPress: () {
if (!_isEditMode) setEditMode(true);
setState(() {
final allIdsInGroup =
mergedRecord.records.map((r) => r.uniqueId).toSet();
_selectedRecords.addAll(allIdsInGroup);
widget.onSelectionChanged();
});
},
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildRecordHeader(mergedRecord.latestRecord,
isMerged: true),
_buildPositionAndSpeed(mergedRecord.latestRecord),
_buildLocoInfo(mergedRecord.latestRecord),
if (isExpanded) _buildMergedExpandedContent(mergedRecord)
]))));
}
Widget _buildMergedExpandedContent(MergedTrainRecord mergedRecord) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildExpandedMapForAll(mergedRecord.records),
const Divider(color: Colors.white24, height: 24),
...mergedRecord.records.map((record) => _buildSubRecordItem(
record, mergedRecord.latestRecord, _mergeSettings.groupBy)),
],
);
}
Widget _buildSubRecordItem(
TrainRecord record, TrainRecord latest, GroupBy groupBy) {
String differingInfo = _getDifferingInfo(record, latest, groupBy);
String locationInfo = _getLocationInfo(record);
return Padding(
padding: const EdgeInsets.only(bottom: 8.0, top: 4.0),
child: Column(
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
record.receivedTimestamp.toString().split('.')[0],
style: const TextStyle(color: Colors.grey, fontSize: 12),
),
if (differingInfo.isNotEmpty)
Text(
differingInfo,
style:
const TextStyle(color: Color(0xFF81D4FA), fontSize: 12),
),
],
),
const SizedBox(height: 4),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Flexible(
child: Text(
locationInfo,
style: const TextStyle(color: Colors.white70, fontSize: 14),
overflow: TextOverflow.ellipsis,
),
),
Text(
record.speed.isNotEmpty ? "${record.speed} km/h" : "",
style: const TextStyle(color: Colors.white70, fontSize: 14),
),
],
),
],
),
);
}
String _getDifferingInfo(
TrainRecord record, TrainRecord latest, GroupBy groupBy) {
final train = record.train.trim();
final loco = record.loco.trim();
final latestTrain = latest.train.trim();
final latestLoco = latest.loco.trim();
switch (groupBy) {
case GroupBy.trainOnly:
return loco != latestLoco && loco.isNotEmpty ? "机车: $loco" : "";
case GroupBy.locoOnly:
return train != latestTrain && train.isNotEmpty ? "车次: $train" : "";
case GroupBy.trainOrLoco:
if (train.isNotEmpty && train != latestTrain) return "车次: $train";
if (loco.isNotEmpty && loco != latestLoco) return "机车: $loco";
return "";
case GroupBy.trainAndLoco:
return "";
}
}
String _getLocationInfo(TrainRecord record) {
List<String> parts = [];
if (record.route.isNotEmpty && record.route != "<NUL>")
parts.add(record.route);
if (record.direction != 0) parts.add(record.direction == 1 ? "" : "");
if (record.position.isNotEmpty && record.position != "<NUL>")
parts.add("${record.position}K");
return parts.join(' ');
}
Widget _buildExpandedMapForAll(List<TrainRecord> records) {
final positions = records
.map((record) => _parsePosition(record.positionInfo))
.whereType<LatLng>()
.toList();
if (positions.isEmpty) return const SizedBox.shrink();
final bounds = LatLngBounds.fromPoints(positions);
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: 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())
]))
]);
}
Widget _buildRecordCard(TrainRecord record, {bool isSubCard = false}) {
final isSelected = _selectedRecords.contains(record.uniqueId);
final isExpanded =
!isSubCard && (_expandedStates[record.uniqueId] ?? false);
return Card(
color: isSelected && _isEditMode
? const Color(0xFF2E2E2E)
: const Color(0xFF1E1E1E),
elevation: isSubCard ? 0 : 1,
margin: EdgeInsets.only(bottom: isSubCard ? 4.0 : 8.0),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8.0),
side: BorderSide(
color: isSelected && _isEditMode
? Colors.blue
: Colors.transparent,
width: 2.0)),
child: InkWell(
borderRadius: BorderRadius.circular(8.0),
onTap: () {
if (_isEditMode) {
setState(() {
if (isSelected) {
_selectedRecords.remove(record.uniqueId);
} else {
_selectedRecords.add(record.uniqueId);
}
widget.onSelectionChanged();
});
} else if (!isSubCard) {
setState(() => _expandedStates[record.uniqueId] = !isExpanded);
}
},
onLongPress: () {
if (!_isEditMode) setEditMode(true);
setState(() {
_selectedRecords.add(record.uniqueId);
widget.onSelectionChanged();
});
},
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildRecordHeader(record),
_buildPositionAndSpeed(record),
_buildLocoInfo(record),
if (isExpanded) _buildExpandedContent(record)
]))));
}
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
? record.loco.substring(record.loco.length - 5)
: record.loco;
formattedLocoInfo = "${record.locoType}-$shortLoco";
} else if (record.locoType.isNotEmpty) {
formattedLocoInfo = record.locoType;
} else if (record.loco.isNotEmpty) {
formattedLocoInfo = record.loco;
}
return Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Row(mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [
Flexible(
child: Text(
(record.time == "<NUL>" || record.time.isEmpty)
? record.receivedTimestamp.toString().split(".")[0]
: record.time.split("\n")[0],
style: const TextStyle(fontSize: 12, color: Colors.grey),
overflow: TextOverflow.ellipsis)),
if (trainType.isNotEmpty)
Flexible(
child: Text(trainType,
style: const TextStyle(fontSize: 12, 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)
]);
}
Widget _buildLocoInfo(TrainRecord record) {
final locoInfo = record.locoInfo;
if (locoInfo == null || locoInfo.isEmpty) return const SizedBox.shrink();
return Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
const SizedBox(height: 4),
Text(locoInfo,
style: const TextStyle(fontSize: 14, color: Colors.white),
maxLines: 1,
overflow: TextOverflow.ellipsis)
]);
}
Widget _buildPositionAndSpeed(TrainRecord record) {
final routeStr = record.route.trim();
final position = record.position.trim();
final speed = record.speed.trim();
final isValidRoute = routeStr.isNotEmpty &&
!routeStr.runes.every((r) => r == '*'.runes.first);
final isValidPosition = position.isNotEmpty &&
!position.runes
.every((r) => r == '-'.runes.first || r == '.'.runes.first) &&
position != "<NUL>";
final isValidSpeed = speed.isNotEmpty &&
!speed.runes
.every((r) => r == '*'.runes.first || r == '-'.runes.first) &&
speed != "NUL" &&
speed != "<NUL>";
if (!isValidRoute && !isValidPosition && !isValidSpeed)
return const SizedBox.shrink();
return Padding(
padding: const EdgeInsets.only(top: 4.0),
child:
Row(mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [
if (isValidRoute || isValidPosition)
Expanded(
child: Row(children: [
if (isValidRoute)
Flexible(
child: Text(routeStr,
style:
const TextStyle(fontSize: 16, color: Colors.white),
overflow: TextOverflow.ellipsis)),
if (isValidRoute && isValidPosition) const SizedBox(width: 4),
if (isValidPosition)
Flexible(
child: Text("$position K",
style:
const TextStyle(fontSize: 16, color: Colors.white),
overflow: TextOverflow.ellipsis))
])),
if (isValidSpeed)
Text("$speed km/h",
style: const TextStyle(fontSize: 16, color: Colors.white),
textAlign: TextAlign.right)
]));
}
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)))
])
]))
])
]);
}
LatLng? _parsePosition(String? positionInfo) {
if (positionInfo == null || positionInfo.isEmpty || positionInfo == '<NUL>')
return null;
try {
final parts = positionInfo.trim().split(RegExp(r'\s+'));
if (parts.length >= 2) {
final lat = _parseDmsCoordinate(parts[0]);
final lng = _parseDmsCoordinate(parts[1]);
if (lat != null &&
lng != null &&
(lat.abs() > 0.001 || lng.abs() > 0.001)) {
return LatLng(lat, lng);
}
}
} catch (e) {}
return null;
}
double? _parseDmsCoordinate(String dmsStr) {
try {
final degreeIndex = dmsStr.indexOf('°');
if (degreeIndex == -1) return null;
final degrees = double.tryParse(dmsStr.substring(0, degreeIndex));
if (degrees == null) return null;
final minuteIndex = dmsStr.indexOf('');
if (minuteIndex == -1) return degrees;
final minutes =
double.tryParse(dmsStr.substring(degreeIndex + 1, minuteIndex));
if (minutes == null) return degrees;
return degrees + (minutes / 60.0);
} catch (e) {
return null;
}
}
}