// 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 '../util/format.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, this.enabled = true, this.onDisabledTap, }); final VpnPhase phase; final VoidCallback onTap; /// 是否可点。免费额度耗尽时置 false → 灰化不可连,点击走 onDisabledTap。 final bool enabled; /// 灰化态被点击的回调(如弹看广告加时/升级)。enabled=false 时生效。 final VoidCallback? onDisabledTap; /// 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(); } @override Widget build(BuildContext context) { final c = context.pangolin; final s = widget.phase; // 免费额度耗尽:off 态灰化不可连(锁图标 + 柔和阴影),点击走 onDisabledTap。 final disabled = !widget.enabled && s == VpnPhase.off; final Color fill = disabled ? c.bgSubtle : switch (s) { VpnPhase.off => c.bgSubtle, VpnPhase.connecting => c.accent, VpnPhase.on => c.success, }; final Color fg = disabled ? c.fg3 : (s == VpnPhase.off ? c.accent : PangolinColors.white); final IconData icon = disabled ? PangolinIcons.lock : 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: disabled ? widget.onDisabledTap : 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(formatDuration(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; }