212 lines
7.0 KiB
Dart
212 lines
7.0 KiB
Dart
|
|
// lib/widgets/map_preview_widget.dart
|
|||
|
|
import 'dart:async';
|
|||
|
|
import 'dart:convert';
|
|||
|
|
import 'package:flutter/material.dart';
|
|||
|
|
import 'package:qhd_prevention/tools/asset_server.dart';
|
|||
|
|
import 'package:webview_flutter/webview_flutter.dart';
|
|||
|
|
|
|||
|
|
/// MapPreviewWidget:只读缩略地图(不能交互),显示传入的点位(markers)。
|
|||
|
|
/// points 格式示例:
|
|||
|
|
/// [ { "longitude": 116.397428, "latitude": 39.90923, "iconPath": "/static/marker50.png" }, ... ]
|
|||
|
|
class MapPreviewWidget extends StatefulWidget {
|
|||
|
|
const MapPreviewWidget({
|
|||
|
|
Key? key,
|
|||
|
|
required this.points,
|
|||
|
|
this.width = 160,
|
|||
|
|
this.height = 160,
|
|||
|
|
this.borderRadius = 6,
|
|||
|
|
this.showBorder = true,
|
|||
|
|
this.defaultLongitude = 116.397428,
|
|||
|
|
this.defaultLatitude = 39.90923,
|
|||
|
|
this.defaultZoom = 15,
|
|||
|
|
}) : super(key: key);
|
|||
|
|
|
|||
|
|
final List<Map<String, dynamic>> points;
|
|||
|
|
final double width;
|
|||
|
|
final double height;
|
|||
|
|
final double borderRadius;
|
|||
|
|
final bool showBorder;
|
|||
|
|
|
|||
|
|
/// 当 points 为空时使用的默认中心(北京)
|
|||
|
|
final double defaultLongitude;
|
|||
|
|
final double defaultLatitude;
|
|||
|
|
final int defaultZoom;
|
|||
|
|
|
|||
|
|
@override
|
|||
|
|
State<MapPreviewWidget> createState() => _MapPreviewWidgetState();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
class _MapPreviewWidgetState extends State<MapPreviewWidget> {
|
|||
|
|
late final WebViewController _controller;
|
|||
|
|
bool _loading = true;
|
|||
|
|
Uri? _baseUri;
|
|||
|
|
|
|||
|
|
@override
|
|||
|
|
void initState() {
|
|||
|
|
super.initState();
|
|||
|
|
|
|||
|
|
// 创建 controller(无需 JS channel,因为不需要回调)
|
|||
|
|
_controller = WebViewController()
|
|||
|
|
..setJavaScriptMode(JavaScriptMode.unrestricted)
|
|||
|
|
..setNavigationDelegate(NavigationDelegate(
|
|||
|
|
onPageFinished: (url) async {
|
|||
|
|
// 页面加载完成后注入“居中 + 阻止交互”的脚本
|
|||
|
|
await _injectCenterAndBlock();
|
|||
|
|
setState(() {
|
|||
|
|
_loading = false;
|
|||
|
|
});
|
|||
|
|
},
|
|||
|
|
onWebResourceError: (err) {
|
|||
|
|
debugPrint('[MapPreview] WebResourceError: $err');
|
|||
|
|
},
|
|||
|
|
));
|
|||
|
|
|
|||
|
|
// 启动本地 server 并加载 page
|
|||
|
|
_initServerAndLoad();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
Future<void> _initServerAndLoad() async {
|
|||
|
|
_baseUri = await AssetServer().start(); // 单例 asset server
|
|||
|
|
// 把 points JSON 编码到 URL(map.html 已能处理 point 参数)
|
|||
|
|
final jsonPoints = jsonEncode(widget.points);
|
|||
|
|
final encoded = Uri.encodeComponent(jsonPoints);
|
|||
|
|
final uri = Uri.parse('${_baseUri.toString()}/map.html?point=$encoded&t=${DateTime.now().millisecondsSinceEpoch}');
|
|||
|
|
debugPrint('[MapPreview] load url: $uri');
|
|||
|
|
try {
|
|||
|
|
await _controller.loadRequest(uri);
|
|||
|
|
} catch (e) {
|
|||
|
|
debugPrint('[MapPreview] loadRequest failed: $e');
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 计算点位平均中心(如果没有点则返回 null)
|
|||
|
|
Map<String, double>? _computeAverageCenter() {
|
|||
|
|
double sumLon = 0.0, sumLat = 0.0;
|
|||
|
|
int count = 0;
|
|||
|
|
for (final p in widget.points) {
|
|||
|
|
try {
|
|||
|
|
final lon = _toDouble(p['longitude'] ?? p['longitue'] ?? p['long']);
|
|||
|
|
final lat = _toDouble(p['latitude'] ?? p['lat']);
|
|||
|
|
if (lon != null && lat != null) {
|
|||
|
|
sumLon += lon;
|
|||
|
|
sumLat += lat;
|
|||
|
|
count++;
|
|||
|
|
}
|
|||
|
|
} catch (_) {}
|
|||
|
|
}
|
|||
|
|
if (count == 0) return null;
|
|||
|
|
return {'longitude': sumLon / count, 'latitude': sumLat / count};
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
double? _toDouble(dynamic v) {
|
|||
|
|
if (v == null) return null;
|
|||
|
|
if (v is double) return v;
|
|||
|
|
if (v is int) return v.toDouble();
|
|||
|
|
if (v is String) {
|
|||
|
|
final cleaned = v.replaceAll(RegExp(r'[^\d\.\-]'), '');
|
|||
|
|
return double.tryParse(cleaned);
|
|||
|
|
}
|
|||
|
|
return null;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 注入 JS:设置中心并插入一个透明的覆盖层阻止所有页面内交互
|
|||
|
|
Future<void> _injectCenterAndBlock() async {
|
|||
|
|
final center = _computeAverageCenter();
|
|||
|
|
final lon = center != null ? center['longitude']! : widget.defaultLongitude;
|
|||
|
|
final lat = center != null ? center['latitude']! : widget.defaultLatitude;
|
|||
|
|
final zoom = widget.defaultZoom;
|
|||
|
|
|
|||
|
|
// JS:尝试用几种不同地图 API 的方式设置中心与缩放(兼容性考虑),
|
|||
|
|
// 然后在页面上加一个透明 div 覆盖层,阻止交互(pointerEvents 会拦截)
|
|||
|
|
final js = '''
|
|||
|
|
(function(){
|
|||
|
|
try {
|
|||
|
|
var lon = ${lon.toString()};
|
|||
|
|
var lat = ${lat.toString()};
|
|||
|
|
var zoom = ${zoom.toString()};
|
|||
|
|
// try custom setter
|
|||
|
|
try {
|
|||
|
|
if (typeof window.setMapCenter === 'function') {
|
|||
|
|
window.setMapCenter(lon, lat, zoom);
|
|||
|
|
}
|
|||
|
|
} catch(e) {}
|
|||
|
|
// Try T.Map style (used in your map.html)
|
|||
|
|
try {
|
|||
|
|
if (typeof map !== 'undefined' && map) {
|
|||
|
|
if (typeof map.centerAndZoom === 'function' && typeof T !== 'undefined') {
|
|||
|
|
try { map.centerAndZoom(new T.LngLat(lon, lat), zoom); } catch(e) {}
|
|||
|
|
} else if (typeof map.setCenter === 'function') {
|
|||
|
|
try { map.setCenter(new BMap.Point(lon, lat)); if (typeof map.setZoom === 'function') map.setZoom(zoom); } catch(e) {}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
} catch(e) {}
|
|||
|
|
// Add a full-page transparent blocker DIV to prevent interactions
|
|||
|
|
try {
|
|||
|
|
// avoid duplicate block
|
|||
|
|
if (!document.getElementById('flutter_preview_block')) {
|
|||
|
|
var block = document.createElement('div');
|
|||
|
|
block.id = 'flutter_preview_block';
|
|||
|
|
block.style.position = 'fixed';
|
|||
|
|
block.style.left = '0';
|
|||
|
|
block.style.top = '0';
|
|||
|
|
block.style.width = '100%';
|
|||
|
|
block.style.height = '100%';
|
|||
|
|
block.style.zIndex = '2147483647'; // very high
|
|||
|
|
block.style.background = 'transparent';
|
|||
|
|
// pointerEvents 'auto' means this div captures events and prevents underlying map from receiving them
|
|||
|
|
block.style.pointerEvents = 'auto';
|
|||
|
|
// ensure it doesn't block CSS visuals (transparent)
|
|||
|
|
document.documentElement.appendChild(block);
|
|||
|
|
}
|
|||
|
|
} catch (e) { console.log('preview block insert err', e); }
|
|||
|
|
} catch(e) {
|
|||
|
|
console.log('preview inject error', e);
|
|||
|
|
}
|
|||
|
|
})();
|
|||
|
|
''';
|
|||
|
|
|
|||
|
|
try {
|
|||
|
|
await _controller.runJavaScript(js);
|
|||
|
|
debugPrint('[MapPreview] injected center + blocker');
|
|||
|
|
} catch (e) {
|
|||
|
|
debugPrint('[MapPreview] inject error: $e');
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
@override
|
|||
|
|
void dispose() {
|
|||
|
|
// 不停止单例 AssetServer(可能被其它页面复用)
|
|||
|
|
super.dispose();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
@override
|
|||
|
|
Widget build(BuildContext context) {
|
|||
|
|
return ClipRRect(
|
|||
|
|
borderRadius: BorderRadius.circular(widget.borderRadius),
|
|||
|
|
child: Container(
|
|||
|
|
width: widget.width,
|
|||
|
|
height: widget.height,
|
|||
|
|
decoration: widget.showBorder
|
|||
|
|
? BoxDecoration(
|
|||
|
|
color: Colors.white,
|
|||
|
|
border: Border.all(color: Colors.black12),
|
|||
|
|
borderRadius: BorderRadius.circular(widget.borderRadius),
|
|||
|
|
)
|
|||
|
|
: null,
|
|||
|
|
child: Stack(
|
|||
|
|
children: [
|
|||
|
|
// WebView(我们不使用 AbsorbPointer,因为 JS 覆盖层会阻止页面内交互)
|
|||
|
|
WebViewWidget(controller: _controller),
|
|||
|
|
if (_loading)
|
|||
|
|
const Positioned.fill(
|
|||
|
|
child: Center(
|
|||
|
|
child: SizedBox(width: 28, height: 28, child: CircularProgressIndicator(strokeWidth: 2)),
|
|||
|
|
),
|
|||
|
|
),
|
|||
|
|
],
|
|||
|
|
),
|
|||
|
|
),
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
}
|