25 changed files with 1591 additions and 1229 deletions
@ -0,0 +1,140 @@ |
|||||
|
import 'package:flutter/material.dart'; |
||||
|
import 'dart:math' as math; |
||||
|
|
||||
|
/// 环形进度条播放按钮组件 |
||||
|
class CircularProgressButton extends StatelessWidget { |
||||
|
final bool isPlaying; |
||||
|
final double progress; // 0.0 - 1.0 |
||||
|
final VoidCallback onPlayPause; |
||||
|
final Function(double)? onSeek; // 点击进度条跳转回调 |
||||
|
final double size; |
||||
|
final Color primaryColor; |
||||
|
final Color backgroundColor; |
||||
|
final double strokeWidth; |
||||
|
|
||||
|
const CircularProgressButton({ |
||||
|
Key? key, |
||||
|
required this.isPlaying, |
||||
|
required this.progress, |
||||
|
required this.onPlayPause, |
||||
|
this.onSeek, |
||||
|
this.size = 36.0, |
||||
|
required this.primaryColor, |
||||
|
this.backgroundColor = Colors.grey, |
||||
|
this.strokeWidth = 2.0, |
||||
|
}) : super(key: key); |
||||
|
|
||||
|
@override |
||||
|
Widget build(BuildContext context) { |
||||
|
return GestureDetector( |
||||
|
onTapDown: (details) { |
||||
|
if (onSeek != null) { |
||||
|
// 计算点击位置对应的进度 |
||||
|
final center = Offset(size / 2, size / 2); |
||||
|
final tapPosition = details.localPosition; |
||||
|
final dx = tapPosition.dx - center.dx; |
||||
|
final dy = tapPosition.dy - center.dy; |
||||
|
|
||||
|
// 计算角度 (从顶部开始,顺时针) |
||||
|
double angle = math.atan2(dy, dx) + math.pi / 2; |
||||
|
if (angle < 0) angle += 2 * math.pi; |
||||
|
|
||||
|
// 转换为进度值 (0.0 - 1.0) |
||||
|
final seekProgress = angle / (2 * math.pi); |
||||
|
|
||||
|
// 检查点击是否在圆环区域内 |
||||
|
final distance = math.sqrt(dx * dx + dy * dy); |
||||
|
final innerRadius = (size / 2) - strokeWidth - 4; |
||||
|
final outerRadius = (size / 2) + 4; |
||||
|
|
||||
|
if (distance >= innerRadius && distance <= outerRadius) { |
||||
|
onSeek!(seekProgress); |
||||
|
} else if (distance < innerRadius) { |
||||
|
// 点击中心区域,播放/暂停 |
||||
|
onPlayPause(); |
||||
|
} |
||||
|
} else { |
||||
|
onPlayPause(); |
||||
|
} |
||||
|
}, |
||||
|
child: SizedBox( |
||||
|
width: size, |
||||
|
height: size, |
||||
|
child: Stack( |
||||
|
alignment: Alignment.center, |
||||
|
children: [ |
||||
|
// 背景圆环 |
||||
|
CustomPaint( |
||||
|
size: Size(size, size), |
||||
|
painter: _CircularProgressPainter( |
||||
|
progress: 1.0, |
||||
|
color: backgroundColor.withValues(alpha: 0.3), |
||||
|
strokeWidth: strokeWidth, |
||||
|
), |
||||
|
), |
||||
|
// 进度圆环 |
||||
|
CustomPaint( |
||||
|
size: Size(size, size), |
||||
|
painter: _CircularProgressPainter( |
||||
|
progress: progress, |
||||
|
color: primaryColor, |
||||
|
strokeWidth: strokeWidth, |
||||
|
), |
||||
|
), |
||||
|
// 播放/暂停按钮 |
||||
|
Icon( |
||||
|
isPlaying ? Icons.pause : Icons.play_arrow, |
||||
|
color: primaryColor, |
||||
|
size: size * 0.5, |
||||
|
), |
||||
|
], |
||||
|
), |
||||
|
), |
||||
|
); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/// 环形进度条绘制器 |
||||
|
class _CircularProgressPainter extends CustomPainter { |
||||
|
final double progress; |
||||
|
final Color color; |
||||
|
final double strokeWidth; |
||||
|
|
||||
|
_CircularProgressPainter({ |
||||
|
required this.progress, |
||||
|
required this.color, |
||||
|
required this.strokeWidth, |
||||
|
}); |
||||
|
|
||||
|
@override |
||||
|
void paint(Canvas canvas, Size size) { |
||||
|
final center = Offset(size.width / 2, size.height / 2); |
||||
|
final radius = (size.width / 2) - strokeWidth / 2; |
||||
|
|
||||
|
final paint = Paint() |
||||
|
..color = color |
||||
|
..strokeWidth = strokeWidth |
||||
|
..style = PaintingStyle.stroke |
||||
|
..strokeCap = StrokeCap.round; |
||||
|
|
||||
|
// 绘制圆弧,从顶部开始 (-π/2),顺时针绘制 |
||||
|
const startAngle = -math.pi / 2; |
||||
|
final sweepAngle = 2 * math.pi * progress; |
||||
|
|
||||
|
canvas.drawArc( |
||||
|
Rect.fromCircle(center: center, radius: radius), |
||||
|
startAngle, |
||||
|
sweepAngle, |
||||
|
false, |
||||
|
paint, |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
@override |
||||
|
bool shouldRepaint(covariant CustomPainter oldDelegate) { |
||||
|
return oldDelegate is! _CircularProgressPainter || |
||||
|
oldDelegate.progress != progress || |
||||
|
oldDelegate.color != color || |
||||
|
oldDelegate.strokeWidth != strokeWidth; |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,11 @@ |
|||||
|
import 'package:get/get.dart'; |
||||
|
import '../controllers/music_playlist_controller.dart'; |
||||
|
|
||||
|
class MusicPlaylistBinding extends Bindings { |
||||
|
@override |
||||
|
void dependencies() { |
||||
|
Get.lazyPut<MusicPlaylistController>( |
||||
|
() => MusicPlaylistController(), |
||||
|
); |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,19 @@ |
|||||
|
import 'package:get/get.dart'; |
||||
|
|
||||
|
class MusicPlaylistController extends GetxController { |
||||
|
// 歌单数据 |
||||
|
final playlist = <Map<String, dynamic>>[].obs; |
||||
|
|
||||
|
@override |
||||
|
void onInit() { |
||||
|
super.onInit(); |
||||
|
// 从路由参数获取歌单数据 |
||||
|
final List<Map<String, dynamic>> playlistData = Get.arguments ?? []; |
||||
|
playlist.assignAll(playlistData); |
||||
|
} |
||||
|
|
||||
|
@override |
||||
|
void onClose() { |
||||
|
super.onClose(); |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,203 @@ |
|||||
|
import 'package:flutter/material.dart'; |
||||
|
import 'package:get/get.dart'; |
||||
|
import '../../agent/controllers/agent_controller.dart'; |
||||
|
import '../../agent/views/circular_progress_button.dart'; |
||||
|
|
||||
|
class MusicPlaylistView extends StatelessWidget { |
||||
|
const MusicPlaylistView({Key? key}) : super(key: key); |
||||
|
|
||||
|
@override |
||||
|
Widget build(BuildContext context) { |
||||
|
final controller = Get.find<AgentController>(); |
||||
|
final List<Map<String, dynamic>> playlist = Get.arguments ?? []; |
||||
|
final isDarkMode = Theme.of(context).brightness == Brightness.dark; |
||||
|
|
||||
|
return Scaffold( |
||||
|
appBar: AppBar( |
||||
|
title: const Text('音乐播放列表'), |
||||
|
backgroundColor: isDarkMode ? Colors.grey[900] : Colors.white, |
||||
|
foregroundColor: isDarkMode ? Colors.white : Colors.black, |
||||
|
elevation: 0, |
||||
|
), |
||||
|
backgroundColor: isDarkMode ? Colors.black : Colors.grey[50], |
||||
|
body: playlist.isEmpty |
||||
|
? Center( |
||||
|
child: Column( |
||||
|
mainAxisAlignment: MainAxisAlignment.center, |
||||
|
children: [ |
||||
|
Icon( |
||||
|
Icons.music_off, |
||||
|
size: 64, |
||||
|
color: Colors.grey[400], |
||||
|
), |
||||
|
const SizedBox(height: 16), |
||||
|
Text( |
||||
|
'暂无音乐', |
||||
|
style: TextStyle( |
||||
|
fontSize: 16, |
||||
|
color: Colors.grey[600], |
||||
|
), |
||||
|
), |
||||
|
], |
||||
|
), |
||||
|
) |
||||
|
: ListView.builder( |
||||
|
padding: const EdgeInsets.all(16), |
||||
|
itemCount: playlist.length, |
||||
|
itemBuilder: (context, index) { |
||||
|
final item = playlist[index]; |
||||
|
final id = item["id"]?.toString() ?? ""; |
||||
|
final name = item["name"] ?? "未知歌曲"; |
||||
|
final url = item["url"] ?? ""; |
||||
|
final singer = item["singer"] ?? item["sgener"] ?? "未知歌手"; |
||||
|
final image = item["image"] ?? ""; |
||||
|
|
||||
|
return _buildMusicItem( |
||||
|
context, |
||||
|
controller, |
||||
|
id, |
||||
|
name, |
||||
|
url, |
||||
|
singer, |
||||
|
image, |
||||
|
isDarkMode, |
||||
|
); |
||||
|
}, |
||||
|
), |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
Widget _buildMusicItem( |
||||
|
BuildContext context, |
||||
|
AgentController controller, |
||||
|
String id, |
||||
|
String name, |
||||
|
String url, |
||||
|
String singer, |
||||
|
String image, |
||||
|
bool isDarkMode, |
||||
|
) { |
||||
|
final song = { |
||||
|
'id': id, |
||||
|
'title': name, |
||||
|
'url': url, |
||||
|
'artist': singer, |
||||
|
'coverUrl': image, |
||||
|
}; |
||||
|
|
||||
|
return Container( |
||||
|
margin: const EdgeInsets.only(bottom: 12), |
||||
|
padding: const EdgeInsets.all(12), |
||||
|
decoration: BoxDecoration( |
||||
|
color: isDarkMode ? Colors.grey[800] : Colors.white, |
||||
|
borderRadius: BorderRadius.circular(12), |
||||
|
boxShadow: [ |
||||
|
BoxShadow( |
||||
|
color: Colors.black.withValues(alpha: isDarkMode ? 0.2 : 0.03), |
||||
|
blurRadius: 3, |
||||
|
spreadRadius: 1, |
||||
|
offset: const Offset(0, 1), |
||||
|
), |
||||
|
], |
||||
|
), |
||||
|
child: Row( |
||||
|
children: [ |
||||
|
// 封面图片 |
||||
|
Container( |
||||
|
width: 50, |
||||
|
height: 50, |
||||
|
decoration: BoxDecoration( |
||||
|
borderRadius: BorderRadius.circular(8), |
||||
|
color: Colors.grey[300], |
||||
|
), |
||||
|
child: image.isNotEmpty |
||||
|
? ClipRRect( |
||||
|
borderRadius: BorderRadius.circular(8), |
||||
|
child: Image.network( |
||||
|
image, |
||||
|
fit: BoxFit.cover, |
||||
|
errorBuilder: (context, error, stackTrace) { |
||||
|
return Icon( |
||||
|
Icons.music_note, |
||||
|
color: Colors.grey[600], |
||||
|
); |
||||
|
}, |
||||
|
), |
||||
|
) |
||||
|
: Icon( |
||||
|
Icons.music_note, |
||||
|
color: Colors.grey[600], |
||||
|
), |
||||
|
), |
||||
|
const SizedBox(width: 12), |
||||
|
// 歌曲信息 |
||||
|
Expanded( |
||||
|
child: Column( |
||||
|
crossAxisAlignment: CrossAxisAlignment.start, |
||||
|
children: [ |
||||
|
Text( |
||||
|
name, |
||||
|
style: TextStyle( |
||||
|
fontSize: 16, |
||||
|
fontWeight: FontWeight.w500, |
||||
|
color: isDarkMode ? Colors.white : Colors.black87, |
||||
|
), |
||||
|
maxLines: 1, |
||||
|
overflow: TextOverflow.ellipsis, |
||||
|
), |
||||
|
const SizedBox(height: 4), |
||||
|
Text( |
||||
|
singer, |
||||
|
style: TextStyle( |
||||
|
fontSize: 14, |
||||
|
color: isDarkMode ? Colors.grey[400] : Colors.grey[600], |
||||
|
), |
||||
|
maxLines: 1, |
||||
|
overflow: TextOverflow.ellipsis, |
||||
|
), |
||||
|
], |
||||
|
), |
||||
|
), |
||||
|
const SizedBox(width: 12), |
||||
|
// 播放按钮 |
||||
|
Obx(() { |
||||
|
final isThisPlaying = |
||||
|
controller.isPlaying.value && controller.currentUrl.value == url; |
||||
|
|
||||
|
// 只有当前播放的歌曲才显示进度 |
||||
|
final currentProgress = isThisPlaying ? controller.progress.value : 0.0; |
||||
|
|
||||
|
return CircularProgressButton( |
||||
|
isPlaying: isThisPlaying, |
||||
|
progress: currentProgress, |
||||
|
primaryColor: Theme.of(context).primaryColor, |
||||
|
backgroundColor: Colors.grey, |
||||
|
size: 40.0, |
||||
|
strokeWidth: 2.5, |
||||
|
onPlayPause: () { |
||||
|
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); |
||||
|
} |
||||
|
} |
||||
|
}, |
||||
|
onSeek: isThisPlaying ? (seekProgress) { |
||||
|
// 只有当前播放的歌曲才允许跳转 |
||||
|
final targetPosition = (controller.duration.value * seekProgress).round(); |
||||
|
controller.seekToPosition(targetPosition); |
||||
|
} : null, |
||||
|
); |
||||
|
}), |
||||
|
], |
||||
|
), |
||||
|
); |
||||
|
} |
||||
|
} |
||||
File diff suppressed because it is too large
@ -0,0 +1,258 @@ |
|||||
|
import 'package:flutter/material.dart'; |
||||
|
import 'package:get/get.dart'; |
||||
|
import 'package:flutter_screenutil/flutter_screenutil.dart'; |
||||
|
import '../controllers/pairing_controller.dart'; |
||||
|
import '../../../core/themes/app_theme.dart'; |
||||
|
|
||||
|
/// 配对视图 - 简化版本 |
||||
|
class PairingView extends GetView<PairingController> { |
||||
|
const PairingView({Key? key}) : super(key: key); |
||||
|
|
||||
|
@override |
||||
|
Widget build(BuildContext context) { |
||||
|
final bool isDarkMode = Theme.of(context).brightness == Brightness.dark; |
||||
|
|
||||
|
return Scaffold( |
||||
|
backgroundColor: isDarkMode ? Colors.black : Colors.white, |
||||
|
body: SafeArea( |
||||
|
child: Padding( |
||||
|
padding: EdgeInsets.all(24.w), |
||||
|
child: Column( |
||||
|
children: [ |
||||
|
// 顶部标题 |
||||
|
_buildHeader(isDarkMode), |
||||
|
|
||||
|
// 中间内容 |
||||
|
Expanded( |
||||
|
child: Center( |
||||
|
child: Obx(() => _buildContent(isDarkMode)), |
||||
|
), |
||||
|
), |
||||
|
|
||||
|
// 底部按钮 |
||||
|
Obx(() => _buildButtons(isDarkMode)), |
||||
|
|
||||
|
SizedBox(height: 20.h), |
||||
|
], |
||||
|
), |
||||
|
), |
||||
|
), |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
/// 构建标题 |
||||
|
Widget _buildHeader(bool isDarkMode) { |
||||
|
return Column( |
||||
|
children: [ |
||||
|
Text( |
||||
|
'设备配对', |
||||
|
style: TextStyle( |
||||
|
fontSize: 24.sp, |
||||
|
fontWeight: FontWeight.bold, |
||||
|
color: isDarkMode ? Colors.white : Colors.black, |
||||
|
), |
||||
|
), |
||||
|
SizedBox(height: 8.h), |
||||
|
Text( |
||||
|
'连接您的 DeepSound 耳机', |
||||
|
style: TextStyle( |
||||
|
fontSize: 14.sp, |
||||
|
color: isDarkMode ? Colors.grey[400] : Colors.grey[600], |
||||
|
), |
||||
|
), |
||||
|
SizedBox(height: 40.h), |
||||
|
], |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
/// 构建主要内容 |
||||
|
Widget _buildContent(bool isDarkMode) { |
||||
|
final state = controller.currentState; |
||||
|
final isLoading = controller.isLoading.value; |
||||
|
|
||||
|
return Column( |
||||
|
mainAxisSize: MainAxisSize.min, |
||||
|
children: [ |
||||
|
// 状态图标 |
||||
|
_buildStateIcon(state, isLoading, isDarkMode), |
||||
|
|
||||
|
SizedBox(height: 40.h), |
||||
|
|
||||
|
// 状态文本 |
||||
|
Text( |
||||
|
controller.statusText.value, |
||||
|
style: TextStyle( |
||||
|
fontSize: 16.sp, |
||||
|
color: isDarkMode ? Colors.white : Colors.black, |
||||
|
), |
||||
|
textAlign: TextAlign.center, |
||||
|
), |
||||
|
|
||||
|
// 错误详情 |
||||
|
if (state == PairingState.error) ...[ |
||||
|
SizedBox(height: 16.h), |
||||
|
Container( |
||||
|
padding: EdgeInsets.all(16.w), |
||||
|
decoration: BoxDecoration( |
||||
|
color: Colors.red.withOpacity(0.1), |
||||
|
borderRadius: BorderRadius.circular(12.r), |
||||
|
border: Border.all(color: Colors.red.withOpacity(0.3)), |
||||
|
), |
||||
|
child: Row( |
||||
|
children: [ |
||||
|
Icon( |
||||
|
Icons.error_outline, |
||||
|
color: Colors.red, |
||||
|
size: 20.sp, |
||||
|
), |
||||
|
SizedBox(width: 12.w), |
||||
|
Expanded( |
||||
|
child: Text( |
||||
|
controller.errorMessage, |
||||
|
style: TextStyle( |
||||
|
fontSize: 14.sp, |
||||
|
color: Colors.red, |
||||
|
), |
||||
|
), |
||||
|
), |
||||
|
], |
||||
|
), |
||||
|
), |
||||
|
], |
||||
|
], |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
/// 构建状态图标 |
||||
|
Widget _buildStateIcon(PairingState state, bool isLoading, bool isDarkMode) { |
||||
|
if (isLoading) { |
||||
|
return CircularProgressIndicator( |
||||
|
valueColor: AlwaysStoppedAnimation<Color>( |
||||
|
Theme.of(Get.context!).primaryColor, |
||||
|
), |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
IconData iconData; |
||||
|
Color iconColor; |
||||
|
|
||||
|
switch (state) { |
||||
|
case PairingState.idle: |
||||
|
case PairingState.checking: |
||||
|
iconData = Icons.bluetooth_searching; |
||||
|
iconColor = Colors.blue; |
||||
|
break; |
||||
|
|
||||
|
case PairingState.waitingClassic: |
||||
|
iconData = Icons.bluetooth; |
||||
|
iconColor = Colors.grey; |
||||
|
break; |
||||
|
|
||||
|
case PairingState.verifying: |
||||
|
iconData = Icons.verified_user; |
||||
|
iconColor = Colors.orange; |
||||
|
break; |
||||
|
|
||||
|
case PairingState.associating: |
||||
|
iconData = Icons.link; |
||||
|
iconColor = Colors.blue; |
||||
|
break; |
||||
|
|
||||
|
case PairingState.waitingBle: |
||||
|
iconData = Icons.sync; |
||||
|
iconColor = Colors.blue; |
||||
|
break; |
||||
|
|
||||
|
case PairingState.completed: |
||||
|
iconData = Icons.check_circle; |
||||
|
iconColor = Colors.green; |
||||
|
break; |
||||
|
|
||||
|
case PairingState.error: |
||||
|
iconData = Icons.error; |
||||
|
iconColor = Colors.red; |
||||
|
break; |
||||
|
} |
||||
|
|
||||
|
return Container( |
||||
|
width: 100.w, |
||||
|
height: 100.w, |
||||
|
decoration: BoxDecoration( |
||||
|
color: iconColor.withOpacity(0.1), |
||||
|
shape: BoxShape.circle, |
||||
|
), |
||||
|
child: Icon( |
||||
|
iconData, |
||||
|
size: 60.sp, |
||||
|
color: iconColor, |
||||
|
), |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
/// 构建按钮 |
||||
|
Widget _buildButtons(bool isDarkMode) { |
||||
|
final showConnectButton = controller.showConnectButton.value; |
||||
|
final showRetryButton = controller.showRetryButton.value; |
||||
|
final showSkipButton = controller.showSkipButton.value; |
||||
|
|
||||
|
return Column( |
||||
|
children: [ |
||||
|
// 主要操作按钮 |
||||
|
if (showConnectButton) |
||||
|
_buildPrimaryButton( |
||||
|
'连接设备', |
||||
|
controller.onConnectTap, |
||||
|
isDarkMode, |
||||
|
), |
||||
|
|
||||
|
if (showRetryButton) |
||||
|
_buildPrimaryButton( |
||||
|
'重试', |
||||
|
controller.onRetryTap, |
||||
|
isDarkMode, |
||||
|
), |
||||
|
|
||||
|
SizedBox(height: 12.h), |
||||
|
|
||||
|
// 跳过按钮 |
||||
|
if (showSkipButton) |
||||
|
TextButton( |
||||
|
onPressed: controller.onSkipTap, |
||||
|
child: Text( |
||||
|
'跳过', |
||||
|
style: TextStyle( |
||||
|
fontSize: 14.sp, |
||||
|
color: isDarkMode ? Colors.grey[400] : Colors.grey[600], |
||||
|
), |
||||
|
), |
||||
|
), |
||||
|
], |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
/// 构建主要按钮 |
||||
|
Widget _buildPrimaryButton(String text, VoidCallback onPressed, bool isDarkMode) { |
||||
|
return SizedBox( |
||||
|
width: double.infinity, |
||||
|
height: 48.h, |
||||
|
child: ElevatedButton( |
||||
|
onPressed: onPressed, |
||||
|
style: ElevatedButton.styleFrom( |
||||
|
backgroundColor: Theme.of(Get.context!).primaryColor, |
||||
|
shape: RoundedRectangleBorder( |
||||
|
borderRadius: BorderRadius.circular(12.r), |
||||
|
), |
||||
|
elevation: 0, |
||||
|
), |
||||
|
child: Text( |
||||
|
text, |
||||
|
style: TextStyle( |
||||
|
fontSize: 16.sp, |
||||
|
fontWeight: FontWeight.w600, |
||||
|
color: Colors.white, |
||||
|
), |
||||
|
), |
||||
|
), |
||||
|
); |
||||
|
} |
||||
|
} |
||||
Loading…
Reference in new issue