初始化

This commit is contained in:
谢宇宁
2026-09-15 15:44:13 +07:00
commit a23ba685e9
1065 changed files with 101122 additions and 0 deletions
@@ -0,0 +1,26 @@
class PointItem {
double x;
double y;
int? index;
bool isSelected;
bool isError;
bool isFirstSelected;
double angle;
PointItem({
required this.x,
required this.y,
this.index,
this.isSelected = false,
this.isError = false,
this.isFirstSelected = false,
this.angle = double.infinity,
});
@override
bool operator ==(Object other) =>
identical(this, other) || other is PointItem && runtimeType == other.runtimeType && x == other.x && y == other.y;
@override
int get hashCode => x.hashCode ^ y.hashCode;
}
@@ -0,0 +1,602 @@
import 'dart:math';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../../../../tools_base/debug_log.dart';
import '../model/point_item.dart';
import 'line_painter.dart';
/// 当点被选中时的回调函数
typedef OnHitPoint = void Function(List<int> result);
/// 手势滑动结束时的回调函数
/// [result] 已经选择的所有点的结果集
typedef OnComplete = void Function(List<int> result);
class GesturePasswordWidget extends StatefulWidget with DiagnosticableTreeMixin {
/// GesturePasswordWidget 的 width 和 height.
final double size;
///用来判断点是否被选中的区域大小,值越大识别越精准.
final double identifySize;
///正常情况下展示的widget
final Widget? normalItem;
///选中情况下展示的widget
final Widget? selectedItem;
/// 错误情况下展示的widget,只有设置了[minLength]或[answer]时才会生效,
/// 1)当[minLength]不为null时,如果选择的点的数量小于minLength,则展示[errorItem]
/// 比如设置了 minLength = 4,但是选择的点的结果集为 [0,1,3],共选择了3个点,小于4;
/// 2)当[answer]不为null时,如果选择的点的结果集和[answer]不相等,则展示[errorItem]
/// 比如 answer = [0,1,2,4,7],但是选择的点的结果集为[0,1,2,5,8],和answer不相等;
/// 另外,[errorItem]的展示时长由[completeWaitMilliseconds]控制。
final Widget? errorItem;
/// 当这个点被选中时要展示的widget,其展示时长由[hitShowMilliseconds]控制,达到展示时长
/// 后继续展示[selectedItem]。
final Widget? hitItem;
///正常情况下显示的箭头控件。
///跟随手势旋转时,x轴正方向为0度,所以如果你使用了箭头,确保箭头指向x轴正方向。
final Widget? arrowItem;
///错误情况下显示的箭头控件,如果设置了[errorArrowItem],则必须设置[arrowItem],
///否则[errorArrowItem]不会展示。
///跟随手势旋转时,x轴正方向为0度,所以如果你使用了箭头,确保箭头指向x轴正方向。
final Widget? errorArrowItem;
///[arrowItem]和[errorArrowItem]在x轴上的偏移,原点在[normalItem]的中心。
///当 -1 < [arrowXAlign] < 1 时,[arrowItem]和[errorArrowItem]在[normalItem]范围内进行绘制;
///当[arrowXAlign] > 1 或者[arrowXAlign] < -1时,在[normalItem]范围外进行绘制;
final double arrowXAlign;
///[arrowItem]和[errorArrowItem]在y轴上的偏移,原点在[normalItem]的中心。
///当 -1 < [arrowYAlign] < 1 时,[arrowItem]和[errorArrowItem]在[normalItem]范围内进行绘制;
///当[arrowYAlign] > 1 或者[arrowYAlign] < -1时,在[normalItem]范围外进行绘制;
final double arrowYAlign;
///单行个数,总个数等于 singleLineCount * singleLineCount.
final int singleLineCount;
///GesturePasswordWidget的背景色,默认为 [Theme.of].[scaffoldBackgroundColor]
final Color? color;
///当点被选中时的回调函数
final OnHitPoint? onHitPoint;
///手势滑动结束时的回调函数
final OnComplete? onComplete;
///线的颜色
final Color lineColor;
///错误场景下线的颜色,见[errorItem]
final Color errorLineColor;
///线的宽度
final double lineWidth;
/// 是否采用宽松策略,默认为true。
/// 考虑这种情况:第一个点选中了 index = 0 的点,第二个点选中了 index = 6的点,
/// 此时index = 0,index = 3,index = 6这三个点在一条直线上,
/// 如果loose为true,输出为[0,3,6],
/// 如果loose为false,输出为[0,6].
final bool loose;
///正确的结果,demo: [0, 1, 2, 4, 7]
final List<int>? answer;
///最后选择的所有点及绘制的直线在屏幕上展示的时间,时间结束后,清除所有点,恢复到初始状态,
///时间结束之前 GesturePasswordWidget 不再接受任何手势事件。
final int completeWaitMilliseconds;
/// 只是用来展示用,不能触摸
final bool ignoring;
///见[hitItem]
final int hitShowMilliseconds;
///如果设置了此值,则长度不够时显示[errorItem]和[errorLineColor].
final int? minLength;
///是否开启触觉反馈(命中点轻震、绘制错误较重震),默认开启
final bool enableHaptic;
GesturePasswordWidget({
super.key,
this.size = 300.0,
this.identifySize = 50.0,
this.normalItem,
this.selectedItem,
this.errorItem,
this.hitItem,
this.arrowItem,
this.errorArrowItem,
this.arrowXAlign = 0.6,
this.arrowYAlign = 0.0,
this.singleLineCount = 3,
this.color,
this.ignoring = false,
this.onHitPoint,
this.onComplete,
this.lineColor = Colors.green,
this.errorLineColor = Colors.redAccent,
this.lineWidth = 2.0,
this.answer,
this.loose = true,
this.completeWaitMilliseconds = 300,
this.hitShowMilliseconds = 40,
this.minLength,
this.enableHaptic = true,
}) : assert(singleLineCount > 1, 'singLineCount must not be smaller than 1'),
assert(identifySize > 0),
assert(size > identifySize),
assert(!(errorArrowItem != null && arrowItem == null), 'when arrowItem == null, errorArrowItem will not be shown.');
@override
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
super.debugFillProperties(properties);
properties.add(DoubleProperty('size', size));
properties.add(DoubleProperty('identifySize', identifySize));
properties.add(DiagnosticsProperty<Widget>('normalItem', normalItem));
properties.add(DiagnosticsProperty<Widget>('selectedItem', selectedItem));
properties.add(DiagnosticsProperty<Widget>('errorItem', errorItem));
properties.add(DiagnosticsProperty<Widget>('hitItem', hitItem));
properties.add(DiagnosticsProperty<Widget>('arrowItem', arrowItem));
properties.add(
DiagnosticsProperty<Widget>('errorArrowItem', errorArrowItem),
);
properties.add(DoubleProperty('arrowXAlign', arrowXAlign));
properties.add(DoubleProperty('arrowYAlign', arrowYAlign));
properties.add(IntProperty('singleLineCount', singleLineCount));
properties.add(ColorProperty('color', color));
properties.add(DiagnosticsProperty<OnHitPoint>('onHitPoint', onHitPoint));
properties.add(DiagnosticsProperty<OnComplete>('onComplete', onComplete));
properties.add(ColorProperty('lineColor', lineColor));
properties.add(ColorProperty('errorLineColor', errorLineColor));
properties.add(IterableProperty('answer', answer));
properties.add(DoubleProperty('lineWidth', lineWidth));
properties.add(FlagProperty(
'loose',
value: loose,
ifFalse: 'loose: false',
ifTrue: 'loose: true',
defaultValue: true,
));
properties.add(
IntProperty('completeWaitMilliseconds', completeWaitMilliseconds),
);
properties.add(IntProperty('hitShowMilliseconds', hitShowMilliseconds));
properties.add(IntProperty('minLength', minLength));
}
@override
State<GesturePasswordWidget> createState() => _GesturePasswordWidgetState();
}
class _GesturePasswordWidgetState extends State<GesturePasswordWidget> {
late Point origin;
late int totalCount;
Point<double>? lastPoint;
Widget? normalItem;
Widget? defaultNormalItem;
Widget? selectedItem;
Widget? defaultSelectedItem;
Widget? errorItem;
Widget? defaultErrorItem;
Color? lineColor;
String tipText = '绘制解锁图形';
bool _completing = false; // 完成动画期间锁定手势,避免动画未结束就开始下一笔
final points = <PointItem>[];
final linePoints = <Point<double>>[];
final result = <int>[];
final double defaultSize = 10.0;
@override
void initState() {
super.initState();
defaultNormalItem = Container(
width: defaultSize,
height: defaultSize,
decoration: BoxDecoration(
color: Colors.greenAccent,
borderRadius: BorderRadius.circular(50.0),
),
);
defaultSelectedItem = Container(
width: defaultSize,
height: defaultSize,
decoration: BoxDecoration(
color: Colors.green,
borderRadius: BorderRadius.circular(50.0),
),
);
defaultErrorItem = Container(
width: defaultSize,
height: defaultSize,
decoration: BoxDecoration(
color: Colors.red,
borderRadius: BorderRadius.circular(50.0),
),
);
if (widget.answer?.isNotEmpty ?? false) {
tipText = '验证解锁图形';
}
lineColor = widget.lineColor;
normalItem = widget.normalItem ?? defaultNormalItem;
selectedItem = widget.selectedItem ?? defaultSelectedItem;
errorItem = widget.errorItem ?? defaultErrorItem;
totalCount = widget.singleLineCount * widget.singleLineCount;
origin = Point<double>(widget.size * 0.5, widget.size * 0.5);
calculatePointPosition();
}
@override
void didUpdateWidget(GesturePasswordWidget oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.lineColor != oldWidget.lineColor) {
lineColor = widget.lineColor;
}
if (widget.normalItem != oldWidget.normalItem) {
normalItem = widget.normalItem ?? defaultNormalItem;
}
if (widget.selectedItem != oldWidget.selectedItem) {
selectedItem = widget.selectedItem ?? defaultSelectedItem;
}
if (widget.errorItem != oldWidget.errorItem) {
errorItem = widget.errorItem ?? defaultErrorItem;
}
if (widget.singleLineCount != oldWidget.singleLineCount ||
widget.size != oldWidget.size ||
widget.identifySize != oldWidget.identifySize) {
totalCount = widget.singleLineCount * widget.singleLineCount;
origin = Point<double>(widget.size * 0.5, widget.size * 0.5);
points.clear();
calculatePointPosition();
}
}
@override
Widget build(BuildContext context) {
//预览小点的边长:每行 singleLineCount 个 10px 的点 + 之间 8px 间距。必须按 singleLineCount 算,
//写死值(原来是 56)比内容宽时 Wrap 默认贴左上,整块预览会偏左偏上,且换行数只对 3x3 成立
final previewSize = 10.0 * widget.singleLineCount + 8 * (widget.singleLineCount - 1);
return Column(
children: [
SizedBox(
width: previewSize,
height: previewSize,
child: Wrap(
runSpacing: 8,
spacing: 8,
children: points
.map((e) => Container(
width: 10,
height: 10,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(12),
//选中态靠加粗边框填实心,边框画在 10x10 内部,不影响尺寸和对齐
border: Border.all(width: e.isSelected ? 5 : 1.0, color: const Color(0xff6792ff)),
),
))
.toList(),
),
),
Container(
margin: EdgeInsets.symmetric(vertical: 20),
child: Text(
tipText,
style: TextStyle(
color: Colors.white,
),
),
),
IgnorePointer(
ignoring: widget.ignoring || _completing,
child: Container(
color: widget.color ?? Theme.of(context).scaffoldBackgroundColor,
width: widget.size,
height: widget.size,
child: Stack(
children: createPointsWidget()
..add(
GestureDetector(
onPanDown: handlePanDown,
onPanUpdate: handlePanUpdate,
onPanEnd: handlePanEnd,
onPanCancel: () {
handlePanEnd(null);
},
child: CustomPaint(
painter: LinePainter(
points: linePoints,
lineColor: lineColor,
lineWidth: widget.lineWidth,
),
willChange: true,
size: Size(widget.size, widget.size),
),
),
),
),
),
)
],
);
}
//计算每个点的位置
void calculatePointPosition() {
double initX = widget.identifySize * 0.5;
double initY = widget.identifySize * 0.5;
double gap = (widget.size - widget.identifySize) / (widget.singleLineCount - 1);
for (int i = 0; i < totalCount; i++) {
double centerX = initX + i % widget.singleLineCount * gap;
double centerY = initY + i ~/ widget.singleLineCount * gap;
var point = PointItem(x: centerX, y: centerY, index: i);
//展示模式(ignoring)下预置 answer 命中的点为选中
if (widget.ignoring && (widget.answer?.contains(i) ?? false)) {
point.isSelected = true;
}
points.add(point);
}
}
//创建每个点的widget
List<Widget> createPointsWidget() {
return points.map<Widget>((p) {
double reference = 1 - (widget.identifySize / widget.size);
double x = (p.x - origin.x) / (widget.size * 0.5) / reference;
double y = (p.y - origin.y) / (widget.size * 0.5) / reference;
Widget? child = normalItem;
if (p.isError) {
child = errorItem;
} else if (p.isFirstSelected) {
child = widget.hitItem;
} else if (p.isSelected) {
child = selectedItem;
}
Widget? arrowItem = widget.arrowItem;
if (p.isError && widget.errorArrowItem != null) {
arrowItem = widget.errorArrowItem;
}
return Align(
alignment: Alignment(x, y),
child: Container(
color: Colors.transparent,
width: widget.identifySize,
height: widget.identifySize,
alignment: Alignment.center,
child: widget.arrowItem == null || p.angle == double.infinity
? child
: Transform.rotate(
angle: p.angle,
child: Stack(
alignment: AlignmentDirectional.center,
children: [
child!,
Align(
alignment: Alignment(
widget.arrowXAlign,
widget.arrowYAlign,
),
child: arrowItem,
),
],
),
),
),
);
}).toList();
}
void handlePanDown(DragDownDetails details) {
Point<double> curPoint = Point(details.localPosition.dx, details.localPosition.dy);
final point = calculateHitPoint(curPoint);
if (point != null) {
if (!linePoints.contains(Point(point.x, point.y))) {
addPointToResult(point.index);
setState(() {
point.isSelected = true;
linePoints.add(Point(point.x, point.y));
});
}
}
}
void handlePanUpdate(DragUpdateDetails details) {
Point<double> curPoint = Point(details.localPosition.dx, details.localPosition.dy);
final hitPoint = calculateHitPoint(curPoint);
if (hitPoint != null) {
if (!linePoints.contains(Point(hitPoint.x, hitPoint.y))) {
final drawPoint = Point(hitPoint.x, hitPoint.y);
//宽松策略下,若三点共线则自动将中间的点设置为选中状态。
if (widget.loose && linePoints.isNotEmpty) {
handleLooseCase(points[result.last], hitPoint);
}
//处理箭头的角度展示
if (widget.arrowItem != null) {
for (int i = 0; i < result.length - 1; i++) {
final p1 = Point(points[result[i]].x, points[result[i]].y);
final p2 = Point(points[result[i + 1]].x, points[result[i + 1]].y);
points[result[i]].angle = calculateAngle(p1, p2);
}
}
if (result.isNotEmpty) {
final p1 = Point(points[result.last].x, points[result.last].y);
points[result.last].angle = calculateAngle(p1, Point(hitPoint.x, hitPoint.y));
}
addPointToResult(hitPoint.index);
setState(() {
linePoints.remove(lastPoint);
hitPoint.isSelected = true;
linePoints.add(drawPoint);
});
}
} else {
if (linePoints.isNotEmpty) {
if (widget.arrowItem != null) {
final p1 = Point(points[result.last].x, points[result.last].y);
points[result.last].angle = calculateAngle(p1, curPoint);
}
setState(() {
linePoints.remove(lastPoint);
linePoints.add(curPoint);
});
lastPoint = curPoint;
}
}
}
void handlePanEnd(DragEndDetails? details) async {
if (result.isEmpty) {
return;
}
if (!mounted) {
return;
}
linePoints.removeLast();
if ((widget.answer != null && widget.answer!.join() != result.join()) ||
(widget.minLength != null && widget.minLength! > result.length)) {
lineColor = widget.errorLineColor;
if (widget.minLength != null && widget.minLength! > result.length) {
tipText = '请至少链接4个点';
} else {
tipText = '图形绘制不正确';
}
for (int i = 0; i < result.length; i++) {
points[result[i]].isError = true;
}
if (widget.enableHaptic) HapticFeedback.mediumImpact(); //绘制错误:较重震动
}
//清除最后一个点的角度
points[result.last].angle = double.infinity;
if (!mounted) {
return;
}
setState(() {
_completing = true;
});
await Future.delayed(Duration(
milliseconds: widget.completeWaitMilliseconds,
));
_completing = false;
lineColor = widget.lineColor;
widget.onComplete?.call(result);
debugLog('handlePanEnd answer=${widget.answer}, result = $result');
if (!mounted) {
return;
}
setState(() {
for (final p in points) {
p.isSelected = false;
p.isError = false;
p.angle = double.infinity;
}
linePoints.clear();
result.clear();
});
}
//计算命中的点
PointItem? calculateHitPoint(Point<double> curPoint) {
for (int i = 0; i < points.length; i++) {
final p = Point(points[i].x, points[i].y);
if (p.distanceTo(curPoint) < widget.identifySize * 0.5) {
if (points[i].isSelected) {
return null;
}
return points[i];
}
}
return null;
}
void addPointToResult(int? index) {
if (widget.enableHaptic) HapticFeedback.selectionClick(); //每命中一个点轻震,提升手感
result.add(index ?? 0);
widget.onHitPoint?.call(result);
if (widget.hitItem != null) {
setState(() {
points[index!].isFirstSelected = true;
});
Future.delayed(Duration(milliseconds: widget.hitShowMilliseconds), () {
if (!mounted) return;
setState(() {
points[index!].isFirstSelected = false;
});
});
}
}
//根据海伦公式计算三角形面积,面积为0时视为三点共线。如果这个点还在共线的中间,
//则将其设置为选中状态,并将其添加到result中。
void handleLooseCase(PointItem pre, PointItem next) {
List<int?> midItems = [];
for (final item in points) {
if (item != pre && item != next && item.isSelected == false) {
final itemDrawPoint = Point<double>(item.x, item.y);
final preDrawPoint = Point<double>(pre.x, pre.y);
final nextDrawPoint = Point<double>(next.x, next.y);
double a = itemDrawPoint.distanceTo(preDrawPoint);
double b = itemDrawPoint.distanceTo(nextDrawPoint);
double c = preDrawPoint.distanceTo(nextDrawPoint);
double p = (a + b + c) * 0.5;
double area = p * (p - a) * (p - b) * (p - c);
double halfDistance = c * 0.5;
Point<double> mid = Point(
(pre.x + next.x) * 0.5,
(pre.y + next.y) * 0.5,
);
if (area - 0.5 <= 0 && itemDrawPoint.distanceTo(mid) < halfDistance) {
item.isSelected = true;
midItems.add(item.index);
}
}
}
if (next.index! > pre.index!) {
midItems.sort((a, b) => a! - b!);
} else {
midItems.sort((a, b) => b! - a!);
}
for (final index in midItems) {
addPointToResult(index);
}
}
//计算两点之间连线和 x 轴的夹角,返回弧度
double calculateAngle(Point p1, Point p2) {
return atan2((p2.y - p1.y), (p2.x - p1.x)); //弧度
}
}
@@ -0,0 +1,38 @@
import 'dart:math';
import 'package:flutter/widgets.dart';
class LinePainter extends CustomPainter {
final List<Point>? points;
final Color? lineColor;
final double? lineWidth;
LinePainter({
this.points,
this.lineColor,
this.lineWidth,
});
@override
void paint(Canvas canvas, Size size) {
if (points != null && points!.length > 1) {
Paint paint = Paint();
paint.strokeWidth = lineWidth!;
paint.color = lineColor!;
paint.isAntiAlias = true;
paint.style = PaintingStyle.fill;
paint.strokeCap = StrokeCap.round;
for (int i = 0; i < points!.length - 1; i++) {
canvas.drawLine(
Offset(points![i].x as double, points![i].y as double),
Offset(points![i + 1].x as double, points![i + 1].y as double),
paint,
);
}
}
}
@override
bool shouldRepaint(LinePainter oldDelegate) => true;
}