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
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,
|
|
),
|
|
),
|
|
);
|
|
}),
|
|
);
|
|
}
|
|
}
|
|
|