Ver Fonte

fix #19900 #19888 加班申请单、用车申请单

chengc há 2 semanas atrás
pai
commit
672a2b2ff7

+ 7 - 0
assets/i18n/en.json

@@ -117,6 +117,13 @@
     "select": "Select",
     "selectOrFill": "Select or fill in",
     "pleaseSelect": "Please select",
+    "copiedToClipboard": "Copied",
+    "selectLocation": "Select Location",
+    "searchAddress": "Search or enter address",
+    "locationPermissionTitle": "Location Permission",
+    "locationPermissionMsg": "Location permission has been permanently denied. Please enable it in system settings.",
+    "locateFailed": "Location failed, please check permission and network",
+    "goToSettings": "Settings",
     "selectDateTime": "Select date & time",
     "selectDate": "Select date",
     "optional": "Optional",

+ 7 - 0
assets/i18n/zh_CN.json

@@ -114,6 +114,13 @@
     "select": "选择",
     "selectOrFill": "请选择或填写",
     "pleaseSelect": "请选择",
+    "copiedToClipboard": "已复制",
+    "selectLocation": "选择位置",
+    "searchAddress": "搜索或输入地址",
+    "locationPermissionTitle": "定位权限",
+    "locationPermissionMsg": "定位权限已被永久拒绝,请在系统设置中开启定位权限。",
+    "locateFailed": "定位失败,请检查定位权限和网络",
+    "goToSettings": "去设置",
     "selectDateTime": "选择日期时间",
     "selectDate": "选择日期",
     "optional": "选填",

+ 7 - 0
assets/i18n/zh_TW.json

@@ -117,6 +117,13 @@
     "select": "選擇",
     "selectOrFill": "請選擇或填寫",
     "pleaseSelect": "請選擇",
+    "copiedToClipboard": "已複製",
+    "selectLocation": "選擇位置",
+    "searchAddress": "搜尋或輸入地址",
+    "locationPermissionTitle": "定位權限",
+    "locationPermissionMsg": "定位權限已被永久拒絕,請在系統設定中開啟定位權限。",
+    "locateFailed": "定位失敗,請檢查定位權限和網絡",
+    "goToSettings": "去設定",
     "selectDateTime": "選擇日期時間",
     "selectDate": "選擇日期",
     "optional": "選填",

+ 80 - 6
lib/features/overtime/widgets/overtime_apply_detail_dialog.dart

@@ -8,6 +8,7 @@ import '../../../core/navigation/host_app_channel.dart';
 import '../../../core/theme/app_colors.dart';
 import '../../../core/theme/app_colors_extension.dart';
 import '../../../shared/widgets/searchable_picker_sheet.dart';
+import '../../../shared/widgets/location_picker.dart';
 import '../../expense_apply/expense_apply_api.dart';
 import '../overtime_apply_api.dart';
 
