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>
111 lines
3.8 KiB
Dart
111 lines
3.8 KiB
Dart
// tab_swipe.dart — Tab 左右滑动切换的手势仲裁组件
|
|
//
|
|
// 约定(design/CLAUDE.md §5):横向位移 >60px 且明显大于纵向时切换 Tab;
|
|
// 子页内的纵向滚动区域不响应该手势。
|
|
//
|
|
// 仲裁机制:本组件只接 HorizontalDrag。纵向滚动由内部 ListView 的
|
|
// VerticalDragGestureRecognizer 在手势竞技场中胜出,本组件的横向识别器
|
|
// 自然落败——因此「滚动列表/纵向拖拽」永远不会误触发 Tab 切换,无需手写
|
|
// dx/dy 比例判断。横向为主的拖拽才会进入本组件并按阈值判定方向。
|
|
import 'package:flutter/material.dart';
|
|
|
|
/// 方向感知:1 = 向后(进入右侧 Tab),-1 = 向前(进入左侧 Tab)。
|
|
typedef SwipeCallback = void Function();
|
|
|
|
class HorizontalSwipeArea extends StatefulWidget {
|
|
const HorizontalSwipeArea({
|
|
super.key,
|
|
required this.child,
|
|
this.onSwipeLeft,
|
|
this.onSwipeRight,
|
|
this.threshold = 60,
|
|
});
|
|
|
|
/// 包裹的内容(通常是当前 Tab 页)。
|
|
final Widget child;
|
|
|
|
/// 向左滑(手指从右往左)→ 切到下一个 Tab。
|
|
final VoidCallback? onSwipeLeft;
|
|
|
|
/// 向右滑(手指从左往右)→ 切到上一个 Tab。
|
|
final VoidCallback? onSwipeRight;
|
|
|
|
/// 触发阈值(逻辑像素)。
|
|
final double threshold;
|
|
|
|
@override
|
|
State<HorizontalSwipeArea> createState() => _HorizontalSwipeAreaState();
|
|
}
|
|
|
|
class _HorizontalSwipeAreaState extends State<HorizontalSwipeArea> {
|
|
double _dx = 0;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return GestureDetector(
|
|
// 仅接横向拖拽:纵向滚动交给内部可滚动组件(竞技场仲裁)。
|
|
onHorizontalDragStart: (_) => _dx = 0,
|
|
onHorizontalDragUpdate: (d) => _dx += d.delta.dx,
|
|
onHorizontalDragEnd: (details) {
|
|
final v = details.primaryVelocity ?? 0;
|
|
// 位移过阈值,或带明显速度的快划。
|
|
final left = _dx <= -widget.threshold || v < -300;
|
|
final right = _dx >= widget.threshold || v > 300;
|
|
if (left) {
|
|
widget.onSwipeLeft?.call();
|
|
} else if (right) {
|
|
widget.onSwipeRight?.call();
|
|
}
|
|
_dx = 0;
|
|
},
|
|
child: widget.child,
|
|
);
|
|
}
|
|
}
|
|
|
|
/// 方向感知的页面切换动画:200ms 轻移淡入(对齐 React pg-in-l / pg-in-r)。
|
|
class DirectionalTabSwitcher extends StatelessWidget {
|
|
const DirectionalTabSwitcher({super.key, required this.index, required this.direction, required this.child});
|
|
|
|
/// 当前 Tab 索引(作为切换 key)。
|
|
final int index;
|
|
|
|
/// 进入方向:1 从右进(下一个),-1 从左进(上一个),0 无动画。
|
|
final int direction;
|
|
final Widget child;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return AnimatedSwitcher(
|
|
duration: const Duration(milliseconds: 200),
|
|
switchInCurve: PangolinMotionEaseOut.curve,
|
|
transitionBuilder: (child, animation) {
|
|
// 仅对「进入」的新页做方向位移 + 淡入;旧页直接淡出,避免回弹。
|
|
final isIncoming = child.key == ValueKey<int>(index);
|
|
if (direction == 0 || !isIncoming) {
|
|
return FadeTransition(opacity: animation, child: child);
|
|
}
|
|
final begin = Offset(direction > 0 ? 0.06 : -0.06, 0);
|
|
return FadeTransition(
|
|
opacity: animation,
|
|
child: SlideTransition(
|
|
position: Tween<Offset>(begin: begin, end: Offset.zero).animate(animation),
|
|
child: child,
|
|
),
|
|
);
|
|
},
|
|
layoutBuilder: (currentChild, previousChildren) => Stack(
|
|
alignment: Alignment.topCenter,
|
|
children: [...previousChildren, if (currentChild != null) currentChild],
|
|
),
|
|
child: KeyedSubtree(key: ValueKey<int>(index), child: child),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// 暴露 easeOut 曲线(避免在本文件 import 主题造成循环)。
|
|
class PangolinMotionEaseOut {
|
|
PangolinMotionEaseOut._();
|
|
static const Curve curve = Cubic(0.22, 1, 0.36, 1);
|
|
}
|