| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622 |
- 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<LocationResult?> show(
- BuildContext context, {
- String? initialAddress,
- }) {
- return Navigator.push(
- context,
- TDSlidePopupRoute<LocationResult>(
- slideTransitionFrom: SlideTransitionFrom.bottom,
- isDismissible: true,
- builder: (_) => LocationPicker(initialAddress: initialAddress),
- ),
- );
- }
- @override
- State<LocationPicker> createState() => _LocationPickerState();
- }
- class _LocationPickerState extends State<LocationPicker> {
- 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<void> _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<void> _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<void> _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<void> _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);
- } on LocationServiceDisabledException catch (e) {
- // 系统位置服务(GPS)总开关关闭
- if (mounted) {
- _showLocateError(
- context,
- messageKey: 'locateServiceOff',
- detail: e.toString(),
- );
- }
- } on TimeoutException catch (e) {
- // 定位超时(GPS 冷启动/室内等场景)
- if (mounted) {
- _showLocateError(
- context,
- messageKey: 'locateTimeout',
- detail: e.toString(),
- );
- }
- } catch (e) {
- if (mounted) _showLocateError(context, detail: e.toString());
- }
- if (mounted) setState(() => _locating = false);
- }
- void _showLocateError(
- BuildContext context, {
- String? messageKey,
- String? detail,
- }) {
- final l10n = AppLocalizations.of(context);
- final colors = Theme.of(context).extension<AppColorsExtension>()!;
- final content = detail == null
- ? l10n.get(messageKey ?? 'locateFailed')
- : '${l10n.get(messageKey ?? 'locateFailed')}\n\n$detail';
- showDialog(
- context: context,
- useRootNavigator: true,
- builder: (ctx) => TDAlertDialog(
- content: content,
- // 带异常详情时限制高度,由 TDAlertDialog 自带的滚动处理长内容
- contentMaxHeight: detail == null ? 0 : 240,
- buttonStyle: TDDialogButtonStyle.text,
- rightBtn: TDDialogButtonOptions(
- title: l10n.get('confirm'),
- titleColor: colors.primary,
- action: () => Navigator.pop(ctx),
- ),
- ),
- );
- }
- 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<AppColorsExtension>()!;
- 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);
- }
|