Merge remote-tracking branch 'origin/feature/oneapp-0812' into feature/oneapp-0812
This commit is contained in:
BIN
assets/images/ai1_hd.png
Normal file
BIN
assets/images/ai1_hd.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 52 KiB |
BIN
assets/images/ai2_hd.png
Normal file
BIN
assets/images/ai2_hd.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 7.0 KiB |
BIN
assets/images/ai_hd_clean.png
Normal file
BIN
assets/images/ai_hd_clean.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 46 KiB |
BIN
assets/images/history_mini.png
Normal file
BIN
assets/images/history_mini.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.8 KiB |
BIN
assets/images/keyboard_mini.png
Normal file
BIN
assets/images/keyboard_mini.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 813 B |
@@ -4,34 +4,76 @@ import 'package:flutter/services.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../services/message_service.dart';
|
||||
import '../utils/common_util.dart';
|
||||
import 'voice_animation.dart';
|
||||
import 'large_audio_wave.dart';
|
||||
|
||||
class ChatFooter extends StatelessWidget {
|
||||
class ChatFooter extends StatefulWidget {
|
||||
final VoidCallback? onClear;
|
||||
|
||||
const ChatFooter({super.key, this.onClear});
|
||||
|
||||
@override
|
||||
State<ChatFooter> createState() => _ChatFooterState();
|
||||
}
|
||||
|
||||
class _ChatFooterState extends State<ChatFooter>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late AnimationController _animationController;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_animationController = AnimationController(
|
||||
duration: const Duration(milliseconds: 1000),
|
||||
vsync: this,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_animationController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// 定义颜色常量
|
||||
const Color buttonColor = Color(0xFF6F72F1); // 主题紫色(应用栏和按钮边框)
|
||||
|
||||
return Container(
|
||||
color: Colors.transparent,
|
||||
child: Row(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 12, bottom: 12),
|
||||
child: IconButton(
|
||||
icon: const Icon(Icons.delete_outline,
|
||||
color: CommonUtil.commonColor),
|
||||
iconSize: 30,
|
||||
tooltip: '清除会话',
|
||||
onPressed: onClear,
|
||||
padding: const EdgeInsets.only(left: 16, bottom: 12),
|
||||
child: GestureDetector(
|
||||
onTap: widget.onClear,
|
||||
child: Image.asset(
|
||||
'assets/images/history_mini.png',
|
||||
width: 24,
|
||||
height: 24,
|
||||
),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(bottom: 12),
|
||||
key: const Key('startBtn'),
|
||||
padding: const EdgeInsets.only(bottom: 12, left: 16, right: 16),
|
||||
child: Consumer<MessageService>(
|
||||
builder: (context, messageService, child) {
|
||||
// 立即响应录音状态变化,无延迟启动动画
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (messageService.isRecording) {
|
||||
if (!_animationController.isAnimating) {
|
||||
_animationController.repeat();
|
||||
}
|
||||
} else {
|
||||
if (_animationController.isAnimating) {
|
||||
_animationController.stop();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return GestureDetector(
|
||||
onTap: () {
|
||||
HapticFeedback.heavyImpact();
|
||||
@@ -39,6 +81,8 @@ class ChatFooter extends StatelessWidget {
|
||||
},
|
||||
onLongPress: () {
|
||||
HapticFeedback.mediumImpact();
|
||||
// 立即启动动画,无延迟
|
||||
_animationController.repeat();
|
||||
Future.delayed(const Duration(milliseconds: 100), () {
|
||||
HapticFeedback.heavyImpact();
|
||||
});
|
||||
@@ -46,30 +90,42 @@ class ChatFooter extends StatelessWidget {
|
||||
},
|
||||
onLongPressUp: () {
|
||||
HapticFeedback.lightImpact();
|
||||
// 立即停止动画
|
||||
_animationController.stop();
|
||||
messageService.stopAndProcessVoiceInput();
|
||||
},
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 150),
|
||||
height: 48,
|
||||
width: double.infinity,
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
color: messageService.isRecording
|
||||
? Color(0xFF8E24AA).withValues(alpha: 0.42)
|
||||
: Colors.white.withValues(alpha: 0.12),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: messageService.isRecording
|
||||
? const VoiceWaveAnimation()
|
||||
: Text(
|
||||
Intl.getCurrentLocale().startsWith('zh')
|
||||
? '按 住 说 话'
|
||||
: 'Hold to Speak',
|
||||
style: TextStyle(
|
||||
color: CommonUtil.commonColor,
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold),
|
||||
child: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final containerWidth = constraints.maxWidth;
|
||||
final waveWidth = containerWidth * 0.57; // 波形宽度为容器的57%
|
||||
|
||||
return Container(
|
||||
height: 48, // 固定高度 48px
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0x14FFFFFF), // 白色背景透明度8%
|
||||
borderRadius: BorderRadius.circular(8), // 圆角按钮 8px
|
||||
border: Border.all(
|
||||
color: buttonColor, // 边框颜色 #6F72F1
|
||||
width: 0.5, // 边框粗细 0.5px
|
||||
),
|
||||
),
|
||||
child: messageService.isRecording
|
||||
? AudioWaveLarge(
|
||||
animationController: _animationController,
|
||||
totalWidth: waveWidth,
|
||||
)
|
||||
: Text(
|
||||
Intl.getCurrentLocale().startsWith('zh')
|
||||
? '按 住 说 话'
|
||||
: 'Hold to Speak',
|
||||
style: TextStyle(
|
||||
color: CommonUtil.commonColor,
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
@@ -77,11 +133,14 @@ class ChatFooter extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 12, right: 12),
|
||||
child: IconButton(
|
||||
icon: const Icon(Icons.keyboard, color: CommonUtil.commonColor),
|
||||
iconSize: 30,
|
||||
onPressed: () {},
|
||||
padding: const EdgeInsets.only(bottom: 12, right: 16),
|
||||
child: GestureDetector(
|
||||
onTap: () {},
|
||||
child: Image.asset(
|
||||
'assets/images/keyboard_mini.png',
|
||||
width: 24,
|
||||
height: 24,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import 'package:ai_chat_assistant/services/vehicle_state_service.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../services/message_service.dart';
|
||||
import '../screens/full_screen.dart';
|
||||
import '../screens/part_screen.dart';
|
||||
import 'floating_icon_with_wave.dart';
|
||||
|
||||
class FloatingIcon extends StatefulWidget {
|
||||
const FloatingIcon({super.key});
|
||||
@@ -12,15 +12,28 @@ class FloatingIcon extends StatefulWidget {
|
||||
State<FloatingIcon> createState() => _FloatingIconState();
|
||||
}
|
||||
|
||||
class _FloatingIconState extends State<FloatingIcon> {
|
||||
class _FloatingIconState extends State<FloatingIcon>
|
||||
with SingleTickerProviderStateMixin {
|
||||
Offset _position = const Offset(10, 120);
|
||||
final iconSize = 80.0;
|
||||
bool _isShowPartScreen = false;
|
||||
|
||||
late AnimationController _waveAnimationController;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
// VehicleStateService();
|
||||
_waveAnimationController = AnimationController(
|
||||
duration: const Duration(milliseconds: 1000),
|
||||
vsync: this,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_waveAnimationController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _showPartScreen() {
|
||||
@@ -67,13 +80,30 @@ class _FloatingIconState extends State<FloatingIcon> {
|
||||
right: _position.dx,
|
||||
child: Consumer<MessageService>(
|
||||
builder: (context, messageService, child) {
|
||||
// 立即响应录音状态变化,控制动画
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (messageService.isRecording) {
|
||||
if (!_waveAnimationController.isAnimating) {
|
||||
_waveAnimationController.repeat();
|
||||
}
|
||||
} else {
|
||||
if (_waveAnimationController.isAnimating) {
|
||||
_waveAnimationController.stop();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return GestureDetector(
|
||||
onTap: _showFullScreen,
|
||||
onLongPress: () async {
|
||||
_showPartScreen();
|
||||
// 立即启动动画
|
||||
_waveAnimationController.repeat();
|
||||
await messageService.startVoiceInput();
|
||||
},
|
||||
onLongPressUp: () async {
|
||||
// 立即停止动画
|
||||
_waveAnimationController.stop();
|
||||
await messageService.stopAndProcessVoiceInput();
|
||||
final hasMessage = messageService.messages.isNotEmpty;
|
||||
if (!hasMessage) {
|
||||
@@ -81,13 +111,17 @@ class _FloatingIconState extends State<FloatingIcon> {
|
||||
}
|
||||
},
|
||||
onPanUpdate: (details) => _updatePosition(details.delta),
|
||||
child: Image.asset(
|
||||
messageService.isRecording
|
||||
? 'assets/images/ai2.png'
|
||||
: 'assets/images/ai1.png',
|
||||
width: iconSize,
|
||||
height: iconSize,
|
||||
),
|
||||
child: messageService.isRecording
|
||||
? FloatingIconWithWave(
|
||||
animationController: _waveAnimationController,
|
||||
iconSize: iconSize,
|
||||
waveColor: Colors.white,
|
||||
)
|
||||
: Image.asset(
|
||||
'assets/images/ai1_hd.png',
|
||||
width: iconSize,
|
||||
height: iconSize,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
|
||||
59
lib/widgets/floating_icon_with_wave.dart
Normal file
59
lib/widgets/floating_icon_with_wave.dart
Normal file
@@ -0,0 +1,59 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'mini_audio_wave.dart';
|
||||
|
||||
/// 浮动图标与声波动画组合组件
|
||||
///
|
||||
/// 将ai_hd_clean.png图片和AudioWaveLargeMini声波效果组合成一个独立组件
|
||||
class FloatingIconWithWave extends StatelessWidget {
|
||||
/// 动画控制器,用于驱动声波动画
|
||||
final AnimationController animationController;
|
||||
|
||||
/// 图标大小
|
||||
final double iconSize;
|
||||
|
||||
/// 声波颜色,默认为白色
|
||||
final Color waveColor;
|
||||
|
||||
/// 构造函数
|
||||
const FloatingIconWithWave({
|
||||
super.key,
|
||||
required this.animationController,
|
||||
required this.iconSize,
|
||||
this.waveColor = Colors.white,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SizedBox(
|
||||
width: iconSize,
|
||||
height: iconSize,
|
||||
child: Stack(
|
||||
alignment: Alignment.center,
|
||||
children: [
|
||||
// 背景图片
|
||||
Image.asset(
|
||||
'assets/images/ai_hd_clean.png',
|
||||
width: iconSize,
|
||||
height: iconSize,
|
||||
fit: BoxFit.contain,
|
||||
),
|
||||
// 声波动画,距离底部13px的位置
|
||||
Positioned(
|
||||
bottom: 13 * (iconSize / 198), // 按比例计算距离底部的位置
|
||||
child: SizedBox(
|
||||
width: 96 * (iconSize / 144), // 最宽不超过96px(按图片原始宽度比例)
|
||||
height: 27 * (iconSize / 198), // 最高不超过27px(按图片原始高度比例)
|
||||
child: AudioWaveLargeMini(
|
||||
animationController: animationController,
|
||||
waveColor: waveColor,
|
||||
barSpacing: 2.5, // 减小间距以适应更小的宽度
|
||||
minHeightRatio: 0.4, // 调整最小高度比例
|
||||
maxHeightRatio: 1.0, // 保持最大高度比例
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
86
lib/widgets/large_audio_wave.dart
Normal file
86
lib/widgets/large_audio_wave.dart
Normal file
@@ -0,0 +1,86 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'dart:math' as math;
|
||||
|
||||
/// 大型音频波形动画组件
|
||||
///
|
||||
/// 用于显示适合主按钮的波形动画,竖线数量根据总宽度自动计算
|
||||
class AudioWaveLarge extends StatelessWidget {
|
||||
/// 动画控制器,用于驱动波形动画
|
||||
final AnimationController animationController;
|
||||
|
||||
/// 波形颜色,默认为白色
|
||||
final Color waveColor;
|
||||
|
||||
/// 波形条间距,默认为4.0px
|
||||
final double barSpacing;
|
||||
|
||||
/// 总宽度,默认为148.0px
|
||||
final double totalWidth;
|
||||
|
||||
/// 最大高度,默认为22.0px
|
||||
final double maxHeight;
|
||||
|
||||
/// 最小高度,默认为4.0px
|
||||
final double minHeight;
|
||||
|
||||
/// 构造函数
|
||||
const AudioWaveLarge({
|
||||
super.key,
|
||||
required this.animationController,
|
||||
this.waveColor = Colors.white,
|
||||
this.barSpacing = 4.0,
|
||||
this.totalWidth = 148.0,
|
||||
this.maxHeight = 22.0,
|
||||
this.minHeight = 4.0,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AnimatedBuilder(
|
||||
key: const Key('audioWaveAnimation'), // 为音频波形添加key便于引用
|
||||
animation: animationController,
|
||||
builder: (context, child) {
|
||||
// 根据总宽度动态计算竖线数量
|
||||
// 每个竖线宽度2px + 间距,保持合理的密度
|
||||
final barWidthWithSpacing = 2 + barSpacing;
|
||||
final dynamicBarCount = (totalWidth / barWidthWithSpacing).floor();
|
||||
final actualBarCount = dynamicBarCount > 0 ? dynamicBarCount : 1;
|
||||
|
||||
return SizedBox(
|
||||
width: totalWidth, // 设置动态宽度
|
||||
height: maxHeight, // 设置固定高度
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.center, // 垂直居中
|
||||
children: List.generate(actualBarCount, (index) {
|
||||
// 为每个竖线创建不同的相位,使动画不同步
|
||||
final phase = index * 0.3;
|
||||
|
||||
// 设置不同的频率增加自然变化
|
||||
final frequency = 1.0 + (index % 3) * 0.2;
|
||||
|
||||
// 计算波形高度
|
||||
final height = minHeight + (maxHeight - minHeight) * math.sin(
|
||||
(animationController.value * 2 * math.pi * frequency) + phase
|
||||
).abs();
|
||||
|
||||
// 构建单个波形条
|
||||
return Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: barSpacing / 2), // 左右间距
|
||||
child: Container(
|
||||
width: 2, // 波形条宽度2px
|
||||
height: height, // 高度随动画变化
|
||||
decoration: BoxDecoration(
|
||||
color: waveColor,
|
||||
borderRadius: BorderRadius.circular(2), // 圆角效果
|
||||
),
|
||||
),
|
||||
);
|
||||
}),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
82
lib/widgets/mini_audio_wave.dart
Normal file
82
lib/widgets/mini_audio_wave.dart
Normal file
@@ -0,0 +1,82 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'dart:math' as math;
|
||||
|
||||
/// 小型按钮波形动画组件
|
||||
///
|
||||
/// 用于显示适合小按钮的波形动画,竖线数量根据容器宽度自动计算
|
||||
class AudioWaveLargeMini extends StatelessWidget {
|
||||
/// 动画控制器,用于驱动波形动画
|
||||
final AnimationController animationController;
|
||||
|
||||
/// 波形颜色,默认为白色
|
||||
final Color waveColor;
|
||||
|
||||
/// 波形条间距,默认为3.0px
|
||||
final double barSpacing;
|
||||
|
||||
/// 最小高度比例,默认为0.3(相对于容器高度)
|
||||
final double minHeightRatio;
|
||||
|
||||
/// 最大高度比例,默认为1.0(相对于容器高度)
|
||||
final double maxHeightRatio;
|
||||
|
||||
/// 构造函数
|
||||
const AudioWaveLargeMini({
|
||||
super.key,
|
||||
required this.animationController,
|
||||
this.waveColor = Colors.white,
|
||||
this.barSpacing = 3.0,
|
||||
this.minHeightRatio = 0.3,
|
||||
this.maxHeightRatio = 1.0,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AnimatedBuilder(
|
||||
animation: animationController, // 使用传入的动画控制器
|
||||
builder: (context, child) {
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
// 根据容器宽度动态计算竖线数量
|
||||
final containerWidth = constraints.maxWidth;
|
||||
final containerHeight = constraints.maxHeight;
|
||||
final barWidthWithSpacing = 2 + barSpacing;
|
||||
final barCount = (containerWidth / barWidthWithSpacing).floor().clamp(3, 15);
|
||||
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.center, // 改为垂直居中,和AudioWaveLarge一致
|
||||
children: List.generate(barCount, (index) {
|
||||
// 为每个竖线设置不同的相位,和AudioWaveLarge保持一致
|
||||
final phase = index * 0.3; // 改为0.3,和AudioWaveLarge一致
|
||||
// 设置不同的频率变化
|
||||
final frequency = 1.0 + (index % 3) * 0.2;
|
||||
|
||||
// 根据容器高度计算波形高度,使用和AudioWaveLarge相同的算法
|
||||
final minHeight = containerHeight * minHeightRatio;
|
||||
final maxHeight = containerHeight * maxHeightRatio;
|
||||
final height = minHeight + (maxHeight - minHeight) * math.sin(
|
||||
(animationController.value * 2 * math.pi * frequency) + phase
|
||||
).abs();
|
||||
|
||||
// 构建单个波形条
|
||||
return Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: barSpacing / 2),
|
||||
child: Container(
|
||||
width: 2, // 波形条宽度2px
|
||||
height: height, // 高度随动画变化
|
||||
decoration: BoxDecoration(
|
||||
color: waveColor,
|
||||
borderRadius: BorderRadius.circular(1), // 圆角效果
|
||||
),
|
||||
),
|
||||
);
|
||||
}),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user