connection_theme_button.dart 18 KB

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