@@ -1034,12 +1035,85 @@ class _OvertimeApplyDetailDialogState extends State<OvertimeApplyDetailDialog> {
 
   // ── 12. 地点 ──
   Widget _buildAdrInput(AppColorsExtension colors) {
-    return _inputCard(
-      label: _l10n.get('adr'),
-      required: false,
-      controller: _adrCtrl,
-      hintText: _l10n.get('enterAdr'),
-      focusNode: _adrFocus,
+    final tdTheme = TDTheme.of(context);
+    return GestureDetector(
+      onTap: () async {
+        FocusScope.of(context).unfocus();
+        final result = await LocationPicker.show(context);
+        if (result != null && mounted) {
+          setState(() => _adrCtrl.text = result.address);
+        }
+      },
+      child: Container(
+        padding: const EdgeInsets.only(
+          left: 16,
+          right: 10,
+          top: 12,
+          bottom: 12,
+        ),
+        decoration: BoxDecoration(
+          color: tdTheme.bgColorContainer,
+          borderRadius: BorderRadius.circular(tdTheme.radiusDefault),
+          border: Border.all(color: tdTheme.componentStrokeColor),
+        ),
+        child: Row(
+          children: [
+            TDText(
+              _l10n.get('adr'),
+              maxLines: 1,
+              overflow: TextOverflow.visible,
+              font: tdTheme.fontBodyLarge,
+              fontWeight: FontWeight.w400,
+              style: const TextStyle(letterSpacing: 0),
+            ),
+            const SizedBox(width: 12),
+            Expanded(
+              child: Row(
+                mainAxisAlignment: MainAxisAlignment.end,
+                children: [
+                  Flexible(
+                    child: TDText(
+                      _adrCtrl.text.isNotEmpty
+                          ? _adrCtrl.text
+                          : _l10n.get('enterAdr'),
+                      maxLines: 1,
+                      overflow: TextOverflow.ellipsis,
+                      font: tdTheme.fontBodyLarge,
+                      fontWeight: FontWeight.w400,
+                      textColor: _adrCtrl.text.isNotEmpty
+                          ? tdTheme.textColorPrimary
+                          : tdTheme.textColorPlaceholder,
+                      textAlign: TextAlign.end,
+                    ),
+                  ),
+                  const SizedBox(width: 4),
+                  SizedBox(
+                    width: 18,
+                    height: 18,
+                    child: _adrCtrl.text.isNotEmpty
+                        ? GestureDetector(
+                            onTap: () {
+                              _adrCtrl.clear();
+                              setState(() {});
+                            },
+                            child: Icon(
+                              Icons.close,
+                              size: 18,
+                              color: tdTheme.textColorPlaceholder,
+                            ),
+                          )
+                        : Icon(
+                            Icons.location_on_outlined,
+                            size: 20,
+                            color: tdTheme.textColorPlaceholder,
+                          ),
+                  ),
+                ],
+              ),
+            ),
+          ],
+        ),
+      ),
     );
   }
 

+ 47 - 14
lib/features/vehicle/vehicle_apply_create_page.dart

@@ -19,6 +19,7 @@ import '../../shared/widgets/loading_dialog.dart';
 import '../../shared/widgets/nav_bar_config.dart';
 import '../../shared/widgets/app_input_dialog.dart';
 import '../../shared/widgets/searchable_picker_sheet.dart';
+import '../../shared/widgets/location_picker.dart';
 import '../../shared/helpers/audit_flow_helper.dart';
 import 'vehicle_apply_api.dart';
 
@@ -38,6 +39,10 @@ class _VehicleApplyCreatePageState
   final _reasonController = TextEditingController();
   String _originAdr = '';
   String _destAdr = '';
+  double? _originLng;
+  double? _originLat;
+  double? _destLng;
+  double? _destLat;
   DateTime? _startTime;
   DateTime? _endTime;
 
@@ -360,28 +365,52 @@ class _VehicleApplyCreatePageState
         FormFieldRow(
           label: l10n.get('origin'),
           value: _originAdr.isNotEmpty ? _originAdr : null,
-          hint: l10n.get('pleaseEnter'),
-          onTap: () => _showTextInput(
-            l10n.get('origin'),
-            (v) => setState(() => _originAdr = v),
-            initialText: _originAdr,
-          ),
+          hint: l10n.get('pleaseSelect'),
+          onTap: () async {
+            final result = await LocationPicker.show(
+              context,
+              initialAddress: _originAdr,
+            );
+            if (result != null && mounted) {
+              setState(() {
+                _originAdr = result.address;
+                _originLat = result.latitude;
+                _originLng = result.longitude;
+              });
+            }
+          },
           onClear: _originAdr.isNotEmpty
-              ? () => setState(() => _originAdr = '')
+              ? () => setState(() {
+                  _originAdr = '';
+                  _originLat = null;
+                  _originLng = null;
+                })
               : null,
         ),
         const SizedBox(height: 16),
         FormFieldRow(
           label: l10n.get('destination'),
           value: _destAdr.isNotEmpty ? _destAdr : null,
-          hint: l10n.get('pleaseEnter'),
-          onTap: () => _showTextInput(
-            l10n.get('destination'),
-            (v) => setState(() => _destAdr = v),
-            initialText: _destAdr,
-          ),
+          hint: l10n.get('pleaseSelect'),
+          onTap: () async {
+            final result = await LocationPicker.show(
+              context,
+              initialAddress: _destAdr,
+            );
+            if (result != null && mounted) {
+              setState(() {
+                _destAdr = result.address;
+                _destLat = result.latitude;
+                _destLng = result.longitude;
+              });
+            }
+          },
           onClear: _destAdr.isNotEmpty
-              ? () => setState(() => _destAdr = '')
+              ? () => setState(() {
+                  _destAdr = '';
+                  _destLat = null;
+                  _destLng = null;
+                })
               : null,
         ),
       ],
@@ -647,7 +676,11 @@ class _VehicleApplyCreatePageState
         'DEP': _selectedDeptId,
         'REASON': _reasonController.text.trim(),
         'ORIGIN_ADR': _originAdr,
+        'ORIGIN_LONGITUDE': _originLng,
+        'ORIGIN_LATITUDE': _originLat,
         'DEST_ADR': _destAdr,
+        'DEST_LONGITUDE': _destLng,
+        'DEST_LATITUDE': _destLat,
         'PASSENGER_COUNT': _passengers.length,
         'PASSENGER_NAME': _passengers
             .map((p) => '${p.salNo}/${p.name.isNotEmpty ? p.name : p.salNo}')

+ 10 - 22
lib/features/vehicle/vehicle_apply_detail_page.dart

@@ -12,6 +12,7 @@ import '../../core/utils/amount_utils.dart';
 import '../../shared/widgets/app_skeletons.dart';
 import '../../shared/widgets/bill_status_bar.dart';
 import '../../shared/widgets/loading_dialog.dart';
+import '../../shared/widgets/trip_route_card.dart';
 import '../../shared/widgets/form_field_row.dart';
 import '../../shared/widgets/form_section.dart';
 import 'vehicle_apply_api.dart';
@@ -98,10 +99,7 @@ class _VehicleApplyDetailPageState
                       final dd = _data?.ycDd != null
                           ? du.DateUtils.formatDate(_data!.ycDd!)
                           : '';
-                      LoadingDialog.show(
-                        context,
-                        text: l10n.get('submitting'),
-                      );
+                      LoadingDialog.show(context, text: l10n.get('submitting'));
                       try {
                         await api.shSubmit(
                           bilId: 'YC',
@@ -118,10 +116,7 @@ class _VehicleApplyDetailPageState
                       final dd = _data?.ycDd != null
                           ? du.DateUtils.formatDate(_data!.ycDd!)
                           : '';
-                      LoadingDialog.show(
-                        context,
-                        text: l10n.get('submitting'),
-                      );
+                      LoadingDialog.show(context, text: l10n.get('submitting'));
                       try {
                         await api.shSubmit(
                           bilId: 'YC',
@@ -384,20 +379,13 @@ class _VehicleApplyDetailPageState
           showArrow: false,
         ),
         const SizedBox(height: 16),
-        FormFieldRow(
-          label: l10n.get('origin'),
-          value: app.originAdr.isNotEmpty ? app.originAdr : '-',
-          readOnly: true,
-          showArrow: false,
-          showMoreOnOverflow: true,
-        ),
-        const SizedBox(height: 16),
-        FormFieldRow(
-          label: l10n.get('destination'),
-          value: app.destAdr.isNotEmpty ? app.destAdr : '-',
-          readOnly: true,
-          showArrow: false,
-          showMoreOnOverflow: true,
+        TripRouteCard(
+          originLabel: app.originAdr.isNotEmpty ? app.originAdr : '-',
+          destLabel: app.destAdr.isNotEmpty ? app.destAdr : '-',
+          originLat: app.originLatitude,
+          originLng: app.originLongitude,
+          destLat: app.destLatitude,
+          destLng: app.destLongitude,
         ),
       ],
     );

+ 51 - 14
lib/features/vehicle/vehicle_apply_edit_page.dart

@@ -18,6 +18,7 @@ import '../../shared/widgets/nav_bar_config.dart';
 import '../../shared/widgets/app_input_dialog.dart';
 import '../../shared/widgets/searchable_picker_sheet.dart';
 import '../../shared/helpers/audit_flow_helper.dart';
+import '../../shared/widgets/location_picker.dart';
 import 'vehicle_apply_api.dart';
 import 'vehicle_apply_list_controller.dart';
 import 'vehicle_apply_model.dart';
@@ -45,6 +46,10 @@ class _VehicleApplyEditPageState extends ConsumerState<VehicleApplyEditPage> {
   // ── 行程信息 ──
   String _originAdr = '';
   String _destAdr = '';
+  double? _originLng;
+  double? _originLat;
+  double? _destLng;
+  double? _destLat;
   DateTime? _startTime;
   DateTime? _endTime;
 
@@ -251,6 +256,10 @@ class _VehicleApplyEditPageState extends ConsumerState<VehicleApplyEditPage> {
     _reasonController.text = model.reason;
     _originAdr = model.originAdr;
     _destAdr = model.destAdr;
+    _originLng = model.originLongitude;
+    _originLat = model.originLatitude;
+    _destLng = model.destLongitude;
+    _destLat = model.destLatitude;
     _startTime = model.startTime;
     _endTime = model.endTime;
     _licensePlate = model.licensePlate;
@@ -609,28 +618,52 @@ class _VehicleApplyEditPageState extends ConsumerState<VehicleApplyEditPage> {
         FormFieldRow(
           label: l10n.get('origin'),
           value: _originAdr.isNotEmpty ? _originAdr : null,
-          hint: l10n.get('pleaseEnter'),
-          onTap: () => _showTextInput(
-            l10n.get('origin'),
-            (v) => setState(() => _originAdr = v),
-            initialText: _originAdr,
-          ),
+          hint: l10n.get('pleaseSelect'),
+          onTap: () async {
+            final result = await LocationPicker.show(
+              context,
+              initialAddress: _originAdr,
+            );
+            if (result != null && mounted) {
+              setState(() {
+                _originAdr = result.address;
+                _originLng = result.longitude;
+                _originLat = result.latitude;
+              });
+            }
+          },
           onClear: _originAdr.isNotEmpty
-              ? () => setState(() => _originAdr = '')
+              ? () => setState(() {
+                  _originAdr = '';
+                  _originLng = null;
+                  _originLat = null;
+                })
               : null,
         ),
         const SizedBox(height: 16),
         FormFieldRow(
           label: l10n.get('destination'),
           value: _destAdr.isNotEmpty ? _destAdr : null,
-          hint: l10n.get('pleaseEnter'),
-          onTap: () => _showTextInput(
-            l10n.get('destination'),
-            (v) => setState(() => _destAdr = v),
-            initialText: _destAdr,
-          ),
+          hint: l10n.get('pleaseSelect'),
+          onTap: () async {
+            final result = await LocationPicker.show(
+              context,
+              initialAddress: _destAdr,
+            );
+            if (result != null && mounted) {
+              setState(() {
+                _destAdr = result.address;
+                _destLng = result.longitude;
+                _destLat = result.latitude;
+              });
+            }
+          },
           onClear: _destAdr.isNotEmpty
-              ? () => setState(() => _destAdr = '')
+              ? () => setState(() {
+                  _destAdr = '';
+                  _destLng = null;
+                  _destLat = null;
+                })
               : null,
         ),
       ],
@@ -799,7 +832,11 @@ class _VehicleApplyEditPageState extends ConsumerState<VehicleApplyEditPage> {
         'DEP': _selectedDeptId,
         'REASON': _reasonController.text.trim(),
         'ORIGIN_ADR': _originAdr,
+        'ORIGIN_LONGITUDE': _originLng,
+        'ORIGIN_LATITUDE': _originLat,
         'DEST_ADR': _destAdr,
+        'DEST_LONGITUDE': _destLng,
+        'DEST_LATITUDE': _destLat,
         'PASSENGER_COUNT': _passengers.length,
         'PASSENGER_NAME': _passengers
             .map((p) => '${p.salNo}/${p.name.isNotEmpty ? p.name : p.salNo}')

+ 582 - 0
lib/shared/widgets/location_picker.dart

@@ -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)}&region=全国&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);
+}

+ 263 - 0
lib/shared/widgets/trip_route_card.dart

@@ -0,0 +1,263 @@
+import 'package:flutter/material.dart';
+import 'package:flutter/services.dart';
+import 'package:flutter_map/flutter_map.dart';
+import 'package:latlong2/latlong.dart';
+import '../../core/i18n/app_localizations.dart';
+import '../../core/theme/app_colors_extension.dart';
+
+class TripRouteCard extends StatelessWidget {
+  final String originLabel;
+  final String destLabel;
+  final double? originLat;
+  final double? originLng;
+  final double? destLat;
+  final double? destLng;
+
+  const TripRouteCard({
+    super.key,
+    required this.originLabel,
+    required this.destLabel,
+    this.originLat,
+    this.originLng,
+    this.destLat,
+    this.destLng,
+  });
+
+  bool get _hasCoords =>
+      originLat != null &&
+      originLng != null &&
+      destLat != null &&
+      destLng != null &&
+      (originLat! != 0 || originLng! != 0) &&
+      (destLat! != 0 || destLng! != 0);
+
+  LatLng get _origin => LatLng(originLat!, originLng!);
+  LatLng get _dest => LatLng(destLat!, destLng!);
+
+  @override
+  Widget build(BuildContext context) {
+    final colors = Theme.of(context).extension<AppColorsExtension>()!;
+    return Column(
+      mainAxisSize: MainAxisSize.min,
+      children: [
+        _buildTextCard(context, colors),
+        if (_hasCoords) ...[const SizedBox(height: 20), _buildMapCard(colors)],
+      ],
+    );
+  }
+
+  Widget _buildTextCard(BuildContext context, AppColorsExtension colors) {
+    return Column(
+      children: [
+        GestureDetector(
+          onLongPress: () => _copyToClipboard(context, originLabel),
+          child: _bubble(
+            originLabel,
+            Icons.trip_origin,
+            colors.primary,
+            colors,
+          ),
+        ),
+        Padding(
+          padding: const EdgeInsets.only(left: 9),
+          child: Column(
+            children: List.generate(
+              3,
+              (_) => Padding(
+                padding: const EdgeInsets.symmetric(vertical: 3),
+                child: Container(
+                  width: 2,
+                  height: 2,
+                  decoration: BoxDecoration(
+                    color: colors.textPlaceholder,
+                    shape: BoxShape.circle,
+                  ),
+                ),
+              ),
+            ),
+          ),
+        ),
+        GestureDetector(
+          onLongPress: () => _copyToClipboard(context, destLabel),
+          child: _bubble(destLabel, Icons.location_on, colors.danger, colors),
+        ),
+      ],
+    );
+  }
+
+  void _copyToClipboard(BuildContext context, String text) {
+    final l10n = AppLocalizations.of(context);
+    Clipboard.setData(ClipboardData(text: text));
+    ScaffoldMessenger.of(context).showSnackBar(
+      SnackBar(
+        content: Center(child: Text(l10n.get('copiedToClipboard'))),
+        duration: const Duration(seconds: 1),
+        behavior: SnackBarBehavior.floating,
+        width: 120,
+      ),
+    );
+  }
+
+  Widget _bubble(
+    String label,
+    IconData icon,
+    Color color,
+    AppColorsExtension colors,
+  ) {
+    return Container(
+      padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
+      decoration: BoxDecoration(
+        color: color.withValues(alpha: 0.08),
+        borderRadius: BorderRadius.circular(8),
+      ),
+      child: Row(
+        children: [
+          Icon(icon, size: 18, color: color),
+          const SizedBox(width: 10),
+          Expanded(
+            child: Text(
+              label,
+              maxLines: 2,
+              overflow: TextOverflow.ellipsis,
+              style: TextStyle(
+                fontSize: 14,
+                fontWeight: FontWeight.w500,
+                color: colors.textPrimary,
+              ),
+            ),
+          ),
+        ],
+      ),
+    );
+  }
+
+  Widget _buildMapCard(AppColorsExtension colors) {
+    final dist = const Distance().as(LengthUnit.Kilometer, _origin, _dest);
+    final mid = LatLng(
+      (_origin.latitude + _dest.latitude) / 2,
+      (_origin.longitude + _dest.longitude) / 2,
+    );
+    final distText = dist >= 1
+        ? '${dist.toStringAsFixed(1)}km'
+        : '${(dist * 1000).toInt()}m';
+
+    return Container(
+      height: 200,
+      decoration: BoxDecoration(
+        borderRadius: BorderRadius.circular(12),
+        border: Border.all(color: colors.border, width: 0.5),
+      ),
+      clipBehavior: Clip.antiAlias,
+      child: FlutterMap(
+        options: MapOptions(
+          initialCameraFit: CameraFit.bounds(
+            bounds: LatLngBounds.fromPoints([_origin, _dest]),
+            padding: const EdgeInsets.all(40),
+          ),
+        ),
+        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',
+          ),
+          PolylineLayer(
+            polylines: [
+              Polyline(
+                points: [_origin, _dest],
+                color: colors.primary.withValues(alpha: 0.8),
+                strokeWidth: 3,
+              ),
+            ],
+          ),
+          MarkerLayer(
+            markers: [
+              Marker(
+                point: _origin,
+                width: 180,
+                height: 32,
+                child: _mapLabel(
+                  originLabel,
+                  Icons.trip_origin,
+                  colors.primary,
+                  colors.textPrimary,
+                ),
+              ),
+              Marker(
+                point: _dest,
+                width: 180,
+                height: 32,
+                child: _mapLabel(
+                  destLabel,
+                  Icons.location_on,
+                  colors.danger,
+                  colors.textPrimary,
+                ),
+              ),
+              Marker(
+                point: mid,
+                width: 80,
+                height: 24,
+                child: _distBadge(distText, colors),
+              ),
+            ],
+          ),
+        ],
+      ),
+    );
+  }
+
+  Widget _distBadge(String text, AppColorsExtension colors) {
+    return Container(
+      padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
+      alignment: Alignment.center,
+      decoration: BoxDecoration(
+        color: colors.primary.withValues(alpha: 0.85),
+        borderRadius: BorderRadius.circular(10),
+      ),
+      child: Text(
+        text,
+        textAlign: TextAlign.center,
+        style: const TextStyle(
+          fontSize: 10,
+          color: Colors.white,
+          fontWeight: FontWeight.w600,
+        ),
+      ),
+    );
+  }
+
+  Widget _mapLabel(
+    String text,
+    IconData icon,
+    Color iconColor,
+    Color textColor,
+  ) {
+    return Container(
+      padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
+      decoration: BoxDecoration(
+        color: Colors.white,
+        borderRadius: BorderRadius.circular(4),
+        boxShadow: [
+          BoxShadow(color: Colors.black.withValues(alpha: 0.2), blurRadius: 4),
+        ],
+      ),
+      child: Row(
+        mainAxisSize: MainAxisSize.min,
+        children: [
+          Icon(icon, size: 12, color: iconColor),
+          const SizedBox(width: 2),
+          Flexible(
+            child: Text(
+              text,
+              maxLines: 1,
+              overflow: TextOverflow.ellipsis,
+              style: TextStyle(fontSize: 10, color: textColor),
+            ),
+          ),
+        ],
+      ),
+    );
+  }
+}

