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:
@@ -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);
|
||||
@@ -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(),
|
||||
);
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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(),
|
||||
);
|
||||
Reference in New Issue
Block a user