f97ae8186b
对照 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>
95 lines
2.7 KiB
Dart
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(),
|
|
);
|