You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
1237 lines
40 KiB
1237 lines
40 KiB
import 'package:common_utils/common_utils.dart';
|
|
import 'package:deep_voice/modules/agent/views/message_bubble_navigation.dart';
|
|
import 'package:flutter/material.dart';
|
|
import 'package:get/get.dart';
|
|
import 'package:intl/intl.dart';
|
|
import 'package:get_storage/get_storage.dart';
|
|
import 'dart:io';
|
|
|
|
import '../controllers/agent_controller.dart';
|
|
|
|
/// 消息气泡组件
|
|
class MessageBubble extends GetView<AgentController> {
|
|
final bool isUser;
|
|
final String message;
|
|
final DateTime timestamp;
|
|
final bool isRecognizing;
|
|
final bool hasImage;
|
|
final String? imagePath;
|
|
final Map? meta;
|
|
|
|
const MessageBubble({
|
|
Key? key,
|
|
required this.isUser,
|
|
required this.message,
|
|
required this.timestamp,
|
|
this.isRecognizing = false,
|
|
this.hasImage = false,
|
|
this.imagePath,
|
|
this.meta,
|
|
}) : super(key: key);
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final timeFormat = DateFormat('HH:mm');
|
|
|
|
return Padding(
|
|
padding: const EdgeInsets.symmetric(vertical: 8),
|
|
child: Row(
|
|
mainAxisAlignment:
|
|
isUser ? MainAxisAlignment.end : MainAxisAlignment.start,
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
//if (!isUser) _buildAvatar(isUser, context),
|
|
//const SizedBox(width: 8),
|
|
Flexible(
|
|
child: Column(
|
|
crossAxisAlignment:
|
|
isUser ? CrossAxisAlignment.end : CrossAxisAlignment.start,
|
|
children: [
|
|
Container(
|
|
padding: hasImage
|
|
? (message == '[图片]'
|
|
? EdgeInsets.zero
|
|
: isUser
|
|
? const EdgeInsets.fromLTRB(
|
|
12, 12, 16, 12) // 用户消息右侧多一点内边距
|
|
: const EdgeInsets.fromLTRB(
|
|
16, 12, 12, 12)) // AI消息左侧多一点内边距
|
|
: isUser
|
|
? const EdgeInsets.fromLTRB(
|
|
12, 12, 16, 12) // 用户消息右侧多一点内边距
|
|
: const EdgeInsets.fromLTRB(
|
|
16, 12, 12, 12), // AI消息左侧多一点内边距
|
|
decoration: BoxDecoration(
|
|
color: _getBubbleColor(context),
|
|
borderRadius: BorderRadius.circular(18).copyWith(
|
|
topLeft: isUser
|
|
? const Radius.circular(18)
|
|
: const Radius.circular(4),
|
|
topRight: !isUser
|
|
? const Radius.circular(18)
|
|
: const Radius.circular(4),
|
|
),
|
|
boxShadow: [
|
|
BoxShadow(
|
|
color: Colors.black.withOpacity(0.05),
|
|
blurRadius: 2,
|
|
offset: const Offset(0, 1),
|
|
),
|
|
],
|
|
),
|
|
child: _buildMessageContent(context),
|
|
),
|
|
const SizedBox(height: 4),
|
|
// 识别中的消息不显示时间
|
|
if (!isRecognizing)
|
|
Text(
|
|
timeFormat.format(timestamp),
|
|
style: TextStyle(
|
|
fontSize: 10,
|
|
color: Theme.of(context).brightness == Brightness.dark
|
|
? Colors.grey[400]
|
|
: Colors.grey[500],
|
|
fontWeight: FontWeight.w400,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
//const SizedBox(width: 8),
|
|
//if (isUser) _buildAvatar(isUser, context),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
/// 获取气泡颜色
|
|
Color _getBubbleColor(BuildContext context) {
|
|
final isDarkMode = Theme.of(context).brightness == Brightness.dark;
|
|
|
|
if (isUser) {
|
|
return isRecognizing
|
|
? Color(0xFF3B82F6).withOpacity(0.8) // 识别中的用户消息使用半透明的蓝色
|
|
: Color(0xFF3B82F6); // 使用更明亮的蓝色
|
|
} else {
|
|
return isDarkMode
|
|
? Color(0xFF2A2A2A)
|
|
: Color(0xFFF1F5F9); // 根据主题调整AI消息背景色
|
|
}
|
|
}
|
|
|
|
/// 构建头像
|
|
Widget _buildAvatar(bool isUser, BuildContext context) {
|
|
if (isUser) {
|
|
final storage = GetStorage();
|
|
final localAvatarPath = storage.read<String>('local_avatar') ?? '';
|
|
|
|
return Container(
|
|
width: 36,
|
|
height: 36,
|
|
decoration: BoxDecoration(
|
|
color: localAvatarPath.isEmpty ? Colors.grey[300] : null,
|
|
borderRadius: BorderRadius.circular(18),
|
|
boxShadow: [
|
|
BoxShadow(
|
|
color: Colors.black.withOpacity(0.05),
|
|
blurRadius: 2,
|
|
offset: const Offset(0, 1),
|
|
),
|
|
],
|
|
),
|
|
child: localAvatarPath.isNotEmpty
|
|
? ClipRRect(
|
|
borderRadius: BorderRadius.circular(18),
|
|
child: Image.file(
|
|
File(localAvatarPath),
|
|
fit: BoxFit.cover,
|
|
),
|
|
)
|
|
: Icon(
|
|
Icons.person,
|
|
color: Colors.grey[700],
|
|
size: 20,
|
|
),
|
|
);
|
|
} else {
|
|
// AI头像保持不变
|
|
return Container(
|
|
width: 36,
|
|
height: 36,
|
|
decoration: BoxDecoration(
|
|
color: Theme.of(context).primaryColor,
|
|
borderRadius: BorderRadius.circular(18),
|
|
boxShadow: [
|
|
BoxShadow(
|
|
color: Colors.black.withOpacity(0.05),
|
|
blurRadius: 2,
|
|
offset: const Offset(0, 1),
|
|
),
|
|
],
|
|
),
|
|
child: Icon(
|
|
Icons.smart_toy_outlined,
|
|
color: Colors.white,
|
|
size: 20,
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// 构建消息内容
|
|
Widget _buildMessageContent(BuildContext context) {
|
|
// 如果是识别中的消息,添加特殊样式
|
|
if (isRecognizing) {
|
|
return Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Flexible(
|
|
child: Text(
|
|
message,
|
|
style: const TextStyle(
|
|
color: Colors.white,
|
|
fontStyle: FontStyle.italic,
|
|
fontSize: 14,
|
|
height: 1.4,
|
|
),
|
|
overflow: TextOverflow.ellipsis,
|
|
),
|
|
),
|
|
const SizedBox(width: 8),
|
|
_buildTypingIndicator(),
|
|
],
|
|
);
|
|
}
|
|
|
|
// 如果是图片消息,显示图片
|
|
if (hasImage && imagePath != null) {
|
|
// 如果消息是默认的[图片]标记,只显示图片
|
|
if (message == '[图片]') {
|
|
return ClipRRect(
|
|
borderRadius: BorderRadius.circular(16),
|
|
child: Image.file(
|
|
File(imagePath!),
|
|
width: 200,
|
|
height: 200,
|
|
fit: BoxFit.cover,
|
|
),
|
|
);
|
|
}
|
|
|
|
// 如果消息是自定义文本,显示文本+图片
|
|
return Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
message,
|
|
style: TextStyle(
|
|
color: isUser ? Colors.white : Colors.black87,
|
|
fontSize: 14,
|
|
height: 1.4,
|
|
fontWeight: FontWeight.w400,
|
|
),
|
|
),
|
|
const SizedBox(height: 8),
|
|
ClipRRect(
|
|
borderRadius: BorderRadius.circular(12),
|
|
child: Image.file(
|
|
File(imagePath!),
|
|
width: 200,
|
|
height: 200,
|
|
fit: BoxFit.cover,
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
// 判断消息内容,显示特殊卡片
|
|
if (!isUser && meta != null) {
|
|
// 判断消息类型,显示不同的卡片
|
|
if (meta!.keys.contains('card_music')) {
|
|
final musicId = meta!['card_music']['id'].toString();
|
|
final musicName = meta!['card_music']['name'] as String;
|
|
final musicUrl = meta!['card_music']['url'] as String;
|
|
final musicSinger = meta!['card_music']['sgener'] as String;
|
|
final musicImage = meta!['card_music']['image'] as String;
|
|
//音乐卡片
|
|
return _buildMusicItem(
|
|
context, musicId, musicName, musicUrl, musicSinger, musicImage);
|
|
} else if (meta!.keys.contains('card_musiclist')) {
|
|
final rawList = meta!['card_musiclist']['musics'] as List;
|
|
final playlist =
|
|
rawList.map((e) => Map<String, dynamic>.from(e)).toList();
|
|
//音乐播放列表
|
|
return _buildMusicContent(context, playlist);
|
|
} else if (meta!.keys.contains('card_weather')) {
|
|
final city = meta!['card_weather']['city'] as String;
|
|
final rawList = meta!['card_weather']['dayweather'] as List;
|
|
final daylist =
|
|
rawList.map((e) => Map<String, dynamic>.from(e)).toList();
|
|
final now = meta!['card_weather']['day'] as Map<String, dynamic>;
|
|
//天气
|
|
return _buildWeatherContent(
|
|
context,
|
|
city,
|
|
now,
|
|
daylist,
|
|
);
|
|
} else if (meta!.keys.contains('card_travel')) {
|
|
final rawList = meta!['card_travel']['items'] as List;
|
|
final travellist =
|
|
rawList.map((e) => Map<String, dynamic>.from(e)).toList();
|
|
// 旅游
|
|
return _buildTravelContent(context, travellist);
|
|
} else if (meta!.keys.contains('card_navigation')) {
|
|
final rawList = meta!['card_navigation']['items'] as List;
|
|
final list = rawList.map((e) => Map<String, dynamic>.from(e)).toList();
|
|
// 导航
|
|
return navigationCard(context, modes: list);
|
|
}
|
|
|
|
// if (message.contains('播放列表') || message.contains('音乐')) {
|
|
// return _buildMusicContent(context);
|
|
// } else if (message.contains('景点') || message.contains('旅游')) {
|
|
// return _buildTravelContent(context);
|
|
// } else if (message.contains('天气') ||
|
|
// message.contains('气温') ||
|
|
// message.contains('预报')) {
|
|
// return _buildWeatherContent(context);
|
|
// }
|
|
}
|
|
|
|
return Text(
|
|
message,
|
|
style: TextStyle(
|
|
color: isUser
|
|
? Colors.white
|
|
: (Theme.of(context).brightness == Brightness.dark
|
|
? Colors.white.withOpacity(0.9)
|
|
: Colors.black87),
|
|
fontSize: 14,
|
|
height: 1.4,
|
|
fontWeight: FontWeight.w400,
|
|
),
|
|
);
|
|
}
|
|
|
|
/// 构建输入中的指示器
|
|
Widget _buildTypingIndicator() {
|
|
return SizedBox(
|
|
width: 40,
|
|
height: 20,
|
|
child: Row(
|
|
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
|
children: [
|
|
_buildDot(100),
|
|
_buildDot(300),
|
|
_buildDot(500),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
/// 构建动画点
|
|
Widget _buildDot(int milliseconds) {
|
|
return _AnimatedDot(delayMilliseconds: milliseconds);
|
|
}
|
|
|
|
/// 构建音乐内容卡片
|
|
Widget _buildMusicContent(
|
|
BuildContext context, List<Map<String, dynamic>> playlist) {
|
|
final isDarkMode = Theme.of(context).brightness == Brightness.dark;
|
|
|
|
return Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
'以下是一些推荐的音乐:',
|
|
style: TextStyle(
|
|
color: isDarkMode ? Colors.white.withOpacity(0.9) : Colors.black87,
|
|
fontSize: 14,
|
|
height: 1.4,
|
|
fontWeight: FontWeight.w400,
|
|
),
|
|
),
|
|
const SizedBox(height: 12),
|
|
// Container(
|
|
// padding: const EdgeInsets.all(12),
|
|
// decoration: BoxDecoration(
|
|
// color: Colors.white,
|
|
// borderRadius: BorderRadius.circular(12),
|
|
// boxShadow: [
|
|
// BoxShadow(
|
|
// color: Colors.black.withOpacity(0.03),
|
|
// blurRadius: 3,
|
|
// spreadRadius: 1,
|
|
// offset: const Offset(0, 1),
|
|
// ),
|
|
// ],
|
|
// ),
|
|
// child:
|
|
Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Row(
|
|
children: [
|
|
Icon(
|
|
Icons.music_note,
|
|
size: 16,
|
|
color: Theme.of(context).primaryColor.withOpacity(0.7),
|
|
),
|
|
const SizedBox(width: 6),
|
|
Text(
|
|
'播放列表'.tr,
|
|
style: TextStyle(
|
|
fontSize: 13,
|
|
fontWeight: FontWeight.w600,
|
|
color: Theme.of(context).brightness == Brightness.dark
|
|
? Colors.grey[300]
|
|
: Colors.grey[700],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 10),
|
|
|
|
// 动态生成音乐条目
|
|
...playlist.asMap().entries.take(4).map((entry) {
|
|
final index = entry.key;
|
|
final item = entry.value;
|
|
final id = item["id"]?.toString() ?? "";
|
|
final name = item["name"] ?? "未知歌曲";
|
|
final url = item["url"] ?? "";
|
|
final singer = item["sgener"] ?? "未知歌手";
|
|
final image = item["image"] ?? "";
|
|
return Column(
|
|
children: [
|
|
_buildMusicItem(context, id, name, url, singer, image),
|
|
if (index != playlist.length - 1)
|
|
Divider(
|
|
height: 1,
|
|
thickness: 1,
|
|
color: Theme.of(context).brightness == Brightness.dark
|
|
? Colors.grey[800]
|
|
: Colors.grey[100],
|
|
),
|
|
],
|
|
);
|
|
}).toList(),
|
|
const SizedBox(height: 8),
|
|
Center(
|
|
child: TextButton(
|
|
onPressed: () {},
|
|
style: TextButton.styleFrom(
|
|
minimumSize: Size.zero,
|
|
padding: EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
|
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
|
),
|
|
child: Text(
|
|
'查看更多推荐',
|
|
style: TextStyle(
|
|
fontSize: 12,
|
|
color: Theme.of(context).primaryColor,
|
|
fontWeight: FontWeight.w500,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
//),
|
|
//const SizedBox(height: 10),
|
|
// Row(
|
|
// mainAxisSize: MainAxisSize.min,
|
|
// children: [
|
|
// Expanded(
|
|
// child: OutlinedButton.icon(
|
|
// onPressed: () {},
|
|
// icon: Icon(Icons.play_arrow, size: 16),
|
|
// label: Text('播放全部'),
|
|
// style: OutlinedButton.styleFrom(
|
|
// foregroundColor: Theme.of(context).primaryColor,
|
|
// side: BorderSide(
|
|
// color: Theme.of(context).primaryColor.withOpacity(0.5)),
|
|
// padding: EdgeInsets.symmetric(horizontal: 12, vertical: 4),
|
|
// shape: RoundedRectangleBorder(
|
|
// borderRadius: BorderRadius.circular(16),
|
|
// ),
|
|
// textStyle: TextStyle(fontSize: 12),
|
|
// ),
|
|
// ),
|
|
// ),
|
|
// SizedBox(width: 8),
|
|
// Expanded(
|
|
// child: OutlinedButton.icon(
|
|
// onPressed: () {},
|
|
// icon: Icon(Icons.shuffle, size: 16),
|
|
// label: Text('随机播放'),
|
|
// style: OutlinedButton.styleFrom(
|
|
// foregroundColor: Colors.grey[700],
|
|
// side: BorderSide(color: Colors.grey[400]!),
|
|
// padding: EdgeInsets.symmetric(horizontal: 12, vertical: 4),
|
|
// shape: RoundedRectangleBorder(
|
|
// borderRadius: BorderRadius.circular(16),
|
|
// ),
|
|
// textStyle: TextStyle(fontSize: 12),
|
|
// ),
|
|
// ),
|
|
// ),
|
|
// ],
|
|
// ),
|
|
],
|
|
);
|
|
}
|
|
|
|
/// 构建旅游内容卡片
|
|
Widget _buildTravelContent(
|
|
BuildContext context, List<Map<String, dynamic>> travellist) {
|
|
return Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
const Text(
|
|
'以下是一些推搜索结果:',
|
|
style: TextStyle(
|
|
color: Colors.black87,
|
|
fontSize: 14,
|
|
height: 1.4,
|
|
fontWeight: FontWeight.w400,
|
|
),
|
|
),
|
|
const SizedBox(height: 12),
|
|
Container(
|
|
padding: const EdgeInsets.all(12),
|
|
decoration: BoxDecoration(
|
|
color: Colors.white,
|
|
borderRadius: BorderRadius.circular(12),
|
|
boxShadow: [
|
|
BoxShadow(
|
|
color: Colors.black.withOpacity(0.03),
|
|
blurRadius: 3,
|
|
spreadRadius: 1,
|
|
offset: const Offset(0, 1),
|
|
),
|
|
],
|
|
),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
// Row(
|
|
// children: [
|
|
// Icon(
|
|
// Icons.place,
|
|
// size: 16,
|
|
// color: Color(0xFFE11D48),
|
|
// ),
|
|
// const SizedBox(width: 6),
|
|
// Text(
|
|
// '热门景点 · 东京',
|
|
// style: TextStyle(
|
|
// fontSize: 13,
|
|
// fontWeight: FontWeight.w600,
|
|
// color: Colors.grey[700],
|
|
// ),
|
|
// ),
|
|
// ],
|
|
// ),
|
|
// const SizedBox(height: 10),
|
|
|
|
// 动态生成音乐条目
|
|
...travellist.asMap().entries.map((entry) {
|
|
final index = entry.key;
|
|
final item = entry.value;
|
|
final name = item["name"] ?? "未知歌曲"; //名字
|
|
final addr = item["addr"] ?? ""; //地址
|
|
final tag = item["tag"] ?? "未知歌手"; //标签
|
|
final rating = item["rating"] ?? ""; //评分
|
|
final distance = item["distance"] ?? ""; //距离
|
|
final images = item["images"] ?? []; // 图片列表
|
|
return Column(
|
|
children: [
|
|
_buildLocationItem(
|
|
context,
|
|
name,
|
|
addr,
|
|
),
|
|
if (index != travellist.length - 1)
|
|
Divider(height: 1, thickness: 1, color: Colors.grey[100]),
|
|
],
|
|
);
|
|
}).toList(),
|
|
// // 景点列表
|
|
// _buildLocationItem('东京塔', '标志性建筑,可俯瞰东京全景', context),
|
|
// Divider(height: 1, thickness: 1, color: Colors.grey[100]),
|
|
// _buildLocationItem('涩谷十字路口', '世界上最繁忙的十字路口之一', context),
|
|
// Divider(height: 1, thickness: 1, color: Colors.grey[100]),
|
|
// _buildLocationItem('浅草寺', '东京最古老的寺庙', context),
|
|
|
|
const SizedBox(height: 8),
|
|
Center(
|
|
child: TextButton(
|
|
onPressed: () {},
|
|
style: TextButton.styleFrom(
|
|
minimumSize: Size.zero,
|
|
padding: EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
|
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
|
),
|
|
child: Text(
|
|
'查看更多景点',
|
|
style: TextStyle(
|
|
fontSize: 12,
|
|
color: Theme.of(context).primaryColor,
|
|
fontWeight: FontWeight.w500,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
const SizedBox(height: 10),
|
|
Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Expanded(
|
|
child: OutlinedButton.icon(
|
|
onPressed: () {},
|
|
icon: Icon(Icons.map, size: 16),
|
|
label: Text('查看地图'),
|
|
style: OutlinedButton.styleFrom(
|
|
foregroundColor: Theme.of(context).primaryColor,
|
|
side: BorderSide(
|
|
color: Theme.of(context).primaryColor.withOpacity(0.5)),
|
|
padding: EdgeInsets.symmetric(horizontal: 12, vertical: 4),
|
|
shape: RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.circular(16),
|
|
),
|
|
textStyle: TextStyle(fontSize: 12),
|
|
),
|
|
),
|
|
),
|
|
SizedBox(width: 8),
|
|
Expanded(
|
|
child: OutlinedButton.icon(
|
|
onPressed: () {},
|
|
icon: Icon(Icons.bookmark_border, size: 16),
|
|
label: Text('保存到收藏'),
|
|
style: OutlinedButton.styleFrom(
|
|
foregroundColor: Colors.grey[700],
|
|
side: BorderSide(color: Colors.grey[400]!),
|
|
padding: EdgeInsets.symmetric(horizontal: 12, vertical: 4),
|
|
shape: RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.circular(16),
|
|
),
|
|
textStyle: TextStyle(fontSize: 12),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
/// 构建音乐项目
|
|
Widget _buildMusicItem(BuildContext context, String id, String name,
|
|
String url, String singer, String image) {
|
|
final controller = Get.find<AgentController>(); // 获取控制器
|
|
final isDarkMode = Theme.of(context).brightness == Brightness.dark;
|
|
|
|
Map<String, String> song = {
|
|
'id': id,
|
|
'url': url,
|
|
'title': name,
|
|
'artist': singer,
|
|
'coverUrl': image,
|
|
};
|
|
controller.musicStatus(); //刷新状态
|
|
return Container(
|
|
padding: const EdgeInsets.all(12),
|
|
decoration: BoxDecoration(
|
|
color: isDarkMode ? Color(0xFF1E1E1E) : Colors.white,
|
|
borderRadius: BorderRadius.circular(12),
|
|
boxShadow: [
|
|
BoxShadow(
|
|
color: Colors.black.withOpacity(isDarkMode ? 0.2 : 0.03),
|
|
blurRadius: 3,
|
|
spreadRadius: 1,
|
|
offset: const Offset(0, 1),
|
|
),
|
|
],
|
|
),
|
|
child: Padding(
|
|
padding: const EdgeInsets.symmetric(vertical: 8),
|
|
child: Row(
|
|
children: [
|
|
Container(
|
|
width: 50,
|
|
height: 50,
|
|
decoration: BoxDecoration(
|
|
color: Colors.grey[100],
|
|
borderRadius: BorderRadius.circular(8),
|
|
image: image.isNotEmpty
|
|
? DecorationImage(
|
|
image: NetworkImage(image),
|
|
fit: BoxFit.cover,
|
|
)
|
|
: null,
|
|
),
|
|
child: image.isEmpty
|
|
? Icon(
|
|
Icons.music_note,
|
|
size: 18,
|
|
color: Theme.of(context).primaryColor,
|
|
)
|
|
: null,
|
|
),
|
|
const SizedBox(width: 12),
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
name,
|
|
style: TextStyle(
|
|
fontSize: 13,
|
|
fontWeight: FontWeight.w500,
|
|
color: Colors.grey[800],
|
|
),
|
|
overflow: TextOverflow.ellipsis,
|
|
),
|
|
SizedBox(height: 2),
|
|
Text(
|
|
singer,
|
|
style: TextStyle(
|
|
fontSize: 12,
|
|
color: Colors.grey[600],
|
|
),
|
|
overflow: TextOverflow.ellipsis,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
Obx(() {
|
|
final isThisPlaying = controller.isPlaying.value &&
|
|
controller.currentUrl.value == url;
|
|
|
|
return IconButton(
|
|
onPressed: () {
|
|
if (controller.isPlaying.value) {
|
|
if (controller.currentUrl.value == url) {
|
|
controller.musicPause();
|
|
} else {
|
|
controller.musicPlay(song);
|
|
}
|
|
} else {
|
|
if (controller.currentUrl.value == url) {
|
|
controller.musicResume();
|
|
} else {
|
|
controller.musicPlay(song);
|
|
}
|
|
}
|
|
},
|
|
icon: Icon(
|
|
isThisPlaying ? Icons.pause_circle : Icons.play_circle,
|
|
color: Theme.of(context).primaryColor,
|
|
),
|
|
padding: EdgeInsets.zero,
|
|
constraints: BoxConstraints(),
|
|
iconSize: 36,
|
|
);
|
|
}),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
/// 构建地点项目
|
|
Widget _buildLocationItem(
|
|
BuildContext context, String name, String description) {
|
|
final isDarkMode = Theme.of(context).brightness == Brightness.dark;
|
|
|
|
return Padding(
|
|
padding: const EdgeInsets.symmetric(vertical: 8),
|
|
child: Row(
|
|
children: [
|
|
Container(
|
|
width: 38,
|
|
height: 38,
|
|
decoration: BoxDecoration(
|
|
color: isDarkMode ? Color(0xFF1A365D) : Colors.blue[50],
|
|
borderRadius: BorderRadius.circular(8),
|
|
),
|
|
child: Icon(
|
|
Icons.place,
|
|
size: 18,
|
|
color: Color(0xFFE11D48),
|
|
),
|
|
),
|
|
const SizedBox(width: 12),
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
name,
|
|
style: TextStyle(
|
|
fontSize: 13,
|
|
fontWeight: FontWeight.w500,
|
|
color: isDarkMode
|
|
? Colors.white.withOpacity(0.9)
|
|
: Colors.grey[800],
|
|
),
|
|
overflow: TextOverflow.ellipsis,
|
|
),
|
|
SizedBox(height: 2),
|
|
Text(
|
|
description,
|
|
style: TextStyle(
|
|
fontSize: 12,
|
|
color: isDarkMode ? Colors.grey[400] : Colors.grey[600],
|
|
),
|
|
overflow: TextOverflow.ellipsis,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
IconButton(
|
|
onPressed: () {},
|
|
icon: Icon(Icons.arrow_forward, color: Colors.grey[400]),
|
|
padding: EdgeInsets.zero,
|
|
constraints: BoxConstraints(),
|
|
iconSize: 20,
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
/// 构建天气内容卡片
|
|
Widget _buildWeatherContent(BuildContext context, String city,
|
|
Map<String, dynamic> now, List<Map<String, dynamic>> days) {
|
|
final isDarkMode = Theme.of(context).brightness == Brightness.dark;
|
|
|
|
return Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
'查询天气情况:',
|
|
style: TextStyle(
|
|
color: isDarkMode ? Colors.white.withOpacity(0.9) : Colors.black87,
|
|
fontSize: 14,
|
|
height: 1.4,
|
|
fontWeight: FontWeight.w400,
|
|
),
|
|
),
|
|
const SizedBox(height: 12),
|
|
Container(
|
|
padding: const EdgeInsets.all(16),
|
|
decoration: BoxDecoration(
|
|
gradient: isDarkMode
|
|
? LinearGradient(
|
|
begin: Alignment.topLeft,
|
|
end: Alignment.bottomRight,
|
|
colors: [Color(0xFF1E40AF), Color(0xFF1E3A8A)],
|
|
)
|
|
: LinearGradient(
|
|
begin: Alignment.topLeft,
|
|
end: Alignment.bottomRight,
|
|
colors: [Color(0xFF60A5FA), Color(0xFF3B82F6)],
|
|
),
|
|
borderRadius: BorderRadius.circular(12),
|
|
boxShadow: [
|
|
BoxShadow(
|
|
color: Colors.black.withOpacity(isDarkMode ? 0.3 : 0.1),
|
|
blurRadius: 8,
|
|
offset: const Offset(0, 2),
|
|
),
|
|
],
|
|
),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Row(
|
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
"${city} ${now["date"]}",
|
|
style: const TextStyle(
|
|
fontSize: 16,
|
|
fontWeight: FontWeight.w600,
|
|
color: Colors.white,
|
|
),
|
|
),
|
|
SizedBox(height: 4),
|
|
Text(
|
|
now["weatherCondition"],
|
|
style: TextStyle(
|
|
fontSize: 14,
|
|
color: Colors.white.withOpacity(0.9),
|
|
),
|
|
),
|
|
SizedBox(height: 12),
|
|
Row(
|
|
children: [
|
|
Text(
|
|
"${now["temperature"]}°",
|
|
style: const TextStyle(
|
|
fontSize: 36,
|
|
fontWeight: FontWeight.bold,
|
|
color: Colors.white,
|
|
),
|
|
),
|
|
SizedBox(width: 8),
|
|
Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
'最高: ${now["maxTemperature"]}°',
|
|
style: TextStyle(
|
|
fontSize: 12,
|
|
color: Colors.white.withOpacity(0.9),
|
|
),
|
|
),
|
|
Text(
|
|
'最低: ${now["minTemperature"]}°',
|
|
style: TextStyle(
|
|
fontSize: 12,
|
|
color: Colors.white.withOpacity(0.9),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
const Icon(
|
|
Icons.wb_sunny,
|
|
size: 48,
|
|
color: Colors.white,
|
|
),
|
|
],
|
|
),
|
|
SizedBox(height: 16),
|
|
Container(
|
|
height: 1,
|
|
color: Colors.white.withOpacity(isDarkMode ? 0.15 : 0.2),
|
|
),
|
|
SizedBox(height: 16),
|
|
const Text(
|
|
'未来预报',
|
|
style: TextStyle(
|
|
fontSize: 14,
|
|
fontWeight: FontWeight.w600,
|
|
color: Colors.white,
|
|
),
|
|
),
|
|
SizedBox(height: 8),
|
|
// Row(
|
|
// mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
// children: [
|
|
// _buildForecastDay('周一', Icons.wb_sunny, '28°'),
|
|
// _buildForecastDay('周二', Icons.cloud, '25°'),
|
|
// _buildForecastDay('周三', Icons.wb_cloudy, '26°'),
|
|
// _buildForecastDay('周四', Icons.grain, '24°'),
|
|
// _buildForecastDay('周五', Icons.wb_sunny, '27°'),
|
|
// ],
|
|
// ),
|
|
Row(
|
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
children: List.generate(
|
|
days.length,
|
|
(index) {
|
|
String dateStr = days[index]['date'];
|
|
DateTime? date = DateUtil.getDateTime(dateStr);
|
|
IconData weatherIcon =
|
|
getWeatherIcon(days[index]['weatherCondition']);
|
|
String weekday = DateUtil.getWeekday(date,
|
|
languageCode: "zh", short: true);
|
|
String temp = "${days[index]['temperature']}°";
|
|
return _buildForecastDay(
|
|
weekday,
|
|
weatherIcon,
|
|
temp,
|
|
);
|
|
},
|
|
),
|
|
),
|
|
SizedBox(height: 16),
|
|
Container(
|
|
padding: EdgeInsets.symmetric(vertical: 8, horizontal: 12),
|
|
decoration: BoxDecoration(
|
|
color: isDarkMode
|
|
? Colors.white.withOpacity(0.15)
|
|
: Colors.white.withOpacity(0.2),
|
|
borderRadius: BorderRadius.circular(8),
|
|
),
|
|
child: const Row(
|
|
children: [
|
|
Icon(
|
|
Icons.info_outline,
|
|
size: 16,
|
|
color: Colors.white,
|
|
),
|
|
SizedBox(width: 6),
|
|
Flexible(
|
|
child: Text(
|
|
'今日紫外线指数较高,外出请做好防晒',
|
|
style: TextStyle(
|
|
fontSize: 12,
|
|
color: Colors.white,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
// const SizedBox(height: 10),
|
|
// Row(
|
|
// mainAxisSize: MainAxisSize.min,
|
|
// children: [
|
|
// Expanded(
|
|
// child: OutlinedButton.icon(
|
|
// onPressed: () {},
|
|
// icon: Icon(Icons.location_on, size: 16),
|
|
// label: Text('更多地区'),
|
|
// style: OutlinedButton.styleFrom(
|
|
// foregroundColor: Theme.of(context).primaryColor,
|
|
// side: BorderSide(
|
|
// color: Theme.of(context).primaryColor.withOpacity(0.5)),
|
|
// padding: EdgeInsets.symmetric(horizontal: 12, vertical: 4),
|
|
// shape: RoundedRectangleBorder(
|
|
// borderRadius: BorderRadius.circular(16),
|
|
// ),
|
|
// textStyle: TextStyle(fontSize: 12),
|
|
// ),
|
|
// ),
|
|
// ),
|
|
// SizedBox(width: 8),
|
|
// Expanded(
|
|
// child: OutlinedButton.icon(
|
|
// onPressed: () {},
|
|
// icon: Icon(Icons.calendar_today, size: 16),
|
|
// label: Text('15天预报'),
|
|
// style: OutlinedButton.styleFrom(
|
|
// foregroundColor: Colors.grey[700],
|
|
// side: BorderSide(color: Colors.grey[400]!),
|
|
// padding: EdgeInsets.symmetric(horizontal: 12, vertical: 4),
|
|
// shape: RoundedRectangleBorder(
|
|
// borderRadius: BorderRadius.circular(16),
|
|
// ),
|
|
// textStyle: TextStyle(fontSize: 12),
|
|
// ),
|
|
// ),
|
|
// ),
|
|
// ],
|
|
// ),
|
|
],
|
|
);
|
|
}
|
|
|
|
/// 构建单日天气预报
|
|
Widget _buildForecastDay(String day, IconData icon, String temp) {
|
|
return Column(
|
|
children: [
|
|
Text(
|
|
day,
|
|
style: TextStyle(
|
|
fontSize: 12,
|
|
color: Colors.white.withOpacity(0.9),
|
|
),
|
|
),
|
|
SizedBox(height: 4),
|
|
Icon(
|
|
icon,
|
|
size: 20,
|
|
color: Colors.white,
|
|
),
|
|
SizedBox(height: 4),
|
|
Text(
|
|
temp,
|
|
style: TextStyle(
|
|
fontSize: 12,
|
|
fontWeight: FontWeight.w500,
|
|
color: Colors.white,
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
/// 获取天气图标
|
|
IconData getWeatherIcon(String description) {
|
|
description = description.trim();
|
|
|
|
if (["晴", "少云", "晴间多云"].contains(description)) {
|
|
return Icons.wb_sunny;
|
|
}
|
|
|
|
if (["多云", "阴"].contains(description)) {
|
|
return Icons.cloud;
|
|
}
|
|
|
|
if (description.contains("雾") ||
|
|
["雾", "浓雾", "强浓雾", "轻雾", "大雾", "特强浓雾", "霾", "中度霾", "重度霾", "严重霾"]
|
|
.contains(description)) {
|
|
return Icons.blur_on;
|
|
}
|
|
|
|
if (description.contains("风") ||
|
|
[
|
|
"有风",
|
|
"平静",
|
|
"微风",
|
|
"和风",
|
|
"清风",
|
|
"强风/劲风",
|
|
"疾风",
|
|
"大风",
|
|
"烈风",
|
|
"风暴",
|
|
"狂爆风",
|
|
"飓风",
|
|
"热带风暴"
|
|
].contains(description)) {
|
|
return Icons.air;
|
|
}
|
|
|
|
if (description.contains("雨") ||
|
|
[
|
|
"阵雨",
|
|
"雷阵雨",
|
|
"雷阵雨并伴有冰雹",
|
|
"小雨",
|
|
"中雨",
|
|
"大雨",
|
|
"暴雨",
|
|
"大暴雨",
|
|
"特大暴雨",
|
|
"强阵雨",
|
|
"强雷阵雨",
|
|
"极端降雨",
|
|
"毛毛雨/细雨",
|
|
"雨",
|
|
"小雨-中雨",
|
|
"中雨-大雨",
|
|
"大雨-暴雨",
|
|
"暴雨-大暴雨",
|
|
"大暴雨-特大暴雨"
|
|
].contains(description)) {
|
|
return Icons.grain; // 或使用 community icon 包:weather_icons 中的 rain
|
|
}
|
|
|
|
if (description.contains("雪") ||
|
|
[
|
|
"雨雪天气",
|
|
"雨夹雪",
|
|
"阵雨夹雪",
|
|
"冻雨",
|
|
"雪",
|
|
"阵雪",
|
|
"小雪",
|
|
"中雪",
|
|
"大雪",
|
|
"暴雪",
|
|
"小雪-中雪",
|
|
"中雪-大雪",
|
|
"大雪-暴雪"
|
|
].contains(description)) {
|
|
return Icons.ac_unit;
|
|
}
|
|
|
|
if (["浮尘", "扬沙", "沙尘暴", "强沙尘暴"].contains(description)) {
|
|
return Icons.filter_drama;
|
|
}
|
|
|
|
if (["龙卷风"].contains(description)) {
|
|
return Icons.waves;
|
|
}
|
|
|
|
if (["热"].contains(description)) {
|
|
return Icons.local_fire_department;
|
|
}
|
|
|
|
if (["冷"].contains(description)) {
|
|
return Icons.ac_unit;
|
|
}
|
|
|
|
// 默认或未知天气
|
|
return Icons.help_outline;
|
|
}
|
|
}
|
|
|
|
/// 动画点组件
|
|
class _AnimatedDot extends StatefulWidget {
|
|
final int delayMilliseconds;
|
|
|
|
const _AnimatedDot({required this.delayMilliseconds});
|
|
|
|
@override
|
|
_AnimatedDotState createState() => _AnimatedDotState();
|
|
}
|
|
|
|
class _AnimatedDotState extends State<_AnimatedDot>
|
|
with SingleTickerProviderStateMixin {
|
|
late AnimationController _controller;
|
|
late Animation<double> _animation;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
|
|
_controller = AnimationController(
|
|
vsync: this,
|
|
duration: const Duration(milliseconds: 1500),
|
|
);
|
|
|
|
_animation = Tween<double>(begin: 0, end: 6).animate(
|
|
CurvedAnimation(
|
|
parent: _controller,
|
|
curve: Interval(0.0, 0.5, curve: Curves.easeOut),
|
|
reverseCurve: Interval(0.5, 1.0, curve: Curves.easeIn),
|
|
),
|
|
)..addListener(() {
|
|
setState(() {});
|
|
});
|
|
|
|
Future.delayed(Duration(milliseconds: widget.delayMilliseconds), () {
|
|
if (mounted) {
|
|
_controller.repeat();
|
|
}
|
|
});
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_controller.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Container(
|
|
width: 6,
|
|
height: 6,
|
|
decoration: BoxDecoration(
|
|
color: Colors.white,
|
|
borderRadius: BorderRadius.circular(3),
|
|
boxShadow: [
|
|
BoxShadow(
|
|
color: Colors.white.withOpacity(0.5),
|
|
blurRadius: _animation.value,
|
|
spreadRadius: _animation.value / 2,
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|