import 'dart:async'; import 'dart:convert'; import 'dart:math'; import 'package:flutter/material.dart'; import 'package:flutter_map/flutter_map.dart'; import 'package:latlong2/latlong.dart'; import 'package:geolocator/geolocator.dart'; import 'package:http/http.dart' as http; import 'package:tdesign_flutter/tdesign_flutter.dart'; import '../../core/theme/app_colors_extension.dart'; import '../../core/i18n/app_localizations.dart'; class LocationResult { final String address; final double latitude; final double longitude; const LocationResult({ required this.address, required this.latitude, required this.longitude, }); } class LocationPicker extends StatefulWidget { final String? initialAddress; const LocationPicker({super.key, this.initialAddress}); static Future show( BuildContext context, { String? initialAddress, }) { return Navigator.push( context, TDSlidePopupRoute( slideTransitionFrom: SlideTransitionFrom.bottom, isDismissible: true, builder: (_) => LocationPicker(initialAddress: initialAddress), ), ); } @override State createState() => _LocationPickerState(); } class _LocationPickerState extends State { final _mapCtrl = MapController(); final _searchCtrl = TextEditingController(); LatLng _center = const LatLng(22.277, 113.565); // 珠海市区 String _address = ''; bool _locating = false; bool _searching = false; Timer? _debounce; List<_SItem> _results = []; static const _ak = 'rXpqWuD8jNrEzczIYzzc9Jwjqi6MVtXD'; static const _base = 'https://api.map.baidu.com'; static const _xPi = pi * 3000.0 / 180.0; /// BD-09 → GCJ-02 (高德坐标系) LatLng _bd2gcj(double lat, double lng) { final x = lng - 0.0065; final y = lat - 0.006; final z = sqrt(x * x + y * y) - 0.00002 * sin(y * _xPi); final theta = atan2(y, x) - 0.000003 * cos(x * _xPi); return LatLng(z * sin(theta), z * cos(theta)); } @override void initState() { super.initState(); if (widget.initialAddress != null && widget.initialAddress!.isNotEmpty) { _address = widget.initialAddress!; _geocode(_address); } else { _reverse(_center); } } @override void dispose() { _searchCtrl.dispose(); super.dispose(); } Future _geocode(String q) async { try { final u = '$_base/geocoding/v3/?address=${Uri.encodeComponent(q)}&output=json&ak=$_ak'; final r = await http.get(Uri.parse(u)); if (r.statusCode != 200) return; final d = json.decode(r.body); if (d['status'] != 0 || d['result'] == null) return; final loc = d['result']['location']; final p = _bd2gcj(loc['lat'] as double, loc['lng'] as double); _mapCtrl.move(p, 15); setState(() { _center = p; _address = q; }); } catch (_) {} } Future _reverse(LatLng p) async { try { final u = '$_base/reverse_geocoding/v3/?location=${p.latitude},${p.longitude}&coordtype=gcj02ll&output=json&ak=$_ak'; final r = await http.get(Uri.parse(u)); if (r.statusCode != 200) return; final d = json.decode(r.body); if (d['status'] != 0 || d['result'] == null) return; if (!mounted) return; final addr = d['result']['formatted_address'] as String?; if (addr != null && addr.isNotEmpty) { setState(() => _address = addr); } else { setState( () => _address = '${p.latitude.toStringAsFixed(6)}, ${p.longitude.toStringAsFixed(6)}', ); } } catch (_) {} } void _search() { _debounce?.cancel(); final q = _searchCtrl.text.trim(); if (q.isEmpty) { setState(() { _results = []; _address = ''; _searching = false; }); return; } _address = q; // 中文最少 2 个字再触发搜索 if (q.length < 2) { setState(() => _results = []); return; } _debounce = Timer(const Duration(milliseconds: 400), () { _doSearch(q); }); } Future _doSearch(String q) async { // 守卫:防止旧 Timer 带着已删除的文本触发搜索 if (_searchCtrl.text.trim() != q) return; setState(() => _searching = true); try { final u = '$_base/place/v2/search?query=${Uri.encodeComponent(q)}®ion=全国&output=json&scope=2&ak=$_ak'; final r = await http.get(Uri.parse(u)); if (r.statusCode != 200) return; final d = json.decode(r.body); if (d['status'] != 0 || d['results'] == null) return; final list = d['results'] as List; if (!mounted) return; setState(() { _results = list.map((e) { final loc = e['location']; final p = _bd2gcj( (loc['lat'] as num).toDouble(), (loc['lng'] as num).toDouble(), ); final addr = e['address'] as String?; return _SItem( '${e['name'] ?? ''}${addr != null ? ',$addr' : ''}', p.latitude, p.longitude, ); }).toList(); }); } catch (_) { } finally { if (mounted) setState(() => _searching = false); } } void _pick(_SItem item) { final p = LatLng(item.lat, item.lon); _mapCtrl.move(p, 15); setState(() { _center = p; _address = item.name; _results = []; }); _searchCtrl.clear(); FocusScope.of(context).unfocus(); } Future _locate() async { setState(() => _locating = true); try { final ok = await Geolocator.requestPermission(); if (ok == LocationPermission.denied || ok == LocationPermission.deniedForever) { if (mounted) { if (ok == LocationPermission.deniedForever) { _showLocateError(context); } setState(() => _locating = false); } return; } final pos = await Geolocator.getCurrentPosition( desiredAccuracy: LocationAccuracy.high, forceAndroidLocationManager: true, timeLimit: const Duration(seconds: 10), ); final p = LatLng(pos.latitude, pos.longitude); _mapCtrl.move(p, 16); _reverse(p); } catch (_) { if (mounted) _showLocateError(context); } if (mounted) setState(() => _locating = false); } void _showLocateError(BuildContext context) { final l10n = AppLocalizations.of(context); TDToast.showFail(l10n.get('locateFailed'), context: context); } void _confirm() { if (_address.isEmpty) return; Navigator.pop( context, LocationResult( address: _address, latitude: _center.latitude, longitude: _center.longitude, ), ); } @override Widget build(BuildContext context) { final l10n = AppLocalizations.of(context); final colors = Theme.of(context).extension()!; return AnimatedPadding( padding: EdgeInsets.only( bottom: MediaQuery.of(context).viewInsets.bottom, ), duration: const Duration(milliseconds: 200), child: SafeArea( child: ConstrainedBox( constraints: BoxConstraints( maxHeight: MediaQuery.of(context).size.height * 0.9, ), child: Container( decoration: BoxDecoration( color: colors.bgPage, borderRadius: const BorderRadius.vertical( top: Radius.circular(16), ), ), child: Column( mainAxisSize: MainAxisSize.min, children: [ // ── 拖拽指示条 ── Center( child: Container( margin: const EdgeInsets.only(top: 8, bottom: 4), width: 36, height: 4, decoration: BoxDecoration( color: colors.border, borderRadius: BorderRadius.circular(2), ), ), ), // ── 标题栏 ── Padding( padding: const EdgeInsets.fromLTRB(20, 8, 20, 16), child: SizedBox( height: 32, child: Stack( children: [ Positioned( left: 0, top: 0, bottom: 0, child: Center( child: GestureDetector( onTap: () => Navigator.pop(context), child: Icon( Icons.close, size: 24, color: colors.textSecondary, ), ), ), ), Positioned( right: 0, top: 0, bottom: 0, child: Center( child: GestureDetector( onTap: _address.isNotEmpty ? _confirm : null, child: Text( l10n.get('confirm'), style: TextStyle( color: _address.isNotEmpty ? colors.primary : colors.textPlaceholder, fontWeight: FontWeight.w600, fontSize: 16, ), ), ), ), ), Center( child: Text( l10n.get('selectLocation'), maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle( fontSize: 18, fontWeight: FontWeight.w600, color: colors.textPrimary, ), ), ), ], ), ), ), // ── 地图 ── Expanded( child: Stack( children: [ FlutterMap( mapController: _mapCtrl, options: MapOptions( initialCenter: _center, initialZoom: 15, maxZoom: 18, onMapEvent: (e) { if (e is MapEventTap) { FocusScope.of(context).unfocus(); _center = e.tapPosition; _reverse(_center); } if (e is MapEventMoveEnd) { _center = _mapCtrl.camera.center; _reverse(_center); } }, ), children: [ TileLayer( urlTemplate: 'https://webrd0{s}.is.autonavi.com/appmaptile?lang=zh_cn&size=1&scale=1&style=8&x={x}&y={y}&z={z}', subdomains: const ['1', '2', '3', '4'], userAgentPackageName: 'com.amtxts.tboss_oa_module', ), ], ), Center( child: IgnorePointer( child: Column( mainAxisSize: MainAxisSize.min, children: [ if (_address.isNotEmpty) Container( constraints: const BoxConstraints( maxWidth: 280, ), margin: const EdgeInsets.only(bottom: 2), padding: const EdgeInsets.symmetric( horizontal: 10, vertical: 6, ), decoration: BoxDecoration( color: Colors.white, borderRadius: BorderRadius.circular(6), boxShadow: [ BoxShadow( color: Colors.black.withValues( alpha: 0.15, ), blurRadius: 6, ), ], ), child: Text( _address, maxLines: 2, overflow: TextOverflow.ellipsis, textAlign: TextAlign.center, style: TextStyle( fontSize: 13, color: colors.textPrimary, fontWeight: FontWeight.w500, ), ), ), const Icon( Icons.location_on, size: 40, color: Colors.red, ), ], ), ), ), SafeArea( child: Padding( padding: const EdgeInsets.fromLTRB(12, 12, 12, 0), child: Container( decoration: BoxDecoration( color: Colors.white, borderRadius: BorderRadius.circular(8), boxShadow: [ BoxShadow(color: Colors.black12, blurRadius: 4), ], ), child: Column( mainAxisSize: MainAxisSize.min, children: [ Padding( padding: const EdgeInsets.symmetric( horizontal: 12, ), child: TextField( controller: _searchCtrl, style: const TextStyle(fontSize: 16), decoration: InputDecoration( hintText: l10n.get('searchAddress'), hintStyle: TextStyle( color: colors.textPlaceholder, fontSize: 16, ), prefixIcon: const Icon( Icons.search, size: 22, color: Colors.grey, ), suffixIcon: _searching ? const Padding( padding: EdgeInsets.all(12), child: SizedBox( width: 18, height: 18, child: CircularProgressIndicator( strokeWidth: 2, ), ), ) : _searchCtrl.text.isNotEmpty ? IconButton( icon: const Icon( Icons.close, size: 20, color: Colors.grey, ), onPressed: () { _searchCtrl.clear(); setState(() { _results = []; _address = ''; }); }, ) : null, border: InputBorder.none, contentPadding: const EdgeInsets.symmetric( vertical: 14, ), ), onChanged: (_) => _search(), ), ), if (_results.isNotEmpty) Container( constraints: const BoxConstraints( maxHeight: 200, ), child: ListView.separated( shrinkWrap: true, itemCount: _results.length, separatorBuilder: (_, _) => const Divider(height: 1, indent: 0), itemBuilder: (_, i) => ListTile( dense: true, leading: const Icon( Icons.location_on, size: 20, color: Colors.redAccent, ), title: Text( _results[i].name, maxLines: 2, overflow: TextOverflow.ellipsis, style: const TextStyle(fontSize: 14), ), onTap: () => _pick(_results[i]), ), ), ), ], ), ), ), ), Positioned( right: 12, bottom: _address.isNotEmpty ? 70 : 32, child: FloatingActionButton.small( heroTag: 'loc', backgroundColor: Colors.white, onPressed: _locating ? null : _locate, child: _locating ? const SizedBox( width: 20, height: 20, child: CircularProgressIndicator( strokeWidth: 2, ), ) : const Icon( Icons.my_location, color: Colors.blue, ), ), ), if (_address.isNotEmpty) Positioned( left: 0, right: 0, bottom: 0, child: Container( padding: EdgeInsets.fromLTRB( 16, 12, 16, 12 + MediaQuery.of(context).padding.bottom, ), color: Colors.white, child: Row( children: [ const Icon( Icons.location_on, color: Colors.red, size: 20, ), const SizedBox(width: 8), Expanded( child: Text( _address, maxLines: 2, overflow: TextOverflow.ellipsis, ), ), ], ), ), ), ], ), ), ], ), ), ), ), ); } } class _SItem { final String name; final double lat; final double lon; const _SItem(this.name, this.lat, this.lon); }