diff --git a/assets/images/ai1_hd.png b/assets/images/ai1_hd.png new file mode 100644 index 0000000..b61fa62 Binary files /dev/null and b/assets/images/ai1_hd.png differ diff --git a/assets/images/ai2_hd.png b/assets/images/ai2_hd.png new file mode 100644 index 0000000..50d4cc2 Binary files /dev/null and b/assets/images/ai2_hd.png differ diff --git a/assets/images/ai_hd_clean.png b/assets/images/ai_hd_clean.png new file mode 100644 index 0000000..90f2ae9 Binary files /dev/null and b/assets/images/ai_hd_clean.png differ diff --git a/assets/images/history_mini.png b/assets/images/history_mini.png new file mode 100644 index 0000000..0a3def2 Binary files /dev/null and b/assets/images/history_mini.png differ diff --git a/assets/images/keyboard_mini.png b/assets/images/keyboard_mini.png new file mode 100644 index 0000000..6bf8630 Binary files /dev/null and b/assets/images/keyboard_mini.png differ diff --git a/lib/widgets/chat_footer.dart b/lib/widgets/chat_footer.dart index a515eda..d4d67d0 100644 --- a/lib/widgets/chat_footer.dart +++ b/lib/widgets/chat_footer.dart @@ -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 createState() => _ChatFooterState(); +} + +class _ChatFooterState extends State + 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( 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, + ), ), ), ], diff --git a/lib/widgets/floating_icon.dart b/lib/widgets/floating_icon.dart index f2087db..9395bdd 100644 --- a/lib/widgets/floating_icon.dart +++ b/lib/widgets/floating_icon.dart @@ -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 createState() => _FloatingIconState(); } -class _FloatingIconState extends State { +class _FloatingIconState extends State + 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 { right: _position.dx, child: Consumer( 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 { } }, 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, + ), ); }, ), diff --git a/lib/widgets/floating_icon_with_wave.dart b/lib/widgets/floating_icon_with_wave.dart new file mode 100644 index 0000000..d104567 --- /dev/null +++ b/lib/widgets/floating_icon_with_wave.dart @@ -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, // 保持最大高度比例 + ), + ), + ), + ], + ), + ); + } +} diff --git a/lib/widgets/large_audio_wave.dart b/lib/widgets/large_audio_wave.dart new file mode 100644 index 0000000..115d680 --- /dev/null +++ b/lib/widgets/large_audio_wave.dart @@ -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), // 圆角效果 + ), + ), + ); + }), + ), + ); + }, + ); + } +} diff --git a/lib/widgets/mini_audio_wave.dart b/lib/widgets/mini_audio_wave.dart new file mode 100644 index 0000000..765dfd5 --- /dev/null +++ b/lib/widgets/mini_audio_wave.dart @@ -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), // 圆角效果 + ), + ), + ); + }), + ); + }, + ); + }, + ); + } +}