trip_route_card.dart 7.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267
  1. import 'package:flutter/material.dart';
  2. import 'package:flutter/services.dart';
  3. import 'package:flutter_map/flutter_map.dart';
  4. import 'package:latlong2/latlong.dart';
  5. import '../../core/i18n/app_localizations.dart';
  6. import '../../core/theme/app_colors_extension.dart';
  7. class TripRouteCard extends StatelessWidget {
  8. final String originLabel;
  9. final String destLabel;
  10. final double? originLat;
  11. final double? originLng;
  12. final double? destLat;
  13. final double? destLng;
  14. const TripRouteCard({
  15. super.key,
  16. required this.originLabel,
  17. required this.destLabel,
  18. this.originLat,
  19. this.originLng,
  20. this.destLat,
  21. this.destLng,
  22. });
  23. bool get _hasCoords =>
  24. originLat != null &&
  25. originLng != null &&
  26. destLat != null &&
  27. destLng != null &&
  28. (originLat! != 0 || originLng! != 0) &&
  29. (destLat! != 0 || destLng! != 0) &&
  30. // 起终点重合会使 flutter_map 计算出 Infinity/NaN zoom 而崩溃(瓦片层 round 报错),隐藏地图
  31. (originLat != destLat || originLng != destLng);
  32. LatLng get _origin => LatLng(originLat!, originLng!);
  33. LatLng get _dest => LatLng(destLat!, destLng!);
  34. @override
  35. Widget build(BuildContext context) {
  36. final colors = Theme.of(context).extension<AppColorsExtension>()!;
  37. return Column(
  38. mainAxisSize: MainAxisSize.min,
  39. children: [
  40. _buildTextCard(context, colors),
  41. if (_hasCoords) ...[const SizedBox(height: 20), _buildMapCard(colors)],
  42. ],
  43. );
  44. }
  45. Widget _buildTextCard(BuildContext context, AppColorsExtension colors) {
  46. return Column(
  47. children: [
  48. GestureDetector(
  49. onLongPress: () => _copyToClipboard(context, originLabel),
  50. child: _bubble(
  51. originLabel,
  52. Icons.trip_origin,
  53. colors.primary,
  54. colors,
  55. ),
  56. ),
  57. Padding(
  58. padding: const EdgeInsets.only(left: 9),
  59. child: Column(
  60. children: List.generate(
  61. 3,
  62. (_) => Padding(
  63. padding: const EdgeInsets.symmetric(vertical: 3),
  64. child: Container(
  65. width: 2,
  66. height: 2,
  67. decoration: BoxDecoration(
  68. color: colors.textPlaceholder,
  69. shape: BoxShape.circle,
  70. ),
  71. ),
  72. ),
  73. ),
  74. ),
  75. ),
  76. GestureDetector(
  77. onLongPress: () => _copyToClipboard(context, destLabel),
  78. child: _bubble(destLabel, Icons.location_on, colors.danger, colors),
  79. ),
  80. ],
  81. );
  82. }
  83. void _copyToClipboard(BuildContext context, String text) {
  84. final l10n = AppLocalizations.of(context);
  85. Clipboard.setData(ClipboardData(text: text));
  86. ScaffoldMessenger.of(context).showSnackBar(
  87. SnackBar(
  88. content: Center(child: Text(l10n.get('copiedToClipboard'))),
  89. duration: const Duration(seconds: 1),
  90. behavior: SnackBarBehavior.floating,
  91. width: 120,
  92. ),
  93. );
  94. }
  95. Widget _bubble(
  96. String label,
  97. IconData icon,
  98. Color color,
  99. AppColorsExtension colors,
  100. ) {
  101. return Container(
  102. padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
  103. decoration: BoxDecoration(
  104. color: color.withValues(alpha: 0.08),
  105. borderRadius: BorderRadius.circular(8),
  106. ),
  107. child: Row(
  108. children: [
  109. Icon(icon, size: 18, color: color),
  110. const SizedBox(width: 10),
  111. Expanded(
  112. child: Text(
  113. label,
  114. maxLines: 2,
  115. overflow: TextOverflow.ellipsis,
  116. style: TextStyle(
  117. fontSize: 14,
  118. fontWeight: FontWeight.w500,
  119. color: colors.textPrimary,
  120. ),
  121. ),
  122. ),
  123. ],
  124. ),
  125. );
  126. }
  127. Widget _buildMapCard(AppColorsExtension colors) {
  128. final dist = const Distance().as(LengthUnit.Kilometer, _origin, _dest);
  129. final mid = LatLng(
  130. (_origin.latitude + _dest.latitude) / 2,
  131. (_origin.longitude + _dest.longitude) / 2,
  132. );
  133. final distText = dist >= 1
  134. ? '${dist.toStringAsFixed(1)}km'
  135. : '${(dist * 1000).toInt()}m';
  136. return Container(
  137. height: 200,
  138. decoration: BoxDecoration(
  139. borderRadius: BorderRadius.circular(12),
  140. border: Border.all(color: colors.border, width: 0.5),
  141. ),
  142. clipBehavior: Clip.antiAlias,
  143. child: FlutterMap(
  144. options: MapOptions(
  145. initialCameraFit: CameraFit.bounds(
  146. bounds: LatLngBounds.fromPoints([_origin, _dest]),
  147. padding: const EdgeInsets.all(40),
  148. // 高德瓦片最大 18 级:两点极近时 fit 出的 zoom 超限会导致瓦片 404,整图空白
  149. maxZoom: 18,
  150. ),
  151. ),
  152. children: [
  153. TileLayer(
  154. urlTemplate:
  155. 'https://webrd0{s}.is.autonavi.com/appmaptile?lang=zh_cn&size=1&scale=1&style=8&x={x}&y={y}&z={z}',
  156. subdomains: const ['1', '2', '3', '4'],
  157. userAgentPackageName: 'com.amtxts.tboss_oa_module',
  158. ),
  159. PolylineLayer(
  160. polylines: [
  161. Polyline(
  162. points: [_origin, _dest],
  163. color: colors.primary.withValues(alpha: 0.8),
  164. strokeWidth: 3,
  165. ),
  166. ],
  167. ),
  168. MarkerLayer(
  169. markers: [
  170. Marker(
  171. point: _origin,
  172. width: 180,
  173. height: 32,
  174. child: _mapLabel(
  175. originLabel,
  176. Icons.trip_origin,
  177. colors.primary,
  178. colors.textPrimary,
  179. ),
  180. ),
  181. Marker(
  182. point: _dest,
  183. width: 180,
  184. height: 32,
  185. child: _mapLabel(
  186. destLabel,
  187. Icons.location_on,
  188. colors.danger,
  189. colors.textPrimary,
  190. ),
  191. ),
  192. Marker(
  193. point: mid,
  194. width: 80,
  195. height: 24,
  196. child: _distBadge(distText, colors),
  197. ),
  198. ],
  199. ),
  200. ],
  201. ),
  202. );
  203. }
  204. Widget _distBadge(String text, AppColorsExtension colors) {
  205. return Container(
  206. padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
  207. alignment: Alignment.center,
  208. decoration: BoxDecoration(
  209. color: colors.primary.withValues(alpha: 0.85),
  210. borderRadius: BorderRadius.circular(10),
  211. ),
  212. child: Text(
  213. text,
  214. textAlign: TextAlign.center,
  215. style: const TextStyle(
  216. fontSize: 10,
  217. color: Colors.white,
  218. fontWeight: FontWeight.w600,
  219. ),
  220. ),
  221. );
  222. }
  223. Widget _mapLabel(
  224. String text,
  225. IconData icon,
  226. Color iconColor,
  227. Color textColor,
  228. ) {
  229. return Container(
  230. padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
  231. decoration: BoxDecoration(
  232. color: Colors.white,
  233. borderRadius: BorderRadius.circular(4),
  234. boxShadow: [
  235. BoxShadow(color: Colors.black.withValues(alpha: 0.2), blurRadius: 4),
  236. ],
  237. ),
  238. child: Row(
  239. mainAxisSize: MainAxisSize.min,
  240. children: [
  241. Icon(icon, size: 12, color: iconColor),
  242. const SizedBox(width: 2),
  243. Flexible(
  244. child: Text(
  245. text,
  246. maxLines: 1,
  247. overflow: TextOverflow.ellipsis,
  248. style: TextStyle(fontSize: 10, color: textColor),
  249. ),
  250. ),
  251. ],
  252. ),
  253. );
  254. }
  255. }