+ 129 - 1
pubspec.lock

@@ -201,6 +201,14 @@ packages:
       url: "https://pub.flutter-io.cn"
     source: hosted
     version: "1.0.0+7.7.0"
+  dart_earcut:
+    dependency: transitive
+    description:
+      name: dart_earcut
+      sha256: e485001bfc05dcbc437d7bfb666316182e3522d4c3f9668048e004d0eb2ce43b
+      url: "https://pub.flutter-io.cn"
+    source: hosted
+    version: "1.2.0"
   dart_style:
     dependency: transitive
     description:
@@ -355,6 +363,14 @@ packages:
     description: flutter
     source: sdk
     version: "0.0.0"
+  flutter_map:
+    dependency: "direct main"
+    description:
+      name: flutter_map
+      sha256: "2ecb34619a4be19df6f40c2f8dce1591675b4eff7a6857bd8f533706977385da"
+      url: "https://pub.flutter-io.cn"
+    source: hosted
+    version: "7.0.2"
   flutter_plugin_android_lifecycle:
     dependency: transitive
     description:
@@ -413,6 +429,54 @@ packages:
       url: "https://pub.flutter-io.cn"
     source: hosted
     version: "4.0.0"
+  geolocator:
+    dependency: "direct main"
+    description:
+      name: geolocator
+      sha256: f4efb8d3c4cdcad2e226af9661eb1a0dd38c71a9494b22526f9da80ab79520e5
+      url: "https://pub.flutter-io.cn"
+    source: hosted
+    version: "10.1.1"
+  geolocator_android:
+    dependency: transitive
+    description:
+      name: geolocator_android
+      sha256: fcb1760a50d7500deca37c9a666785c047139b5f9ee15aa5469fae7dbbe3170d
+      url: "https://pub.flutter-io.cn"
+    source: hosted
+    version: "4.6.2"
+  geolocator_apple:
+    dependency: transitive
+    description:
+      name: geolocator_apple
+      sha256: "853803d6bb1713c094e935b4a5ae5f19c0308acf81da13fa9ff84fb4c70c0b73"
+      url: "https://pub.flutter-io.cn"
+    source: hosted
+    version: "2.3.14"
+  geolocator_platform_interface:
+    dependency: transitive
+    description:
+      name: geolocator_platform_interface
+      sha256: cdb082e4f048b69da244117b7914cc60d2a8897546ffaa4f2529c786ded7aee2
+      url: "https://pub.flutter-io.cn"
+    source: hosted
+    version: "4.2.8"
+  geolocator_web:
+    dependency: transitive
+    description:
+      name: geolocator_web
+      sha256: "102e7da05b48ca6bf0a5bda0010f886b171d1a08059f01bfe02addd0175ebece"
+      url: "https://pub.flutter-io.cn"
+    source: hosted
+    version: "2.2.1"
+  geolocator_windows:
+    dependency: transitive
+    description:
+      name: geolocator_windows
+      sha256: "175435404d20278ffd220de83c2ca293b73db95eafbdc8131fe8609be1421eb6"
+      url: "https://pub.flutter-io.cn"
+    source: hosted
+    version: "0.2.5"
   glob:
     dependency: transitive
     description:
