// connect_button.dart — 核心连接键(严格三态:off / connecting / on) // // 纯展示组件:状态由外部状态机(connection_provider)注入,点击只回调 // onTap(派发事件),绝不在组件内本地翻转状态——禁止乐观显示。 // // 三态(design/CLAUDE.md §5): // off 暖灰底 sand-100 + clay power 图标 + 虚线轨道环 + 「点击连接」 // connecting clay 实底 + 旋转进度弧 + 旋转 loader // on success 实底 + 白盾勾 + 满白环 + 圆内计时 + 「已加密」+ 绿光晕 // 背景在纯色间切换仅过渡 box-shadow / background-color,不用 transition: all。 import 'package:flutter/material.dart'; import '../pangolin_theme.dart'; import '../state/connection_provider.dart'; import 'pangolin_icons.dart'; class ConnectButton extends StatefulWidget { const ConnectButton({ super.key, required this.phase, required this.onTap, required this.offLabel, required this.secureLabel, this.elapsed = Duration.zero, this.size = 208, }); final VpnPhase phase; final VoidCallback onTap; /// off 态圆内文字(如「点击连接」/「CONNECT」)。 final String offLabel; /// on 态圆内计时下方文字(如「已加密」/「SECURE」)。 final String secureLabel; final Duration elapsed; final double size; @override State createState() => _ConnectButtonState(); } class _ConnectButtonState extends State with SingleTickerProviderStateMixin { late final AnimationController _spin = AnimationController(vsync: this, duration: const Duration(milliseconds: 1400))..repeat(); @override void dispose() { _spin.dispose(); super.dispose(); } String _fmt(Duration d) { String two(int n) => n.toString().padLeft(2, '0'); return '${two(d.inHours)}:${two(d.inMinutes % 60)}:${two(d.inSeconds % 60)}'; } @override Widget build(BuildContext context) { final c = context.pangolin; final s = widget.phase; final Color fill = switch (s) { VpnPhase.off => c.bgSubtle, VpnPhase.connecting => c.accent, VpnPhase.on => c.success, }; final Color fg = s == VpnPhase.off ? c.accent : PangolinColors.white; final IconData icon = switch (s) { VpnPhase.off => PangolinIcons.power, VpnPhase.connecting => PangolinIcons.loader, VpnPhase.on => PangolinIcons.shieldCheck, }; // off 用柔和阴影;connecting/on 增加同色光晕环(box-shadow,不动背景计算)。 final List glow = s == VpnPhase.off ? PangolinShadow.md : [ BoxShadow( color: (s == VpnPhase.on ? c.success : c.accent).withValues(alpha: 0.18), blurRadius: 0, spreadRadius: 9, ), ...PangolinShadow.lg, ]; return Semantics( button: true, label: widget.offLabel, child: GestureDetector( onTap: widget.onTap, child: AnimatedContainer( duration: PangolinMotion.slow, curve: PangolinMotion.easeOut, width: widget.size, height: widget.size, decoration: BoxDecoration(color: fill, shape: BoxShape.circle, boxShadow: glow), child: Stack( alignment: Alignment.center, children: [ Positioned.fill( child: AnimatedBuilder( animation: _spin, builder: (_, __) => CustomPaint( painter: _RingPainter( phase: s, track: PangolinColors.sand200, progress: PangolinColors.white, turns: s == VpnPhase.connecting ? _spin.value : 0, ), ), ), ), // connecting 时图标也旋转(对齐 React loader spin) if (s == VpnPhase.connecting) RotationTransition( turns: _spin, child: Icon(icon, size: 50, color: fg), ) else Column( mainAxisSize: MainAxisSize.min, children: [ Icon(icon, size: 50, color: fg), const SizedBox(height: 6), if (s == VpnPhase.on) ...[ Text(_fmt(widget.elapsed), style: PangolinText.mono .copyWith(color: fg, fontSize: 18, fontWeight: FontWeight.w600)), Text(widget.secureLabel, style: PangolinText.caption.copyWith( color: fg.withValues(alpha: 0.9), fontSize: 10, fontWeight: FontWeight.w700, letterSpacing: 1.0)), ] else Text(widget.offLabel, style: PangolinText.caption.copyWith( color: fg, fontSize: 13, fontWeight: FontWeight.w700, letterSpacing: 0.5)), ], ), ], ), ), ), ); } } class _RingPainter extends CustomPainter { _RingPainter({required this.phase, required this.track, required this.progress, required this.turns}); final VpnPhase phase; final Color track; final Color progress; final double turns; @override void paint(Canvas canvas, Size size) { final center = size.center(Offset.zero); final radius = size.width / 2 - 8; final rect = Rect.fromCircle(center: center, radius: radius); switch (phase) { case VpnPhase.off: final p = Paint() ..style = PaintingStyle.stroke ..strokeWidth = 3 ..strokeCap = StrokeCap.round ..color = track; const dash = 0.045, gap = 0.11; for (double a = 0; a < 6.283; a += dash + gap) { canvas.drawArc(rect, a, dash, false, p); } case VpnPhase.connecting: final base = Paint() ..style = PaintingStyle.stroke ..strokeWidth = 4 ..color = progress.withValues(alpha: 0.3); canvas.drawArc(rect, 0, 6.283, false, base); final arc = Paint() ..style = PaintingStyle.stroke ..strokeWidth = 4 ..strokeCap = StrokeCap.round ..color = progress; canvas.drawArc(rect, turns * 6.283, 1.6, false, arc); case VpnPhase.on: final p = Paint() ..style = PaintingStyle.stroke ..strokeWidth = 4 ..strokeCap = StrokeCap.round ..color = progress.withValues(alpha: 0.85); canvas.drawArc(rect, 0, 6.283, false, p); } } @override bool shouldRepaint(_RingPainter old) => old.turns != turns || old.phase != phase; }