Files
LBJ_Console/lib/screens/history_screen.dart
2025-09-06 00:02:46 +08:00

570 lines
21 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>") {
final position = record.position;
final cleanPosition = position.endsWith('.') ? position.substring(0, position.length - 1) : position;
parts.add("${cleanPosition}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: 11, color: Colors.grey),
overflow: TextOverflow.ellipsis)),
if (trainType.isNotEmpty)
Flexible(
child: Text(trainType,
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)
]);
}
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.trim().endsWith('.') ? position.trim().substring(0, position.trim().length - 1) : position.trim()}K",
style:
const TextStyle(fontSize: 16, color: Colors.white),
overflow: TextOverflow.ellipsis))
])),
if (isValidSpeed)
Text("${speed.replaceAll(' ', '')} 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;
}
}
}