12 changed files with 525 additions and 76 deletions
@ -0,0 +1,107 @@ |
|||||
|
import 'package:flutter/material.dart'; |
||||
|
import 'package:flutter_screenutil/flutter_screenutil.dart'; |
||||
|
import 'package:get/get.dart'; |
||||
|
|
||||
|
import '../controllers/chat_controller.dart'; |
||||
|
|
||||
|
class ChatMusicCard extends GetView<ChatController> { |
||||
|
final Map? data; |
||||
|
final bool isDarkMode; |
||||
|
|
||||
|
const ChatMusicCard(this.data, this.isDarkMode, {super.key}); |
||||
|
|
||||
|
@override |
||||
|
Widget build(BuildContext context) { |
||||
|
final Map musiccards = data?['musiccards'].first; |
||||
|
final Map musicaudio = data?['musicaudio']; |
||||
|
|
||||
|
return Row( |
||||
|
children: [ |
||||
|
Container( |
||||
|
width: 60.w, |
||||
|
height: 60.w, |
||||
|
clipBehavior: Clip.hardEdge, |
||||
|
decoration: BoxDecoration( |
||||
|
borderRadius: BorderRadius.circular(6.r), |
||||
|
), |
||||
|
child: Image.network( |
||||
|
musiccards['image'], |
||||
|
fit: BoxFit.cover, |
||||
|
), |
||||
|
), |
||||
|
10.horizontalSpace, |
||||
|
Expanded( |
||||
|
child: Text( |
||||
|
musiccards['name'], |
||||
|
overflow: TextOverflow.ellipsis, |
||||
|
style: TextStyle( |
||||
|
color: isDarkMode ? Colors.white : Colors.black, |
||||
|
fontSize: 16.sp, |
||||
|
height: 1.5, |
||||
|
), |
||||
|
), |
||||
|
), |
||||
|
10.horizontalSpace, |
||||
|
Obx( |
||||
|
() => controller.audiourl == musicaudio['audiourl'] && |
||||
|
controller.isPlay.value |
||||
|
? GestureDetector( |
||||
|
onTap: () { |
||||
|
controller.pause(); |
||||
|
}, |
||||
|
child: Stack( |
||||
|
alignment: Alignment.center, |
||||
|
children: [ |
||||
|
SizedBox( |
||||
|
width: 38.w, |
||||
|
height: 38.w, |
||||
|
child: StreamBuilder<Duration>( |
||||
|
stream: controller.player.positionStream, |
||||
|
builder: (context, snapshot) { |
||||
|
final position = snapshot.data ?? Duration.zero; |
||||
|
final total = |
||||
|
controller.player.duration ?? Duration.zero; |
||||
|
final progress = total.inSeconds > 0 |
||||
|
? position.inSeconds / total.inSeconds |
||||
|
: 0.0; |
||||
|
return CircularProgressIndicator( |
||||
|
value: progress, |
||||
|
backgroundColor: isDarkMode |
||||
|
? Colors.grey[700]! |
||||
|
: Colors.grey[300]!, |
||||
|
valueColor: AlwaysStoppedAnimation<Color>( |
||||
|
isDarkMode ? Colors.white : Colors.black), |
||||
|
strokeWidth: 2, // 线条粗细 |
||||
|
); |
||||
|
}, |
||||
|
), |
||||
|
), |
||||
|
Icon( |
||||
|
Icons.pause_rounded, |
||||
|
color: isDarkMode ? Colors.white : Colors.black, |
||||
|
), |
||||
|
], |
||||
|
), |
||||
|
) |
||||
|
: GestureDetector( |
||||
|
onTap: () { |
||||
|
controller.play(musicaudio); |
||||
|
}, |
||||
|
child: Container( |
||||
|
width: 38.w, |
||||
|
height: 38.w, |
||||
|
decoration: BoxDecoration( |
||||
|
color: isDarkMode ? Colors.white : Colors.black, |
||||
|
shape: BoxShape.circle, |
||||
|
), |
||||
|
child: Icon( |
||||
|
Icons.play_arrow_rounded, |
||||
|
color: isDarkMode ? Colors.black : Colors.white, |
||||
|
), |
||||
|
), |
||||
|
), |
||||
|
), |
||||
|
], |
||||
|
); |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,179 @@ |
|||||
|
import 'dart:convert'; |
||||
|
import 'dart:math'; |
||||
|
import 'dart:math' as math; |
||||
|
|
||||
|
import 'package:flutter/material.dart'; |
||||
|
import 'package:http/http.dart' as http; |
||||
|
|
||||
|
class ChatNavigationCard extends StatefulWidget { |
||||
|
final Map? data; |
||||
|
final bool isDarkMode; |
||||
|
|
||||
|
const ChatNavigationCard(this.data, this.isDarkMode, {super.key}); |
||||
|
|
||||
|
@override |
||||
|
State<ChatNavigationCard> createState() => _ChatNavigationCardState(); |
||||
|
} |
||||
|
|
||||
|
class _ChatNavigationCardState extends State<ChatNavigationCard> { |
||||
|
final String apiKey = '8bde023b7e5e00f321ea17929f10fbae'; |
||||
|
LatLng startPoint = LatLng(116.397428, 39.90923); // 起点坐标 |
||||
|
LatLng endPoint = LatLng(116.397026, 39.903303); // 终点坐标 |
||||
|
|
||||
|
@override |
||||
|
void initState() { |
||||
|
super.initState(); |
||||
|
_getMapUrl(); |
||||
|
} |
||||
|
|
||||
|
Future<String> _getMapUrl() async { |
||||
|
// 1. 获取路径数据 |
||||
|
final route = await _fetchRouteData(); |
||||
|
|
||||
|
// 2. 解析并简化路径 |
||||
|
List<LatLng> simplified = _simplifyPath(route, epsilon: 0.0001); |
||||
|
|
||||
|
// 3. 计算地图参数 |
||||
|
final zoom = _calculateAutoZoom(simplified); |
||||
|
|
||||
|
// 4. 生成静态地图URL |
||||
|
return _buildStaticMapUrl(simplified, zoom); |
||||
|
} |
||||
|
|
||||
|
Future<List<LatLng>> _fetchRouteData() async { |
||||
|
final url = 'https://restapi.amap.com/v3/direction/driving?' |
||||
|
'origin=${startPoint.lng},${startPoint.lat}&' |
||||
|
'destination=${endPoint.lng},${endPoint.lat}&' |
||||
|
'key=$apiKey'; |
||||
|
|
||||
|
final response = await http.get(Uri.parse(url)); |
||||
|
if (response.statusCode == 200) { |
||||
|
final data = json.decode(response.body); |
||||
|
return _parsePolyline(data['route']['paths'][0]['steps']); |
||||
|
} |
||||
|
throw Exception('Failed to load route'); |
||||
|
} |
||||
|
|
||||
|
List<LatLng> _parsePolyline(List<dynamic> steps) { |
||||
|
final List<LatLng> path = []; |
||||
|
for (final step in steps) { |
||||
|
final points = (step['polyline'] as String).split(';'); |
||||
|
for (final point in points) { |
||||
|
final coords = point.split(',').map(double.parse).toList(); |
||||
|
path.add(LatLng(coords[0], coords[1])); |
||||
|
} |
||||
|
} |
||||
|
return path; |
||||
|
} |
||||
|
|
||||
|
List<LatLng> _simplifyPath(List<LatLng> points, {double epsilon = 0.0001}) { |
||||
|
return douglasPeucker(points, epsilon); |
||||
|
} |
||||
|
|
||||
|
int _calculateAutoZoom(List<LatLng> points, |
||||
|
{int mapWidth = 1024, int mapHeight = 512}) { |
||||
|
if (points.isEmpty) return 15; // 默认缩放级别 |
||||
|
|
||||
|
// 1. 计算坐标范围 |
||||
|
double minLng = double.infinity; |
||||
|
double maxLng = -double.infinity; |
||||
|
double minLat = double.infinity; |
||||
|
double maxLat = -double.infinity; |
||||
|
|
||||
|
for (final p in points) { |
||||
|
final lng = p.lng; |
||||
|
final lat = p.lat; |
||||
|
|
||||
|
minLng = math.min(minLng, lng); |
||||
|
maxLng = math.max(maxLng, lng); |
||||
|
minLat = math.min(minLat, lat); |
||||
|
maxLat = math.max(maxLat, lat); |
||||
|
} |
||||
|
|
||||
|
// 3. 计算缩放级别(基于Leaflet算法改进) |
||||
|
const worldWidth = 256; // 高德瓦片尺寸 |
||||
|
const worldHeight = 256; |
||||
|
const zoomMax = 18; // 高德最大缩放级别 |
||||
|
|
||||
|
double latRad(double lat) { |
||||
|
final sin = math.sin(lat * math.pi / 180); |
||||
|
return math.log((1 + sin) / (1 - sin)) / 2; |
||||
|
} |
||||
|
|
||||
|
final latFraction = (latRad(maxLat) - latRad(minLat)) / math.pi; |
||||
|
final lngDiff = maxLng - minLng; |
||||
|
final lngFraction = ((lngDiff < 0) ? (lngDiff + 360) : lngDiff) / 360; |
||||
|
|
||||
|
final latZoom = |
||||
|
math.log((mapHeight / worldHeight) / latFraction) / math.ln2; |
||||
|
final lngZoom = math.log((mapWidth / worldWidth) / lngFraction) / math.ln2; |
||||
|
|
||||
|
return math.min(math.min(latZoom, lngZoom).floor(), zoomMax); |
||||
|
} |
||||
|
|
||||
|
String _buildStaticMapUrl(List<LatLng> path, int zoom) { |
||||
|
final pathParam = path.map((p) => '${p.lng},${p.lat}').join(';'); |
||||
|
return 'https://restapi.amap.com/v3/staticmap?' |
||||
|
'zoom=$zoom&' |
||||
|
'size=1024*512&' |
||||
|
'paths=10,0x0000ff,1,,:$pathParam&' |
||||
|
'markers=mid,,A:${startPoint.lng},${startPoint.lat}|' |
||||
|
'mid,,B:${endPoint.lng},${endPoint.lat}&' |
||||
|
'key=$apiKey'; |
||||
|
} |
||||
|
|
||||
|
List<LatLng> douglasPeucker(List<LatLng> points, double epsilon) { |
||||
|
if (points.length < 3) return points; |
||||
|
|
||||
|
double maxDistance = 0; |
||||
|
int index = 0; |
||||
|
|
||||
|
for (int i = 1; i < points.length - 1; i++) { |
||||
|
double d = _perpendicularDistance(points[i], points.first, points.last); |
||||
|
if (d > maxDistance) { |
||||
|
index = i; |
||||
|
maxDistance = d; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
if (maxDistance > epsilon) { |
||||
|
return [ |
||||
|
...douglasPeucker(points.sublist(0, index + 1), epsilon), |
||||
|
...douglasPeucker(points.sublist(index), epsilon).skip(1) |
||||
|
]; |
||||
|
} |
||||
|
return [points.first, points.last]; |
||||
|
} |
||||
|
|
||||
|
double _perpendicularDistance(LatLng point, LatLng start, LatLng end) { |
||||
|
final area = (end.lng - start.lng) * (point.lat - start.lat) - |
||||
|
(end.lat - start.lat) * (point.lng - start.lng); |
||||
|
final length = |
||||
|
sqrt(pow(end.lng - start.lng, 2) + pow(end.lat - start.lat, 2)); |
||||
|
return area.abs() / length; |
||||
|
} |
||||
|
|
||||
|
@override |
||||
|
Widget build(BuildContext context) { |
||||
|
return Column( |
||||
|
children: [ |
||||
|
FutureBuilder( |
||||
|
future: _getMapUrl(), |
||||
|
builder: (BuildContext context, AsyncSnapshot snapshot) { |
||||
|
if (snapshot.hasData) { |
||||
|
return Image.network(snapshot.data); |
||||
|
} |
||||
|
return const CircularProgressIndicator(); |
||||
|
}, |
||||
|
), |
||||
|
], |
||||
|
); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
class LatLng { |
||||
|
final double lat; |
||||
|
final double lng; |
||||
|
|
||||
|
LatLng(this.lng, this.lat); // 高德使用经度在前纬度在后的格式 |
||||
|
} |
||||
@ -0,0 +1,94 @@ |
|||||
|
import 'package:flutter/material.dart'; |
||||
|
import 'package:flutter_screenutil/flutter_screenutil.dart'; |
||||
|
|
||||
|
class WeatherDayCard extends StatelessWidget { |
||||
|
final Map? data; |
||||
|
final bool isDarkMode; |
||||
|
|
||||
|
const WeatherDayCard(this.data, this.isDarkMode, {super.key}); |
||||
|
|
||||
|
@override |
||||
|
Widget build(BuildContext context) { |
||||
|
final Map weatherDays = data?['weatherDays'].first; |
||||
|
|
||||
|
return Row( |
||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween, |
||||
|
crossAxisAlignment: CrossAxisAlignment.start, |
||||
|
children: [ |
||||
|
Column( |
||||
|
crossAxisAlignment: CrossAxisAlignment.start, |
||||
|
children: [ |
||||
|
Text( |
||||
|
weatherDays['date'], |
||||
|
style: TextStyle( |
||||
|
color: isDarkMode ? Colors.white : Colors.black, |
||||
|
fontSize: 10.sp, |
||||
|
), |
||||
|
), |
||||
|
Text( |
||||
|
'${weatherDays['maxTemperature']}°', |
||||
|
style: TextStyle( |
||||
|
color: isDarkMode ? Colors.white : Colors.black, |
||||
|
fontSize: 30.sp, |
||||
|
), |
||||
|
), |
||||
|
], |
||||
|
), |
||||
|
Column( |
||||
|
crossAxisAlignment: CrossAxisAlignment.end, |
||||
|
children: [ |
||||
|
Icon( |
||||
|
Icons.sunny, |
||||
|
color: isDarkMode ? Colors.white : Colors.amber[600]!, |
||||
|
size: 18.w, |
||||
|
), |
||||
|
Text( |
||||
|
'${weatherDays['weatherCondition']},${weatherDays['windDirection']}', |
||||
|
style: TextStyle( |
||||
|
color: isDarkMode ? Colors.white : Colors.black, |
||||
|
fontSize: 14.sp, |
||||
|
), |
||||
|
), |
||||
|
Row( |
||||
|
children: [ |
||||
|
Text( |
||||
|
'最\n高', |
||||
|
style: TextStyle( |
||||
|
color: isDarkMode ? Colors.white : Colors.black, |
||||
|
fontSize: 10.sp, |
||||
|
height: 1, |
||||
|
), |
||||
|
), |
||||
|
5.horizontalSpace, |
||||
|
Text( |
||||
|
'${weatherDays['maxTemperature']}°', |
||||
|
style: TextStyle( |
||||
|
color: isDarkMode ? Colors.white : Colors.black, |
||||
|
fontSize: 20.sp, |
||||
|
), |
||||
|
), |
||||
|
10.horizontalSpace, |
||||
|
Text( |
||||
|
'最\n低', |
||||
|
style: TextStyle( |
||||
|
color: isDarkMode ? Colors.white : Colors.black, |
||||
|
fontSize: 10.sp, |
||||
|
height: 1, |
||||
|
), |
||||
|
), |
||||
|
5.horizontalSpace, |
||||
|
Text( |
||||
|
'${weatherDays['minTemperature']}°', |
||||
|
style: TextStyle( |
||||
|
color: isDarkMode ? Colors.white : Colors.black, |
||||
|
fontSize: 20.sp, |
||||
|
), |
||||
|
), |
||||
|
], |
||||
|
), |
||||
|
], |
||||
|
), |
||||
|
], |
||||
|
); |
||||
|
} |
||||
|
} |
||||
Loading…
Reference in new issue