Files
pangolin/client/lib/widgets/tab_swipe.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

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);
}