connection_theme_button.dart 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592
  1. import 'package:flutter/material.dart' hide ConnectionState;
  2. import 'package:flutter_screenutil/flutter_screenutil.dart';
  3. import 'dart:math' as math;
  4. import 'dart:async';
  5. import 'package:get/get.dart';
  6. import 'package:nomo/app/widgets/ix_image.dart';
  7. import '../../../constants/assets.dart';
  8. import '../../../../config/theme/theme_extensions/theme_extension.dart';
  9. import '../../../../config/translations/strings_enum.dart';
  10. import '../../../constants/enums.dart';
  11. /// 连接中状态的流光边框画笔
  12. class ConnectingBorderPainter extends CustomPainter {
  13. final double rotationAngle;
  14. final Color primaryColor;
  15. final Color secondaryColor;
  16. ConnectingBorderPainter({
  17. required this.rotationAngle,
  18. required this.primaryColor,
  19. required this.secondaryColor,
  20. });
  21. @override
  22. void paint(Canvas canvas, Size size) {
  23. final center = Offset(size.width / 2, size.height / 2);
  24. final radius = size.width / 2 - 2;
  25. const strokeWidth = 4.0;
  26. final rect = Rect.fromCircle(center: center, radius: radius);
  27. // 连接中状态:绘制平滑的流光尾巴效果
  28. final gradient = SweepGradient(
  29. startAngle: 0,
  30. endAngle: 2 * math.pi,
  31. colors: [
  32. primaryColor.withValues(alpha: 0.0),
  33. primaryColor.withValues(alpha: 0.05),
  34. primaryColor.withValues(alpha: 0.2),
  35. primaryColor.withValues(alpha: 0.5),
  36. primaryColor.withValues(alpha: 0.8),
  37. primaryColor,
  38. secondaryColor,
  39. secondaryColor.withValues(alpha: 0.8),
  40. secondaryColor.withValues(alpha: 0.4),
  41. secondaryColor.withValues(alpha: 0.1),
  42. primaryColor.withValues(alpha: 0.0),
  43. ],
  44. stops: const [0.0, 0.1, 0.2, 0.35, 0.45, 0.5, 0.55, 0.65, 0.8, 0.9, 1.0],
  45. transform: GradientRotation(rotationAngle - math.pi / 2),
  46. );
  47. final paint = Paint()
  48. ..shader = gradient.createShader(rect)
  49. ..style = PaintingStyle.stroke
  50. ..strokeWidth = strokeWidth
  51. ..strokeCap = StrokeCap.round
  52. ..isAntiAlias = true;
  53. canvas.drawCircle(center, radius, paint);
  54. }
  55. @override
  56. bool shouldRepaint(covariant ConnectingBorderPainter oldDelegate) {
  57. return oldDelegate.rotationAngle != rotationAngle ||
  58. oldDelegate.primaryColor != primaryColor ||
  59. oldDelegate.secondaryColor != secondaryColor;
  60. }
  61. }
  62. class ConnectionThemeButton extends StatefulWidget {
  63. final ConnectionState state;
  64. final VoidCallback? onTap;
  65. const ConnectionThemeButton({super.key, required this.state, this.onTap});
  66. @override
  67. State<ConnectionThemeButton> createState() => _ConnectionThemeButtonState();
  68. }
  69. class _ConnectionThemeButtonState extends State<ConnectionThemeButton>
  70. with TickerProviderStateMixin {
  71. late AnimationController _rotationController; // 旋转动画控制器
  72. late AnimationController _fadeController; // 淡入淡出控制器
  73. Timer? _connectingTimer; // 连接中状态的计时器
  74. int _connectingTextIndex = 0; // 当前显示的连接文本索引(0-4)
  75. ConnectionState? _previousState; // 保存前一个连接状态
  76. bool _isStoppingRotation = false; // 是否正在停止旋转
  77. @override
  78. void initState() {
  79. super.initState();
  80. // 初始化旋转动画控制器
  81. _rotationController = AnimationController(
  82. duration: const Duration(milliseconds: 500),
  83. vsync: this,
  84. );
  85. // 初始化淡入淡出控制器
  86. _fadeController = AnimationController(
  87. duration: const Duration(milliseconds: 600),
  88. vsync: this,
  89. value: 1.0,
  90. );
  91. // 如果初始状态是 connectingVirtual/connecting 或 disconnecting,启动动画
  92. if (widget.state == ConnectionState.connectingVirtual ||
  93. widget.state == ConnectionState.connecting ||
  94. widget.state == ConnectionState.disconnecting) {
  95. _rotationController.repeat();
  96. if (widget.state == ConnectionState.connectingVirtual ||
  97. widget.state == ConnectionState.connecting) {
  98. _startConnectingTimer();
  99. }
  100. }
  101. }
  102. @override
  103. void didUpdateWidget(ConnectionThemeButton oldWidget) {
  104. super.didUpdateWidget(oldWidget);
  105. // 处理状态变化
  106. if (oldWidget.state != widget.state) {
  107. _handleStateChange(oldWidget.state);
  108. }
  109. }
  110. void _handleStateChange(ConnectionState oldState) {
  111. if (widget.state == ConnectionState.connectingVirtual ||
  112. widget.state == ConnectionState.connecting ||
  113. widget.state == ConnectionState.disconnecting) {
  114. // 进入旋转状态时,确保立即开始旋转
  115. _isStoppingRotation = false;
  116. // 先停止任何正在进行的动画,然后立即重新开始
  117. _rotationController.stop();
  118. _rotationController.repeat();
  119. if (widget.state == ConnectionState.connectingVirtual ||
  120. widget.state == ConnectionState.connecting) {
  121. if (_connectingTimer == null || !_connectingTimer!.isActive) {
  122. _startConnectingTimer();
  123. }
  124. } else {
  125. _stopConnectingTimer();
  126. }
  127. } else {
  128. // 从连接中/断开中切换到其他状态时,平滑停止旋转
  129. if (_rotationController.isAnimating && !_isStoppingRotation) {
  130. _isStoppingRotation = true;
  131. // 计算剩余角度,让动画平滑停止在顶部(0度位置)
  132. final currentValue = _rotationController.value;
  133. // 停止重复,然后平滑减速到完整的一圈
  134. _rotationController.stop();
  135. _rotationController
  136. .animateTo(
  137. 1.0,
  138. duration: Duration(
  139. milliseconds: ((1.0 - currentValue) * 400).toInt().clamp(
  140. 100,
  141. 400,
  142. ),
  143. ),
  144. curve: Curves.easeOutCubic,
  145. )
  146. .then((_) {
  147. if (mounted) {
  148. _rotationController.reset();
  149. _isStoppingRotation = false;
  150. }
  151. });
  152. }
  153. _stopConnectingTimer();
  154. }
  155. }
  156. @override
  157. void dispose() {
  158. _rotationController.dispose();
  159. _fadeController.dispose();
  160. _connectingTimer?.cancel();
  161. super.dispose();
  162. }
  163. void _onTap() {
  164. if (widget.onTap != null) {
  165. widget.onTap!();
  166. }
  167. }
  168. // 启动连接中状态的文本轮播计时器
  169. void _startConnectingTimer() {
  170. _connectingTimer?.cancel();
  171. _connectingTextIndex = -1;
  172. _connectingTimer = Timer.periodic(const Duration(seconds: 5), (timer) {
  173. if (mounted) {
  174. setState(() {
  175. // 每秒切换到下一个文本,循环显示0-4
  176. _connectingTextIndex = (_connectingTextIndex + 1) % 5;
  177. });
  178. }
  179. });
  180. }
  181. // 停止连接中状态的文本轮播计时器
  182. void _stopConnectingTimer() {
  183. _connectingTimer?.cancel();
  184. _connectingTimer = null;
  185. _connectingTextIndex = 0;
  186. }
  187. // 根据索引获取对应的连接文本
  188. String _getConnectingText() {
  189. switch (_connectingTextIndex) {
  190. case 0:
  191. return Strings.securingData.tr;
  192. case 1:
  193. return Strings.encryptingTraffic.tr;
  194. case 2:
  195. return Strings.protectingPrivacy.tr;
  196. case 3:
  197. return Strings.safeConnection.tr;
  198. case 4:
  199. return Strings.yourDataIsSafe.tr;
  200. default:
  201. return Strings.connecting.tr;
  202. }
  203. }
  204. // 连接中状态的流光边框颜色
  205. static const Color _connectingPrimaryColor = Color(0xFF4FC3F7); // 浅蓝色
  206. static const Color _connectingSecondaryColor = Color(0xFF7E57C2); // 紫色
  207. // 断开中状态的流光边框颜色
  208. static const Color _disconnectingPrimaryColor = Color(0xFFFF7043); // 橙色
  209. static const Color _disconnectingSecondaryColor = Color(0xFFFFCA28); // 黄色
  210. // 连接成功状态的渐变色
  211. static const Color _connectedGradientStart = Color(0xFF0EA5E9);
  212. static const Color _connectedGradientEnd = Color(0xFF3B82F6);
  213. // 连接成功状态的阴影色
  214. static const Color _connectedShadowColor = Color(
  215. 0x660B84FE,
  216. ); // rgba(11, 132, 254, 0.40)
  217. // 断开状态的阴影色
  218. static const Color _disconnectedShadowColor = Color(
  219. 0x14000000,
  220. ); // rgba(0, 0, 0, 0.08)
  221. // 构建流光动画背景(连接中/断开中状态共用)
  222. Widget _buildAnimatingBackground({
  223. required double size,
  224. required ValueKey key,
  225. required Color primaryColor,
  226. required Color secondaryColor,
  227. }) {
  228. return Container(
  229. key: key,
  230. width: size,
  231. height: size,
  232. decoration: BoxDecoration(
  233. color: Get.reactiveTheme.highlightColor,
  234. shape: BoxShape.circle,
  235. ),
  236. child: Stack(
  237. alignment: Alignment.center,
  238. children: [
  239. // 流光边框
  240. AnimatedBuilder(
  241. animation: _rotationController,
  242. builder: (context, child) {
  243. return CustomPaint(
  244. size: Size(size, size),
  245. painter: ConnectingBorderPainter(
  246. rotationAngle: _rotationController.value * 2 * math.pi,
  247. primaryColor: primaryColor,
  248. secondaryColor: secondaryColor,
  249. ),
  250. );
  251. },
  252. ),
  253. // 内层背景圆(遮住边框内侧)
  254. Container(
  255. width: size - 8.w,
  256. height: size - 8.w,
  257. decoration: BoxDecoration(
  258. color: Get.reactiveTheme.highlightColor,
  259. shape: BoxShape.circle,
  260. ),
  261. ),
  262. ],
  263. ),
  264. );
  265. }
  266. // 构建圆形按钮背景
  267. Widget _buildButtonBackground(ConnectionState state, bool shouldRotate) {
  268. final size = 170.w;
  269. switch (state) {
  270. case ConnectionState.disconnected:
  271. case ConnectionState.error:
  272. // 断开状态:highlightColor 背景 + 4.w dividerColor 边框 + 阴影
  273. return Container(
  274. key: ValueKey('bg_disconnected_$state'),
  275. width: size,
  276. height: size,
  277. decoration: BoxDecoration(
  278. color: Get.reactiveTheme.highlightColor,
  279. shape: BoxShape.circle,
  280. border: Border.all(
  281. color: Get.reactiveTheme.dividerColor,
  282. width: 4.w,
  283. ),
  284. boxShadow: [
  285. BoxShadow(
  286. color: _disconnectedShadowColor,
  287. offset: const Offset(0, 8),
  288. blurRadius: 24,
  289. spreadRadius: 0,
  290. ),
  291. ],
  292. ),
  293. );
  294. case ConnectionState.connectingVirtual:
  295. case ConnectionState.connecting:
  296. // 连接中状态:highlightColor 背景 + 蓝紫色流光边框
  297. return _buildAnimatingBackground(
  298. size: size,
  299. key: const ValueKey('bg_connecting'),
  300. primaryColor: _connectingPrimaryColor,
  301. secondaryColor: _connectingSecondaryColor,
  302. );
  303. case ConnectionState.disconnecting:
  304. // 断开中状态:highlightColor 背景 + 橙黄色流光边框
  305. return _buildAnimatingBackground(
  306. size: size,
  307. key: const ValueKey('bg_disconnecting'),
  308. primaryColor: _disconnectingPrimaryColor,
  309. secondaryColor: _disconnectingSecondaryColor,
  310. );
  311. case ConnectionState.connected:
  312. // 连接成功状态:渐变背景 + 蓝色阴影
  313. return Container(
  314. key: const ValueKey('bg_connected'),
  315. width: size,
  316. height: size,
  317. decoration: BoxDecoration(
  318. gradient: const LinearGradient(
  319. begin: Alignment.topLeft,
  320. end: Alignment.bottomRight,
  321. colors: [_connectedGradientStart, _connectedGradientEnd],
  322. ),
  323. shape: BoxShape.circle,
  324. boxShadow: [
  325. BoxShadow(
  326. color: _connectedShadowColor,
  327. offset: const Offset(0, 8),
  328. blurRadius: 20,
  329. spreadRadius: 0,
  330. ),
  331. ],
  332. ),
  333. );
  334. }
  335. }
  336. // 根据状态和主题获取中心图片路径
  337. String _getCenterImagePath(ConnectionState state) {
  338. final isDark = Get.isDarkMode;
  339. switch (state) {
  340. case ConnectionState.disconnected:
  341. case ConnectionState.error:
  342. return isDark ? Assets.darkDisconnected : Assets.lightDisconnected;
  343. case ConnectionState.connectingVirtual:
  344. case ConnectionState.connecting:
  345. case ConnectionState.disconnecting:
  346. return isDark ? Assets.darkDisconnected : Assets.lightDisconnected;
  347. case ConnectionState.connected:
  348. // 连接成功时使用白色图标(因为背景是蓝色渐变)
  349. return Assets.darkConnected;
  350. }
  351. }
  352. // 获取中心图片的 key(相同图片使用相同 key,避免不必要的动画)
  353. String _getCenterImageKey(ConnectionState state) {
  354. switch (state) {
  355. case ConnectionState.disconnected:
  356. case ConnectionState.error:
  357. return 'center_disconnected_${Get.isDarkMode}';
  358. case ConnectionState.connectingVirtual:
  359. case ConnectionState.connecting:
  360. case ConnectionState.disconnecting:
  361. return 'center_connecting_${Get.isDarkMode}';
  362. case ConnectionState.connected:
  363. return 'center_connected';
  364. }
  365. }
  366. // 构建中心图片
  367. Widget _buildCenterImage(ConnectionState state) {
  368. final imagePath = _getCenterImagePath(state);
  369. return IXImage(
  370. key: ValueKey(_getCenterImageKey(state)),
  371. source: imagePath,
  372. sourceType: ImageSourceType.asset,
  373. width: 40.w,
  374. height: 40.w,
  375. );
  376. }
  377. @override
  378. Widget build(BuildContext context) {
  379. return GestureDetector(onTap: _onTap, child: _buildMainButton());
  380. }
  381. Widget _buildMainButton() {
  382. // 根据状态获取对应的资源和样式
  383. String statusImgPath;
  384. String text;
  385. Color textColor;
  386. bool shouldRotate;
  387. switch (widget.state) {
  388. case ConnectionState.disconnected:
  389. statusImgPath = Assets.disconnected;
  390. text = Strings.disconnected.tr;
  391. textColor = Get.reactiveTheme.hintColor;
  392. shouldRotate = false;
  393. break;
  394. case ConnectionState.connectingVirtual:
  395. case ConnectionState.connecting:
  396. statusImgPath = Assets.connecting;
  397. text = _getConnectingText(); // 使用轮播文本
  398. textColor = Get.reactiveTheme.hintColor;
  399. shouldRotate = true;
  400. break;
  401. case ConnectionState.disconnecting:
  402. statusImgPath = Assets.connecting;
  403. text = Strings.disconnecting.tr;
  404. textColor = Get.reactiveTheme.hintColor;
  405. shouldRotate = true;
  406. break;
  407. case ConnectionState.connected:
  408. statusImgPath = Assets.connected;
  409. text = Strings.connected.tr;
  410. textColor = Get.reactiveTheme.textTheme.bodyLarge!.color!;
  411. shouldRotate = false;
  412. break;
  413. case ConnectionState.error:
  414. statusImgPath = Assets.error;
  415. text = Strings.error.tr;
  416. textColor = Get.reactiveTheme.hintColor;
  417. shouldRotate = false;
  418. break;
  419. }
  420. // 更新前一个状态
  421. if (_previousState != widget.state) {
  422. WidgetsBinding.instance.addPostFrameCallback((_) {
  423. if (mounted) {
  424. _previousState = widget.state;
  425. }
  426. });
  427. }
  428. return Column(
  429. mainAxisSize: MainAxisSize.min,
  430. children: [
  431. // 圆形按钮区域
  432. SizedBox(
  433. width: 170.w,
  434. height: 170.w,
  435. child: Stack(
  436. alignment: Alignment.center,
  437. children: [
  438. // 按钮背景 - 使用纯淡入淡出
  439. AnimatedSwitcher(
  440. duration: const Duration(milliseconds: 600),
  441. switchInCurve: Curves.easeInOut,
  442. switchOutCurve: Curves.easeInOut,
  443. transitionBuilder: (Widget child, Animation<double> animation) {
  444. return FadeTransition(
  445. opacity: CurvedAnimation(
  446. parent: animation,
  447. curve: Curves.easeInOut,
  448. ),
  449. child: child,
  450. );
  451. },
  452. layoutBuilder: (currentChild, previousChildren) {
  453. return Stack(
  454. alignment: Alignment.center,
  455. children: [
  456. ...previousChildren,
  457. if (currentChild != null) currentChild,
  458. ],
  459. );
  460. },
  461. child: _buildButtonBackground(widget.state, shouldRotate),
  462. ),
  463. // 中心图片 - 纯淡入淡出
  464. AnimatedSwitcher(
  465. duration: const Duration(milliseconds: 500),
  466. switchInCurve: Curves.easeInOut,
  467. switchOutCurve: Curves.easeInOut,
  468. transitionBuilder: (Widget child, Animation<double> animation) {
  469. return FadeTransition(
  470. opacity: CurvedAnimation(
  471. parent: animation,
  472. curve: Curves.easeInOut,
  473. ),
  474. child: child,
  475. );
  476. },
  477. layoutBuilder: (currentChild, previousChildren) {
  478. return Stack(
  479. alignment: Alignment.center,
  480. children: [
  481. ...previousChildren,
  482. if (currentChild != null) currentChild,
  483. ],
  484. );
  485. },
  486. child: _buildCenterImage(widget.state),
  487. ),
  488. ],
  489. ),
  490. ),
  491. 20.verticalSpaceFromWidth,
  492. // 状态文字
  493. AnimatedSwitcher(
  494. duration: const Duration(milliseconds: 350),
  495. switchInCurve: Curves.easeOutCubic,
  496. switchOutCurve: Curves.easeInCubic,
  497. transitionBuilder: (Widget child, Animation<double> animation) {
  498. return FadeTransition(
  499. opacity: CurvedAnimation(
  500. parent: animation,
  501. curve: Curves.easeInOut,
  502. ),
  503. child: SlideTransition(
  504. position:
  505. Tween<Offset>(
  506. begin: const Offset(0, 0.15),
  507. end: Offset.zero,
  508. ).animate(
  509. CurvedAnimation(
  510. parent: animation,
  511. curve: Curves.easeOutCubic,
  512. ),
  513. ),
  514. child: child,
  515. ),
  516. );
  517. },
  518. child: SizedBox(
  519. key: ValueKey('status_$text'), // 使用文本作为 key,确保文字改变时触发动画
  520. height: 20.w,
  521. child: Row(
  522. mainAxisAlignment: MainAxisAlignment.center,
  523. crossAxisAlignment: CrossAxisAlignment.center,
  524. mainAxisSize: MainAxisSize.min,
  525. children: [
  526. IXImage(
  527. source: statusImgPath,
  528. sourceType: ImageSourceType.asset,
  529. width: 14.w,
  530. height: 14.w,
  531. ),
  532. 4.horizontalSpace,
  533. Text(
  534. text,
  535. style: TextStyle(
  536. fontSize: 14.sp,
  537. fontWeight: FontWeight.w500,
  538. height: 1.4,
  539. color: textColor,
  540. ),
  541. ),
  542. ],
  543. ),
  544. ),
  545. ),
  546. ],
  547. );
  548. }
  549. }