@@ -446,7 +510,7 @@ packages:
     source: hosted
     version: "2.0.2"
   http:
-    dependency: transitive
+    dependency: "direct main"
     description:
       name: http
       sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412"
@@ -589,6 +653,14 @@ packages:
       url: "https://pub.flutter-io.cn"
     source: hosted
     version: "6.9.5"
+  latlong2:
+    dependency: "direct main"
+    description:
+      name: latlong2
+      sha256: "98227922caf49e6056f91b6c56945ea1c7b166f28ffcd5fb8e72fc0b453cc8fe"
+      url: "https://pub.flutter-io.cn"
+    source: hosted
+    version: "0.9.1"
   leak_tracker:
     dependency: transitive
     description:
@@ -621,6 +693,22 @@ packages:
       url: "https://pub.flutter-io.cn"
     source: hosted
     version: "6.1.0"
+  lists:
+    dependency: transitive
+    description:
+      name: lists
+      sha256: "4ca5c19ae4350de036a7e996cdd1ee39c93ac0a2b840f4915459b7d0a7d4ab27"
+      url: "https://pub.flutter-io.cn"
+    source: hosted
+    version: "1.0.1"
+  logger:
+    dependency: transitive
+    description:
+      name: logger
+      sha256: "25aee487596a6257655a1e091ec2ae66bc30e7af663592cc3a27e6591e05035c"
+      url: "https://pub.flutter-io.cn"
+    source: hosted
+    version: "2.7.0"
   logging:
     dependency: transitive
     description:
