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>
This commit is contained in:
wangjia
2026-06-13 14:32:36 +08:00
parent 787151245e
commit f97ae8186b
40 changed files with 3369 additions and 567 deletions
+22
View File
@@ -0,0 +1,22 @@
// app_providers.dart — 语言 / 主题 / 套餐视角等基础状态(Riverpod)
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../l10n/app_text.dart';
import '../l10n/strings_en.dart';
import '../l10n/strings_zh.dart';
/// 当前语言(单显)。设置/账户页段控切换。
final localeProvider = StateProvider<AppLang>((ref) => AppLang.zh);
/// 由语言派生的文案资源——UI 一律通过它取文案,不写死字面量。
final appTextProvider = Provider<AppText>((ref) {
final lang = ref.watch(localeProvider);
return lang == AppLang.zh ? const StringsZh() : const StringsEn();
});
/// 主题模式。默认跟随系统;设置页可显式切深色。
final themeModeProvider = StateProvider<ThemeMode>((ref) => ThemeMode.system);
/// 演示:是否「免费版视角」。免费版才显示额度卡与免费横幅。
final isFreePlanProvider = StateProvider<bool>((ref) => true);
+94
View File
@@ -0,0 +1,94 @@
// 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(),
);
+25
View File
@@ -0,0 +1,25 @@
// nodes_provider.dart — 节点清单 + 当前选择(含智能选择 AUTO)
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../models/node.dart';
/// 可用节点(演示数据;接口就绪后替换为 nodes API)。
final nodesProvider = Provider<List<Node>>((ref) => kDemoNodes);
/// 当前选中的节点 code;`AUTO` 表示智能选择(默认)。
final selectedNodeCodeProvider = StateProvider<String>((ref) => kSmartNodeCode);
/// 是否处于智能选择。
final isSmartSelectProvider = Provider<bool>(
(ref) => ref.watch(selectedNodeCodeProvider) == kSmartNodeCode,
);
/// 实际生效的节点:智能选择时取延迟最小者,否则取选中节点。
final effectiveNodeProvider = Provider<Node>((ref) {
final nodes = ref.watch(nodesProvider);
final code = ref.watch(selectedNodeCodeProvider);
if (code == kSmartNodeCode) {
return nodes.reduce((a, b) => a.ping <= b.ping ? a : b);
}
return nodes.firstWhere((n) => n.code == code, orElse: () => nodes.first);
});
+53
View File
@@ -0,0 +1,53 @@
// quota_provider.dart — 免费版每日额度状态机(Riverpod,mock 数据)
//
// 设计约定(design/CLAUDE.md §7 / §2):免费额度权威在服务端,本地仅展示。
// 这里以 mock 实现 UI 与数据层解耦的接口形态:剩余分钟 + 是否已看广告解锁。
// 接 API 时只替换本通知器内部,UI 不动。
import 'package:flutter_riverpod/flutter_riverpod.dart';
/// 免费额度快照。
class FreeQuotaState {
const FreeQuotaState({
this.totalMinutes = 10,
this.remainingMinutes = 6,
this.adUnlocked = false,
});
/// 每日总额度(分钟)。§7:免费版每日 10 分钟。
final int totalMinutes;
/// 今日剩余分钟(展示值,权威以服务端为准)。
final int remainingMinutes;
/// 今日是否已观看激励视频解锁。
final bool adUnlocked;
/// 进度(0–1),用于进度条宽度。
double get progress =>
totalMinutes == 0 ? 0 : (remainingMinutes / totalMinutes).clamp(0.0, 1.0);
/// 是否进入低额度警示(≤3 分钟切 warning 色)。
bool get isLow => remainingMinutes <= 3;
FreeQuotaState copyWith({int? totalMinutes, int? remainingMinutes, bool? adUnlocked}) =>
FreeQuotaState(
totalMinutes: totalMinutes ?? this.totalMinutes,
remainingMinutes: remainingMinutes ?? this.remainingMinutes,
adUnlocked: adUnlocked ?? this.adUnlocked,
);
}
class QuotaController extends StateNotifier<FreeQuotaState> {
QuotaController([FreeQuotaState? initial])
: super(initial ?? const FreeQuotaState());
/// 观看激励视频后解锁今日使用。
void watchAd() => state = state.copyWith(adUnlocked: true);
/// 演示重置(回到未解锁)。
void reset() => state = const FreeQuotaState();
}
final quotaProvider = StateNotifierProvider<QuotaController, FreeQuotaState>(
(ref) => QuotaController(),
);