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.

484 lines
16 KiB

import 'package:device_jieli/device_jieli.dart';
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:get/get.dart';
import '../controllers/devices_controller.dart';
class DevicesView extends StatefulWidget {
const DevicesView({Key? key}) : super(key: key);
@override
State<DevicesView> createState() => _DevicesViewState();
}
class _DevicesViewState extends State<DevicesView>
with TickerProviderStateMixin {
late AnimationController _pulseController;
late AnimationController _rotationController;
late Animation<double> _pulseAnimation;
late Animation<double> _rotationAnimation;
DevicesController get controller => Get.find<DevicesController>();
@override
void initState() {
super.initState();
_pulseController = AnimationController(
duration: const Duration(milliseconds: 1500),
vsync: this,
)..repeat(reverse: true);
_rotationController = AnimationController(
duration: const Duration(milliseconds: 2000),
vsync: this,
)..repeat();
_pulseAnimation =
Tween<double>(begin: 0.8, end: 1.2).animate(CurvedAnimation(
parent: _pulseController,
curve: Curves.easeInOut,
));
_rotationAnimation = Tween<double>(begin: 0.0, end: 1.0).animate(
CurvedAnimation(parent: _rotationController, curve: Curves.linear),
);
}
@override
void dispose() {
_pulseController.dispose();
_rotationController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final isDark = Theme.of(context).brightness == Brightness.dark;
return Scaffold(
backgroundColor: isDark ? Colors.black : Colors.white,
body: SafeArea(
child: Column(
children: [
_buildTopSection(isDark),
Expanded(child: _buildDeviceListSection(isDark)),
_buildBottomActions(isDark),
],
),
),
);
}
// ─── 顶部:标题 + 扫描动画 + 状态文字 ────────────────────
Widget _buildTopSection(bool isDark) {
return Padding(
padding: EdgeInsets.only(top: 40.h, left: 24.w, right: 24.w),
child: Column(
children: [
Text(
'Voitrans',
style: TextStyle(
fontSize: 28.sp,
fontWeight: FontWeight.bold,
color: isDark ? Colors.white : Colors.black,
),
),
SizedBox(height: 6.h),
Text(
'homeSlogan'.tr,
style: TextStyle(
fontSize: 14.sp,
color: isDark ? Colors.white60 : Colors.black54,
),
),
SizedBox(height: 24.h),
_buildScanAnimation(isDark),
SizedBox(height: 16.h),
Obx(() {
final scanning = controller.isScanning.value;
return Text(
scanning ? 'scanning'.tr : 'scanIdle'.tr,
style: TextStyle(
fontSize: 14.sp,
fontWeight: FontWeight.w500,
color: isDark ? Colors.white70 : Colors.black87,
),
);
}),
SizedBox(height: 6.h),
Text(
'ensureHeadsetOnAndPairingMode'.tr,
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 12.sp,
color: isDark ? Colors.white38 : Colors.black45,
),
),
],
),
);
}
Widget _buildScanAnimation(bool isDark) {
const primaryColor = Color(0xFF0066CC);
const secondaryColor = Color(0xFF5E5CE6);
return SizedBox(
width: 180.w,
height: 180.w,
child: Obx(() {
final scanning = controller.isScanning.value;
return Stack(
alignment: Alignment.center,
children: [
if (scanning) _buildPulse(primaryColor, secondaryColor),
if (scanning) _buildRotatingBorder(primaryColor),
Container(
width: 92.w,
height: 92.w,
decoration: const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [primaryColor, secondaryColor],
),
shape: BoxShape.circle,
),
child: Icon(
Icons.headphones,
color: Colors.white,
size: 44.w,
),
),
],
);
}),
);
}
Widget _buildPulse(Color primary, Color secondary) {
return AnimatedBuilder(
animation: _pulseAnimation,
builder: (_, __) {
final v = _pulseAnimation.value;
final opacity = 0.4 - (_pulseController.value * 0.3);
return Container(
width: 160.w * v,
height: 160.w * v,
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [
primary.withValues(alpha: opacity),
secondary.withValues(alpha: opacity),
],
),
shape: BoxShape.circle,
),
);
},
);
}
Widget _buildRotatingBorder(Color primary) {
return AnimatedBuilder(
animation: _rotationAnimation,
builder: (_, __) {
return Transform.rotate(
angle: _rotationAnimation.value * 2 * 3.14159,
child: SizedBox(
width: 110.w,
height: 110.w,
child: CircularProgressIndicator(
strokeWidth: 3.w,
valueColor: AlwaysStoppedAnimation<Color>(primary),
backgroundColor: Colors.grey.withValues(alpha: 0.18),
value: 0.7,
),
),
);
},
);
}
// ─── 底部:扫描结果列表 ────────────────────────────────
Widget _buildDeviceListSection(bool isDark) {
return Container(
margin: EdgeInsets.only(top: 20.h, left: 16.w, right: 16.w),
decoration: BoxDecoration(
color: isDark
? Colors.white.withValues(alpha: 0.03)
: const Color(0xFFF5F6FA),
borderRadius: BorderRadius.circular(16.r),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Padding(
padding:
EdgeInsets.only(left: 16.w, right: 16.w, top: 14.h, bottom: 8.h),
child: Row(
children: [
Text(
'discoveredDevices'.tr,
style: TextStyle(
fontSize: 13.sp,
fontWeight: FontWeight.w600,
color: isDark ? Colors.white70 : Colors.black87,
),
),
const Spacer(),
Obx(() => Text(
'${controller.scannedDevices.length}',
style: TextStyle(
fontSize: 12.sp,
color: isDark ? Colors.white54 : Colors.black54,
),
)),
],
),
),
Expanded(
child: Obx(() {
final devices = controller.scannedDevices;
if (devices.isEmpty) {
return Center(
child: Padding(
padding: EdgeInsets.symmetric(vertical: 32.h),
child: Text(
'noDevicesFound'.tr,
style: TextStyle(
fontSize: 13.sp,
color: isDark ? Colors.white38 : Colors.black45,
),
),
),
);
}
return ListView.separated(
padding: EdgeInsets.fromLTRB(8.w, 4.h, 8.w, 12.h),
itemCount: devices.length,
separatorBuilder: (_, __) => SizedBox(height: 6.h),
itemBuilder: (_, i) => _buildDeviceTile(devices[i], isDark),
);
}),
),
],
),
);
}
Widget _buildDeviceTile(JieliDevice device, bool isDark) {
return Obx(() {
// 订阅连接状态、当前连接地址、正在连接/绑定的目标 + 已绑定列表 tick,让 tile 实时刷新
controller.connectionStateRx.value;
controller.boundDevicesTick.value;
final connected = controller.isConnected(device);
final connectingAddr = controller.connectingAddress.value;
final bindingAddr = controller.bindingAddress.value;
final connecting = connectingAddr == device.address;
final binding = bindingAddr == device.address;
final disabled = (connectingAddr != null && !connecting) ||
(bindingAddr != null && !binding);
final bound = controller.isDeviceBound(device);
return _buildDeviceTileContent(
device,
isDark,
connected: connected,
connecting: connecting,
binding: binding,
disabled: disabled,
bound: bound,
);
});
}
Widget _buildDeviceTileContent(
JieliDevice device,
bool isDark, {
required bool connected,
required bool connecting,
required bool binding,
required bool disabled,
required bool bound,
}) {
const connectedColor = Color(0xFF22C55E);
final canTap = !connecting && !binding && !disabled;
void onPrimaryTap() {
if (bound) {
controller.connect(device);
} else {
controller.bindAndConnect(device);
}
}
return Material(
color: Colors.transparent,
child: InkWell(
borderRadius: BorderRadius.circular(12.r),
onTap: canTap ? onPrimaryTap : null,
child: Container(
padding: EdgeInsets.symmetric(horizontal: 12.w, vertical: 12.h),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(12.r),
color: isDark
? Colors.white.withValues(alpha: 0.04)
: Colors.white,
border: Border.all(
color: connected
? connectedColor.withValues(alpha: 0.3)
: (isDark
? Colors.white.withValues(alpha: 0.05)
: Colors.black.withValues(alpha: 0.05)),
),
),
child: Row(
children: [
Container(
width: 36.w,
height: 36.w,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: connected
? connectedColor.withValues(alpha: 0.15)
: const Color(0xFF0066CC).withValues(alpha: 0.12),
),
child: Icon(
Icons.headphones,
size: 18.sp,
color: connected
? connectedColor
: const Color(0xFF0066CC),
),
),
SizedBox(width: 12.w),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Text(
device.name.isEmpty
? 'unnamedDevice'.tr
: device.name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 14.sp,
fontWeight: FontWeight.w600,
color: isDark ? Colors.white : Colors.black87,
),
),
),
if (connected)
Container(
padding: EdgeInsets.symmetric(
horizontal: 6.w, vertical: 2.h),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(6.r),
color: connectedColor.withValues(alpha: 0.12),
),
child: Text(
'connected'.tr,
style: TextStyle(
fontSize: 10.sp,
fontWeight: FontWeight.w600,
color: connectedColor,
),
),
),
],
),
SizedBox(height: 2.h),
Text(
'${device.address}${device.rssi != null ? ' RSSI ${device.rssi}' : ''}',
style: TextStyle(
fontSize: 10.sp,
color: isDark ? Colors.white38 : Colors.black45,
fontFamily: 'Menlo',
),
),
],
),
),
if (connected)
TextButton(
onPressed: controller.disconnect,
style: TextButton.styleFrom(
minimumSize: Size(0, 28.h),
padding: EdgeInsets.symmetric(horizontal: 8.w),
),
child: Text(
'disconnect'.tr,
style: TextStyle(fontSize: 11.sp, color: Colors.red),
),
)
else if (connecting || binding)
SizedBox(
width: 18.w,
height: 18.w,
child: const CircularProgressIndicator(
strokeWidth: 2,
valueColor:
AlwaysStoppedAnimation<Color>(Color(0xFF0066CC)),
),
)
else
ElevatedButton(
onPressed: disabled ? null : onPrimaryTap,
style: ElevatedButton.styleFrom(
backgroundColor: bound
? const Color(0xFF0066CC)
: const Color(0xFF8B5CF6),
foregroundColor: Colors.white,
minimumSize: Size(0, 30.h),
padding: EdgeInsets.symmetric(horizontal: 12.w),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8.r),
),
elevation: 0,
),
child: Text(
bound ? 'connect'.tr : 'bind'.tr,
style: TextStyle(
fontSize: 11.sp,
fontWeight: FontWeight.w600,
),
),
),
],
),
),
),
);
}
// ─── 底部:扫描操作按钮 ────────────────────────────────
Widget _buildBottomActions(bool isDark) {
return Padding(
padding: EdgeInsets.fromLTRB(24.w, 12.h, 24.w, 20.h),
child: Obx(() {
final scanning = controller.isScanning.value;
return SizedBox(
width: double.infinity,
height: 48.h,
child: ElevatedButton.icon(
onPressed: scanning ? controller.stopScan : controller.startScan,
icon: Icon(scanning ? Icons.stop : Icons.refresh, size: 18.sp),
label: Text(
scanning ? 'stopScan'.tr : 'startScan'.tr,
style: TextStyle(fontSize: 14.sp, fontWeight: FontWeight.w600),
),
style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xFF0066CC),
foregroundColor: Colors.white,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(14.r),
),
elevation: 0,
),
),
);
}),
);
}
}