@@ -661,6 +749,14 @@ packages:
       url: "https://pub.flutter-io.cn"
     source: hosted
     version: "1.17.0"
+  mgrs_dart:
+    dependency: transitive
+    description:
+      name: mgrs_dart
+      sha256: fb89ae62f05fa0bb90f70c31fc870bcbcfd516c843fb554452ab3396f78586f7
+      url: "https://pub.flutter-io.cn"
+    source: hosted
+    version: "2.0.0"
   mime:
     dependency: transitive
     description:
@@ -789,6 +885,14 @@ packages:
       url: "https://pub.flutter-io.cn"
     source: hosted
     version: "2.1.8"
+  polylabel:
+    dependency: transitive
+    description:
+      name: polylabel
+      sha256: "41b9099afb2aa6c1730bdd8a0fab1400d287694ec7615dd8516935fa3144214b"
+      url: "https://pub.flutter-io.cn"
+    source: hosted
+    version: "1.0.1"
   pool:
     dependency: transitive
     description:
@@ -797,6 +901,14 @@ packages:
       url: "https://pub.flutter-io.cn"
     source: hosted
     version: "1.5.2"
+  proj4dart:
+    dependency: transitive
+    description:
+      name: proj4dart
+      sha256: c8a659ac9b6864aa47c171e78d41bbe6f5e1d7bd790a5814249e6b68bc44324e
+      url: "https://pub.flutter-io.cn"
+    source: hosted
+    version: "2.1.0"
   pub_semver:
     dependency: transitive
     description:
