|
|
@@ -0,0 +1,582 @@
|
|
|
+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);
|
|
|
+ } 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<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);
|
|
|
+}
|