fix: solve the map state management problem when expanding/collapsed.

This commit is contained in:
Nedifinita
2025-09-24 17:39:52 +08:00
parent 1b05a6092c
commit 10825171fd
2 changed files with 254 additions and 129 deletions

View File

@@ -156,8 +156,19 @@ class HistoryScreenState extends State<HistoryScreen> {
widget.onSelectionChanged(); widget.onSelectionChanged();
}); });
} else { } else {
setState( if (isExpanded) {
() => _expandedStates[mergedRecord.groupKey] = !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: () { onLongPress: () {
@@ -261,8 +272,8 @@ class HistoryScreenState extends State<HistoryScreen> {
} }
} }
double _calculateOptimalZoom(List<LatLng> positions, {double containerWidth = 400, double containerHeight = 220}) { double _calculateOptimalZoom(List<LatLng> positions,
if (positions.isEmpty) return 15.0; {double containerWidth = 400, double containerHeight = 220}) {
if (positions.length == 1) return 17.0; if (positions.length == 1) return 17.0;
double minLat = positions[0].latitude; double minLat = positions[0].latitude;
@@ -298,8 +309,12 @@ class HistoryScreenState extends State<HistoryScreen> {
const paddingRatio = 0.8; const paddingRatio = 0.8;
final widthZoom = math.log((containerWidth * paddingRatio) / (widthWorld * 256.0)) / math.log(2.0); final widthZoom =
final heightZoom = math.log((containerHeight * paddingRatio) / (heightWorld * 256.0)) / math.log(2.0); 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); final optimalZoom = math.min(widthZoom, heightZoom);
@@ -314,8 +329,10 @@ class HistoryScreenState extends State<HistoryScreen> {
final deltaLng = (pos2.longitude - pos1.longitude) * math.pi / 180; final deltaLng = (pos2.longitude - pos1.longitude) * math.pi / 180;
final a = math.sin(deltaLat / 2) * math.sin(deltaLat / 2) + final a = math.sin(deltaLat / 2) * math.sin(deltaLat / 2) +
math.cos(lat1) * math.cos(lat2) * math.cos(lat1) *
math.sin(deltaLng / 2) * math.sin(deltaLng / 2); math.cos(lat2) *
math.sin(deltaLng / 2) *
math.sin(deltaLng / 2);
final c = 2 * math.atan2(math.sqrt(a), math.sqrt(1 - a)); final c = 2 * math.atan2(math.sqrt(a), math.sqrt(1 - a));
return earthRadius * c; return earthRadius * c;
@@ -328,7 +345,9 @@ class HistoryScreenState extends State<HistoryScreen> {
if (record.direction != 0) parts.add(record.direction == 1 ? "" : ""); if (record.direction != 0) parts.add(record.direction == 1 ? "" : "");
if (record.position.isNotEmpty && record.position != "<NUL>") { if (record.position.isNotEmpty && record.position != "<NUL>") {
final position = record.position; 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"); parts.add("${cleanPosition}K");
} }
return parts.join(' '); return parts.join(' ');
@@ -344,10 +363,13 @@ class HistoryScreenState extends State<HistoryScreen> {
final mapId = records.map((r) => r.uniqueId).join('_'); final mapId = records.map((r) => r.uniqueId).join('_');
final bounds = LatLngBounds.fromPoints(positions); final bounds = LatLngBounds.fromPoints(positions);
if (!_mapOptimalZoom.containsKey(mapId) && !(_mapCalculating[mapId] ?? false)) { if (!_mapOptimalZoom.containsKey(mapId) &&
!(_mapCalculating[mapId] ?? false)) {
_mapCalculating[mapId] = true; _mapCalculating[mapId] = true;
_calculateOptimalZoomAsync(positions, containerWidth: 400, containerHeight: 220).then((optimalZoom) { _calculateOptimalZoomAsync(positions,
containerWidth: 400, containerHeight: 220)
.then((optimalZoom) {
if (mounted) { if (mounted) {
setState(() { setState(() {
_mapOptimalZoom[mapId] = optimalZoom; _mapOptimalZoom[mapId] = optimalZoom;
@@ -357,7 +379,24 @@ class HistoryScreenState extends State<HistoryScreen> {
}); });
} }
final zoomLevel = _mapOptimalZoom[mapId] ?? _getDefaultZoom(positions); 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: [ return Column(children: [
const SizedBox(height: 8), const SizedBox(height: 8),
@@ -366,33 +405,12 @@ class HistoryScreenState extends State<HistoryScreen> {
margin: const EdgeInsets.symmetric(vertical: 4), margin: const EdgeInsets.symmetric(vertical: 4),
decoration: BoxDecoration( decoration: BoxDecoration(
borderRadius: BorderRadius.circular(8), color: Colors.grey[900]), borderRadius: BorderRadius.circular(8), color: Colors.grey[900]),
child: FlutterMap( child: _DelayedMultiMarkerMap(
options: MapOptions( key: ValueKey('multi_map_${mapId}_$zoomLevel'),
initialCenter: bounds.center, positions: positions,
initialZoom: zoomLevel, center: bounds.center,
minZoom: 5, zoom: zoomLevel,
maxZoom: 18), ))
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())
]))
]); ]);
} }
@@ -432,7 +450,17 @@ class HistoryScreenState extends State<HistoryScreen> {
widget.onSelectionChanged(); widget.onSelectionChanged();
}); });
} else if (!isSubCard) { } 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: () { onLongPress: () {
@@ -569,7 +597,8 @@ class HistoryScreenState extends State<HistoryScreen> {
if (isValidRoute && isValidPosition) const SizedBox(width: 4), if (isValidRoute && isValidPosition) const SizedBox(width: 4),
if (isValidPosition) if (isValidPosition)
Flexible( 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: style:
const TextStyle(fontSize: 16, color: Colors.white), const TextStyle(fontSize: 16, color: Colors.white),
overflow: TextOverflow.ellipsis)) overflow: TextOverflow.ellipsis))
@@ -583,52 +612,60 @@ class HistoryScreenState extends State<HistoryScreen> {
Widget _buildExpandedContent(TrainRecord record) { Widget _buildExpandedContent(TrainRecord record) {
final position = _parsePosition(record.positionInfo); final position = _parsePosition(record.positionInfo);
if (position == null) return const SizedBox.shrink(); final mapId = record.uniqueId;
return FutureBuilder<double>( if (position == null) {
future: Future(() => _calculateOptimalZoom([position], containerWidth: 400, containerHeight: 220)),
builder: (context, zoomSnapshot) {
if (!zoomSnapshot.hasData) {
return const SizedBox.shrink(); return const SizedBox.shrink();
} }
return Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ 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), const SizedBox(height: 8),
Container( Container(
height: 220, height: 220,
width: double.infinity,
margin: const EdgeInsets.symmetric(vertical: 4), margin: const EdgeInsets.symmetric(vertical: 4),
decoration: BoxDecoration( decoration: BoxDecoration(
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(8), color: Colors.grey[900]),
color: Colors.grey[900]), child: _DelayedMapWithMarker(
child: FlutterMap( key: ValueKey('map_${mapId}_$zoomLevel'),
options: MapOptions( position: position,
initialCenter: position, zoom: zoomLevel,
initialZoom: zoomSnapshot.data! ))
),
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) { LatLng? _parsePosition(String? positionInfo) {
@@ -666,11 +703,8 @@ class HistoryScreenState extends State<HistoryScreen> {
} }
} }
Future<_BoundaryBox> _calculateBoundaryBoxParallel(List<LatLng> positions) async { Future<_BoundaryBox> _calculateBoundaryBoxParallel(
if (positions.isEmpty) { List<LatLng> positions) async {
return _BoundaryBox(0, 0, 0, 0);
}
if (positions.length < 100) { if (positions.length < 100) {
return _calculateBoundaryBoxIsolate(positions); return _calculateBoundaryBoxIsolate(positions);
} }
@@ -683,9 +717,8 @@ class HistoryScreenState extends State<HistoryScreen> {
chunks.add(positions.sublist(i, end)); chunks.add(positions.sublist(i, end));
} }
final results = await Future.wait( final results = await Future.wait(chunks.map(
chunks.map((chunk) => Isolate.run(() => _calculateBoundaryBoxIsolate(chunk))) (chunk) => Isolate.run(() => _calculateBoundaryBoxIsolate(chunk))));
);
double minLat = results[0].minLat; double minLat = results[0].minLat;
double maxLat = results[0].maxLat; double maxLat = results[0].maxLat;
@@ -702,8 +735,8 @@ class HistoryScreenState extends State<HistoryScreen> {
return _BoundaryBox(minLat, maxLat, minLng, maxLng); return _BoundaryBox(minLat, maxLat, minLng, maxLng);
} }
Future<double> _calculateOptimalZoomAsync(List<LatLng> positions, {required double containerWidth, required double containerHeight}) async { Future<double> _calculateOptimalZoomAsync(List<LatLng> positions,
if (positions.isEmpty) return 15.0; {required double containerWidth, required double containerHeight}) async {
if (positions.length == 1) return 17.0; if (positions.length == 1) return 17.0;
final boundaryBox = await _calculateBoundaryBoxParallel(positions); final boundaryBox = await _calculateBoundaryBoxParallel(positions);
@@ -729,12 +762,16 @@ class HistoryScreenState extends State<HistoryScreen> {
const paddingRatio = 0.8; const paddingRatio = 0.8;
final widthZoom = math.log((containerWidth * paddingRatio) / (widthWorld * 256.0)) / math.log(2.0); final widthZoom =
final heightZoom = math.log((containerHeight * paddingRatio) / (heightWorld * 256.0)) / math.log(2.0); 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); final optimalZoom = math.min(widthZoom, heightZoom);
return math.max(1.0, math.min(20.0, optimalZoom)); return math.max(5.0, math.min(18.0, optimalZoom));
} }
} }
@@ -748,10 +785,6 @@ class _BoundaryBox {
} }
_BoundaryBox _calculateBoundaryBoxIsolate(List<LatLng> positions) { _BoundaryBox _calculateBoundaryBoxIsolate(List<LatLng> positions) {
if (positions.isEmpty) {
return _BoundaryBox(0, 0, 0, 0);
}
double minLat = positions[0].latitude; double minLat = positions[0].latitude;
double maxLat = positions[0].latitude; double maxLat = positions[0].latitude;
double minLng = positions[0].longitude; double minLng = positions[0].longitude;
@@ -766,3 +799,95 @@ _BoundaryBox _calculateBoundaryBoxIsolate(List<LatLng> positions) {
return _BoundaryBox(minLat, maxLat, minLng, maxLng); return _BoundaryBox(minLat, maxLat, minLng, maxLng);
} }
class _DelayedMapWithMarker extends StatefulWidget {
final LatLng position;
final double zoom;
const _DelayedMapWithMarker({
Key? key,
required this.position,
required this.zoom,
}) : super(key: key);
@override
State<_DelayedMapWithMarker> createState() => _DelayedMapWithMarkerState();
}
class _DelayedMapWithMarkerState extends State<_DelayedMapWithMarker> {
@override
Widget build(BuildContext context) {
return FlutterMap(
options:
MapOptions(initialCenter: widget.position, initialZoom: widget.zoom),
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;
const _DelayedMultiMarkerMap({
Key? key,
required this.positions,
required this.center,
required this.zoom,
}) : super(key: key);
@override
State<_DelayedMultiMarkerMap> createState() => _DelayedMultiMarkerMapState();
}
class _DelayedMultiMarkerMapState extends State<_DelayedMultiMarkerMap> {
@override
Widget build(BuildContext context) {
return FlutterMap(
options: MapOptions(
initialCenter: widget.center,
initialZoom: widget.zoom,
minZoom: 5,
maxZoom: 18,
),
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()),
],
);
}
}

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 # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
# In Windows, build-name is used as the major, minor, and patch parts # In Windows, build-name is used as the major, minor, and patch parts
# of the product and file versions while build-number is used as the build suffix. # of the product and file versions while build-number is used as the build suffix.
version: 0.1.6-flutter version: 0.1.7-flutter
environment: environment:
sdk: ^3.5.4 sdk: ^3.5.4