Files
pangolin/client/lib/state/connection_provider.dart
T
wangjia f97ae8186b feat(client): Flutter 逐屏还原 + iPad ≥900 断点 (tsk_gpPXi-icyeOE)
对照 design/ui_kits mobile+tablet 原型逐屏补齐 Flutter 客户端:

- l10n 资源层(strings_zh/en,单显)+ Riverpod 状态层(连接状态机/免费额度/
  节点选择/语言/主题),UI 与数据解耦,mock 数据接 API 不动 UI。
- 连接键严格三态(off 虚线轨道环 / connecting 旋转弧 / on 满环+计时+光晕),
  状态来自 connectionProvider,点击只派发事件——禁止乐观显示。
- 节点页置顶「智能选择」推荐卡(clay 渐变 zap + 推荐胶囊,默认选中);
  免费额度卡(剩余分钟+进度条 ≤3 分钟切 warning + 看广告解锁变绿)。
- Tab 左右滑动切换(手势竞技场仲裁,子页滚动不误触发,200ms 方向感知滑入)。
- iPad/宽屏 ≥900 LayoutBuilder 切侧栏分栏(导航行高 ≥48,连接页双栏/节点双列网格),
  复用同一批原子组件,不 fork 页面。
- 语义 token 零硬编码;文案全部走 l10n;套餐数字对齐 §7;无支付表单/emoji 国旗。
- CI 红线词扫描扩展到 client/lib;新增 Flutter analyze+test CI 任务。
- 测试:连接状态机/额度/节点单测、连接键三态与卡片组件测试、
  golden(连接键三态/推荐卡/额度卡 × 明暗)。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 14:32:36 +08:00

95 lines
2.7 KiB
Dart

// connection_provider.dart — 连接状态机(严格三态,禁止乐观显示)
//
// 设计约定(design/CLAUDE.md §2/§5):连接键三态严格对应状态层事件,
// UI 只渲染本通知器的真实状态,绝不在点击时本地乐观翻转。连接握手由
// 状态层(此处为 mock 计时器,后续替换为隧道事件)决定何时进入 on。
import 'dart:async';
import 'package:flutter_riverpod/flutter_riverpod.dart';
/// 连接阶段。严格三态——与 React 原型一致。
enum VpnPhase { off, connecting, on }
/// 连接状态快照。
class ConnectionState {
const ConnectionState({required this.phase, this.elapsed = Duration.zero});
final VpnPhase phase;
final Duration elapsed;
ConnectionState copyWith({VpnPhase? phase, Duration? elapsed}) =>
ConnectionState(phase: phase ?? this.phase, elapsed: elapsed ?? this.elapsed);
@override
bool operator ==(Object other) =>
other is ConnectionState && other.phase == phase && other.elapsed == elapsed;
@override
int get hashCode => Object.hash(phase, elapsed);
}
/// 连接状态机。握手时长可注入,便于测试确定化。
class ConnectionController extends StateNotifier<ConnectionState> {
ConnectionController({this.handshake = const Duration(milliseconds: 1500)})
: super(const ConnectionState(phase: VpnPhase.off));
final Duration handshake;
Timer? _handshakeTimer;
Timer? _tick;
/// 用户轻点连接键:仅依据真实状态决定动作,握手中点击被忽略(禁乐观)。
void toggle() {
switch (state.phase) {
case VpnPhase.off:
connect();
case VpnPhase.on:
disconnect();
case VpnPhase.connecting:
break; // 握手进行中,不响应——避免乐观回退
}
}
void connect() {
_cancelTimers();
state = const ConnectionState(phase: VpnPhase.connecting);
_handshakeTimer = Timer(handshake, _onConnected);
}
void disconnect() {
_cancelTimers();
state = const ConnectionState(phase: VpnPhase.off);
}
/// 切换节点时触发:已连接则重连(短暂回到 connecting)。
void onNodeChanged() {
if (state.phase == VpnPhase.on || state.phase == VpnPhase.connecting) {
connect();
}
}
void _onConnected() {
state = const ConnectionState(phase: VpnPhase.on);
_tick = Timer.periodic(const Duration(seconds: 1), (_) {
state = state.copyWith(elapsed: state.elapsed + const Duration(seconds: 1));
});
}
void _cancelTimers() {
_handshakeTimer?.cancel();
_handshakeTimer = null;
_tick?.cancel();
_tick = null;
}
@override
void dispose() {
_cancelTimers();
super.dispose();
}
}
final connectionProvider =
StateNotifierProvider<ConnectionController, ConnectionState>(
(ref) => ConnectionController(),
);