@@ -994,6 +1106,14 @@ packages:
       url: "https://pub.flutter-io.cn"
     source: hosted
     version: "1.4.0"
+  unicode:
+    dependency: transitive
+    description:
+      name: unicode
+      sha256: "0f69e46593d65245774d4f17125c6084d2c20b4e473a983f6e21b7d7762218f1"
+      url: "https://pub.flutter-io.cn"
+    source: hosted
+    version: "0.3.1"
   uuid:
     dependency: transitive
     description:
@@ -1058,6 +1178,14 @@ packages:
       url: "https://pub.flutter-io.cn"
     source: hosted
     version: "5.15.0"
+  wkt_parser:
+    dependency: transitive
+    description:
+      name: wkt_parser
+      sha256: "8a555fc60de3116c00aad67891bcab20f81a958e4219cc106e3c037aa3937f13"
+      url: "https://pub.flutter-io.cn"
+    source: hosted
+    version: "2.0.0"
   xdg_directories:
     dependency: transitive
     description:

+ 4 - 0
pubspec.yaml

@@ -26,6 +26,10 @@ dependencies:
   open_filex: ^4.5.0
   path_provider: ^2.1.0
   skeletonizer: ^2.1.0+1
+  flutter_map: ^7.0.2
+  latlong2: ^0.9.1
+  geolocator: ^10.1.0
+  http: ^1.2.2
 
 dev_dependencies:
   flutter_test: