This commit is contained in:
2026-08-08 10:57:54 +08:00
parent d0d48c1ffc
commit 604af909b0
184 changed files with 12521 additions and 12722 deletions
@@ -0,0 +1,62 @@
import '../../../data/local/models/models.dart';
/// Computes archery score from a normalized hit coordinate.
///
/// Normalized coordinates use the range [-1, 1] where (0, 0) is the target
/// center and radius 1.0 is the outer scoring edge. Hits outside radius 1.0
/// count as a miss (M, score 0).
class ScoreMath {
const ScoreMath._();
/// Returns the score [0..10] for a hit at [normalizedX]/[normalizedY].
static int scoreFor(double normalizedX, double normalizedY) {
final d = distance(normalizedX, normalizedY);
if (d > 1.0) return 0; // miss
final ring = (d * 10).floor();
var score = 10 - ring;
if (score < 1) score = 1;
if (score > 10) score = 10;
return score;
}
/// Euclidean distance from center for normalized coords.
static double distance(double normalizedX, double normalizedY) {
final sq = normalizedX * normalizedX + normalizedY * normalizedY;
if (sq <= 0) return 0.0;
return _sqrt(sq);
}
/// Converts a pixel tap (relative to a square target of [size]) into a
/// normalized coordinate in [-1, 1] and returns the resulting [HitPoint].
static HitPoint hitPointFromPixels(
double localX,
double localY,
double size, {
required int arrowIndex,
required int endIndex,
}) {
final half = size / 2;
final nx = (localX - half) / half;
final ny = (localY - half) / half;
final score = scoreFor(nx, ny);
return HitPoint(
arrowIndex: arrowIndex,
endIndex: endIndex,
normalizedX: nx,
normalizedY: ny,
score: score,
isMiss: score == 0,
);
}
}
double _sqrt(double x) {
// Newton's method; precise enough for scoring distances.
if (x < 0) return double.nan;
if (x == 0) return 0.0;
var r = x;
for (var i = 0; i < 20; i++) {
r = (r + x / r) / 2;
}
return r;
}
@@ -0,0 +1,100 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:isar/isar.dart';
import '../../../core/providers/app_providers.dart';
import '../../../data/local/models/models.dart';
/// All local [PointRecord]s (drafts + completed), newest first.
final pointRecordsProvider =
AsyncNotifierProvider<PointRecordsNotifier, List<PointRecord>>(
PointRecordsNotifier.new,
);
/// Loads a single record by id (used by the edit/view page).
final recordByIdProvider =
FutureProvider.family<PointRecord?, int>((ref, id) async {
final isar = await ref.watch(isarProvider.future);
return isar.pointRecords.get(id);
});
class PointRecordsNotifier extends AsyncNotifier<List<PointRecord>> {
@override
Future<List<PointRecord>> build() async {
final isar = await ref.watch(isarProvider.future);
return isar.pointRecords.where().sortByCreatedAtDesc().findAll();
}
Future<Isar> _isar() => ref.read(isarProvider.future);
/// Returns the current draft record, if any.
Future<PointRecord?> currentDraft() async {
final isar = await _isar();
return isar.pointRecords
.filter()
.statusEqualTo(PointRecordStatus.draft)
.sortByCreatedAtDesc()
.findFirst();
}
/// Creates a new draft from scoring parameters and returns it.
Future<PointRecord> createDraft({
required BowType bowType,
required TargetFaceType targetFaceType,
required int distanceMeters,
required int endCount,
required int arrowsPerEnd,
String? title,
}) async {
final isar = await _isar();
final draft = PointRecord.createDraft(
bowType: bowType,
targetFaceType: targetFaceType,
distanceMeters: distanceMeters,
endCount: endCount,
arrowsPerEnd: arrowsPerEnd,
title: title,
);
await isar.writeTxn(() => isar.pointRecords.put(draft));
state = AsyncData([draft, ...?state.value]);
return draft;
}
/// Replaces a record (used by the edit page to persist ongoing changes).
Future<void> upsert(PointRecord record) async {
final isar = await _isar();
final updated = record..updatedAt = DateTime.now();
await isar.writeTxn(() => isar.pointRecords.put(updated));
state = AsyncData(state.value?.map((e) => e.id == updated.id ? updated : e).toList() ?? [updated]);
}
/// Marks a draft as completed and persists it. Does NOT touch trial count.
Future<PointRecord> complete(PointRecord record) async {
final completed = record.markCompleted();
final isar = await _isar();
await isar.writeTxn(() => isar.pointRecords.put(completed));
state = AsyncData(
state.value?.map((e) => e.id == completed.id ? completed : e).toList() ?? [completed],
);
return completed;
}
/// Deletes a single record by id.
Future<void> delete(int id) async {
final isar = await _isar();
await isar.writeTxn(() => isar.pointRecords.delete(id));
state = AsyncData(state.value?.where((e) => e.id != id).toList() ?? const []);
}
/// Removes any existing draft records (used when the user picks
/// "重新计分" on the home draft-confirmation dialog).
Future<void> clearDrafts() async {
final isar = await _isar();
final drafts = await isar.pointRecords
.filter()
.statusEqualTo(PointRecordStatus.draft)
.findAll();
if (drafts.isEmpty) return;
await isar.writeTxn(() => isar.pointRecords.deleteAll(drafts.map((e) => e.id).toList()));
state = AsyncData(state.value?.where((e) => !drafts.any((d) => d.id == e.id)).toList() ?? const []);
}
}
@@ -0,0 +1,302 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:arcx/app/app_theme.dart';
import 'package:arcx/data/local/models/models.dart';
import 'package:arcx/features/scoring/application/scoring_service.dart';
class PointBookCreatePage extends ConsumerStatefulWidget {
const PointBookCreatePage({super.key});
@override
ConsumerState<PointBookCreatePage> createState() =>
_PointBookCreatePageState();
}
class _PointBookCreatePageState extends ConsumerState<PointBookCreatePage> {
BowType _bowType = BowType.recurve;
TargetFaceType _targetFace = TargetFaceType.cm40;
int _distance = 18;
int _endCount = 6;
int _arrowsPerEnd = 6;
bool _creating = false;
static const _distances = [10, 18, 25, 30, 40, 50, 60, 70, 90];
static const _endOptions = [3, 4, 5, 6, 8, 10, 12];
static const _arrowOptions = [3, 4, 5, 6, 12];
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('新建记分')),
body: ListView(
padding: const EdgeInsets.all(16),
children: [
_SectionTitle('弓型'),
_ChipGroup(
options: BowType.values,
value: _bowType,
labelOf: _bowLabel,
onChanged: (v) => setState(() => _bowType = v),
),
const SizedBox(height: 16),
_SectionTitle('距离'),
_ChipGroup<int>(
options: _distances,
value: _distance,
labelOf: (v) => '${v}m',
onChanged: (v) => setState(() => _distance = v),
),
const SizedBox(height: 16),
_SectionTitle('靶纸类型'),
_ChipGroup(
options: TargetFaceType.values,
value: _targetFace,
labelOf: _targetLabel,
onChanged: (v) => setState(() => _targetFace = v),
),
const SizedBox(height: 16),
Row(
children: [
Expanded(
child: _NumberPicker(
label: '组数',
value: _endCount,
options: _endOptions,
onChanged: (v) => setState(() => _endCount = v),
),
),
const SizedBox(width: 12),
Expanded(
child: _NumberPicker(
label: '每组箭数',
value: _arrowsPerEnd,
options: _arrowOptions,
onChanged: (v) => setState(() => _arrowsPerEnd = v),
),
),
],
),
const SizedBox(height: 32),
_Summary(
bowType: _bowType,
distance: _distance,
targetFace: _targetFace,
endCount: _endCount,
arrowsPerEnd: _arrowsPerEnd,
),
const SizedBox(height: 16),
FilledButton.icon(
onPressed: _creating ? null : _start,
icon: const Icon(Icons.play_arrow),
label: const Text('开始记分'),
),
],
),
);
}
Future<void> _start() async {
setState(() => _creating = true);
final draft = await ref.read(pointRecordsProvider.notifier).createDraft(
bowType: _bowType,
targetFaceType: _targetFace,
distanceMeters: _distance,
endCount: _endCount,
arrowsPerEnd: _arrowsPerEnd,
);
if (mounted) {
setState(() => _creating = false);
Navigator.of(context).pop(draft);
}
}
}
class _SectionTitle extends StatelessWidget {
const _SectionTitle(this.text);
final String text;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Text(
text,
style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w600),
),
);
}
}
class _ChipGroup<T> extends StatelessWidget {
const _ChipGroup({
required this.options,
required this.value,
required this.labelOf,
required this.onChanged,
});
final List<T> options;
final T value;
final String Function(T) labelOf;
final ValueChanged<T> onChanged;
@override
Widget build(BuildContext context) {
return Wrap(
spacing: 8,
runSpacing: 8,
children: options.map((o) {
final selected = o == value;
return ChoiceChip(
label: Text(labelOf(o)),
selected: selected,
onSelected: (_) => onChanged(o),
selectedColor: AppTheme.primary,
labelStyle: TextStyle(
color: selected ? Colors.white : Colors.black87,
),
);
}).toList(),
);
}
}
class _NumberPicker extends StatelessWidget {
const _NumberPicker({
required this.label,
required this.value,
required this.options,
required this.onChanged,
});
final String label;
final int value;
final List<int> options;
final ValueChanged<int> onChanged;
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: Colors.black12),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(label, style: const TextStyle(fontSize: 13)),
Row(
children: [
IconButton(
icon: const Icon(Icons.remove_circle_outline),
onPressed: () {
final i = options.indexOf(value);
if (i > 0) onChanged(options[i - 1]);
},
),
Text(
'$value',
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.w700,
),
),
IconButton(
icon: const Icon(Icons.add_circle_outline),
onPressed: () {
final i = options.indexOf(value);
if (i < options.length - 1) onChanged(options[i + 1]);
},
),
],
),
],
),
);
}
}
class _Summary extends StatelessWidget {
const _Summary({
required this.bowType,
required this.distance,
required this.targetFace,
required this.endCount,
required this.arrowsPerEnd,
});
final BowType bowType;
final int distance;
final TargetFaceType targetFace;
final int endCount;
final int arrowsPerEnd;
@override
Widget build(BuildContext context) {
final total = endCount * arrowsPerEnd;
return Container(
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: AppTheme.primary.withValues(alpha: 0.08),
borderRadius: BorderRadius.circular(12),
),
child: Row(
children: [
Expanded(
child: Text(
'${_bowLabel(bowType)} · ${distance}m · ${_targetLabel(targetFace)}',
style: const TextStyle(fontSize: 13),
),
),
Text(
'$endCount组×$arrowsPerEnd支 = $total支',
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w700,
color: AppTheme.primaryDark,
),
),
],
),
);
}
}
String _bowLabel(BowType t) {
switch (t) {
case BowType.recurve:
return '反曲弓';
case BowType.compound:
return '复合弓';
case BowType.barebow:
return '光弓';
case BowType.traditional:
return '传统弓';
case BowType.longbow:
return '长弓';
case BowType.other:
return '其他';
}
}
String _targetLabel(TargetFaceType t) {
switch (t) {
case TargetFaceType.cm40:
return '40cm';
case TargetFaceType.cm60:
return '60cm';
case TargetFaceType.cm80:
return '80cm';
case TargetFaceType.cm122:
return '122cm';
case TargetFaceType.vegas3Spot:
return '维加斯三联';
case TargetFaceType.vertical3Spot:
return '垂直三联';
case TargetFaceType.custom:
return '自定义';
}
}
@@ -0,0 +1,398 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:arcx/app/app_theme.dart';
import 'package:arcx/features/config/application/app_config_controller.dart';
import 'package:arcx/data/local/models/models.dart';
import 'package:arcx/features/scoring/application/score_math.dart';
import 'package:arcx/features/scoring/application/scoring_service.dart';
import 'package:arcx/features/scoring/presentation/widgets/target_face_painter.dart';
class PointBookEditPage extends ConsumerStatefulWidget {
const PointBookEditPage({
super.key,
required this.recordId,
this.readOnly = false,
});
final int recordId;
final bool readOnly;
@override
ConsumerState<PointBookEditPage> createState() => _PointBookEditPageState();
}
class _PointBookEditPageState extends ConsumerState<PointBookEditPage> {
late List<int> _scores;
late List<HitPoint> _hitPoints;
bool _initialized = false;
bool _saving = false;
PointRecord? _record;
@override
Widget build(BuildContext context) {
final recordAsync = ref.watch(recordByIdProvider(widget.recordId));
return Scaffold(
appBar: AppBar(
title: Text(widget.readOnly ? '记分详情' : '记分中'),
actions: [
if (!widget.readOnly)
TextButton.icon(
onPressed: _saving ? null : _save,
icon: const Icon(Icons.check, color: Colors.white),
label: const Text('保存', style: TextStyle(color: Colors.white)),
),
],
),
body: recordAsync.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (e, _) => Center(child: Text('加载失败:$e')),
data: (record) {
if (record == null) {
return const Center(child: Text('记录不存在'));
}
if (!_initialized) {
_record = record;
_scores = List<int>.from(record.scores);
_hitPoints = List<HitPoint>.from(record.hitPoints);
_initialized = true;
}
return _buildBody(context, record);
},
),
);
}
Widget _buildBody(BuildContext context, PointRecord record) {
final totalArrows = record.endCount * record.arrowsPerEnd;
final placed = _scores.length;
final isComplete = placed >= totalArrows;
return Column(
children: [
_ProgressHeader(
record: record,
placed: placed,
total: totalArrows,
currentEnd: _currentEnd(record),
currentArrow: _currentArrow(record),
),
const SizedBox(height: 12),
Expanded(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: LayoutBuilder(
builder: (context, constraints) {
final size = constraints.maxWidth;
return GestureDetector(
onTapUp: widget.readOnly || isComplete || _saving
? null
: (details) => _onTap(details, size, record),
child: Stack(
alignment: Alignment.center,
children: [
SizedBox(
width: size,
height: size,
child: CustomPaint(
painter: TargetFacePainter(
hitPoints: _hitPoints,
highlightIndex: _hitPoints.isEmpty
? null
: _hitPoints.length - 1,
),
),
),
if (widget.readOnly && _hitPoints.isEmpty)
Text(
'无落点数据',
style: TextStyle(color: AppTheme.textSecondary),
),
if (!widget.readOnly && isComplete)
Container(
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 8,
),
decoration: BoxDecoration(
color: Colors.black54,
borderRadius: BorderRadius.circular(8),
),
child: const Text(
'已射完,点击右上角保存',
style: TextStyle(color: Colors.white),
),
),
],
),
);
},
),
),
),
const SizedBox(height: 12),
_EndSummary(record: record, scores: _scores),
if (!widget.readOnly)
Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 16),
child: Row(
children: [
OutlinedButton.icon(
onPressed: _saving || _scores.isEmpty ? null : _undo,
icon: const Icon(Icons.undo),
label: const Text('撤销'),
),
const Spacer(),
FilledButton(
onPressed: _saving ? null : _save,
child: const Text('保存记分'),
),
],
),
),
],
);
}
int _currentEnd(PointRecord record) {
if (_scores.isEmpty) return 0;
return (_scores.length - 1) ~/ record.arrowsPerEnd;
}
int _currentArrow(PointRecord record) {
if (_scores.isEmpty) return 0;
return (_scores.length - 1) % record.arrowsPerEnd;
}
void _onTap(TapUpDetails details, double size, PointRecord record) {
final localX = details.localPosition.dx;
final localY = details.localPosition.dy;
final totalArrows = record.endCount * record.arrowsPerEnd;
if (_scores.length >= totalArrows) return;
final arrowIndex = _scores.length;
final endIndex = arrowIndex ~/ record.arrowsPerEnd;
final hp = ScoreMath.hitPointFromPixels(
localX,
localY,
size,
arrowIndex: arrowIndex,
endIndex: endIndex,
);
setState(() {
_scores.add(hp.score ?? 0);
_hitPoints.add(hp);
});
_persist(record);
}
Future<void> _undo() async {
if (_scores.isEmpty) return;
setState(() {
_scores.removeLast();
_hitPoints.removeLast();
});
if (_record != null) {
await ref
.read(pointRecordsProvider.notifier)
.upsert(_record!.copyWithScores(_scores, _hitPoints));
}
}
Future<void> _persist(PointRecord record) async {
await ref
.read(pointRecordsProvider.notifier)
.upsert(record.copyWithScores(_scores, _hitPoints));
}
Future<void> _save() async {
if (_saving) return;
final record = _record;
if (record == null) return;
setState(() => _saving = true);
final completed = record.copyWithScores(_scores, _hitPoints);
await ref.read(pointRecordsProvider.notifier).complete(completed);
// Consume a trial credit on first successful completion of a draft.
if (record.isDraft) {
await ref.read(appConfigProvider.notifier).incrementTrialCount();
}
if (mounted) {
setState(() => _saving = false);
ScaffoldMessenger.of(
context,
).showSnackBar(const SnackBar(content: Text('记分已保存')));
Navigator.of(context).popUntil((route) => route.isFirst);
}
}
}
class _ProgressHeader extends StatelessWidget {
const _ProgressHeader({
required this.record,
required this.placed,
required this.total,
required this.currentEnd,
required this.currentArrow,
});
final PointRecord record;
final int placed;
final int total;
final int currentEnd;
final int currentArrow;
@override
Widget build(BuildContext context) {
return Container(
width: double.infinity,
color: AppTheme.primary.withValues(alpha: 0.06),
padding: const EdgeInsets.fromLTRB(16, 12, 16, 12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Text(
record.title,
style: const TextStyle(fontWeight: FontWeight.w600),
),
),
Text(
'$placed / $total',
style: const TextStyle(
fontWeight: FontWeight.w700,
color: AppTheme.primaryDark,
),
),
],
),
const SizedBox(height: 6),
if (placed < total)
Text(
'${currentEnd + 1}/${record.endCount} 组 · 本组第 ${currentArrow + 1}/${record.arrowsPerEnd}',
style: TextStyle(fontSize: 12, color: AppTheme.textSecondary),
),
const SizedBox(height: 8),
ClipRRect(
borderRadius: BorderRadius.circular(4),
child: LinearProgressIndicator(
value: total == 0 ? 0 : placed / total,
minHeight: 6,
backgroundColor: AppTheme.primary.withValues(alpha: 0.15),
),
),
],
),
);
}
}
class _EndSummary extends StatelessWidget {
const _EndSummary({required this.record, required this.scores});
final PointRecord record;
final List<int> scores;
@override
Widget build(BuildContext context) {
final fullEnds = scores.length ~/ record.arrowsPerEnd;
final ends = <Widget>[];
for (var e = 0; e < record.endCount; e++) {
final start = e * record.arrowsPerEnd;
final end = start + record.arrowsPerEnd;
final endScores = e < fullEnds
? scores.sublist(start, end)
: (e == fullEnds ? scores.sublist(start) : <int>[]);
final sum = endScores.fold<int>(0, (a, b) => a + b);
ends.add(
_EndChip(
index: e + 1,
scores: endScores,
sum: sum,
isCurrent: e == fullEnds && endScores.length < record.arrowsPerEnd,
),
);
}
return SizedBox(
height: 72,
child: ListView.separated(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 16),
itemCount: ends.length,
separatorBuilder: (_, __) => const SizedBox(width: 8),
itemBuilder: (_, i) => ends[i],
),
);
}
}
class _EndChip extends StatelessWidget {
const _EndChip({
required this.index,
required this.scores,
required this.sum,
required this.isCurrent,
});
final int index;
final List<int> scores;
final int sum;
final bool isCurrent;
@override
Widget build(BuildContext context) {
return Container(
width: 110,
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
decoration: BoxDecoration(
color: isCurrent
? AppTheme.accent.withValues(alpha: 0.12)
: Colors.white,
border: Border.all(color: Colors.black12),
borderRadius: BorderRadius.circular(8),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Text(
'$index组',
style: const TextStyle(
fontSize: 11,
fontWeight: FontWeight.w600,
),
),
const Spacer(),
Text(
'$sum',
style: const TextStyle(
fontSize: 13,
fontWeight: FontWeight.w700,
),
),
],
),
const SizedBox(height: 2),
Expanded(
child: Text(
scores.isEmpty ? '' : scores.map(_scoreLabel).join(' '),
style: const TextStyle(fontSize: 12),
),
),
],
),
);
}
}
String _scoreLabel(int s) {
if (s == 0) return 'M';
return '$s';
}
@@ -0,0 +1,95 @@
import 'package:flutter/material.dart';
import '../../../../data/local/models/models.dart';
/// Paints a standard 10-ring archery target face and optionally overlays
/// recorded [HitPoint]s. Coordinates use the normalized convention where
/// (0, 0) is the center and radius 1.0 is the outer scoring edge.
class TargetFacePainter extends CustomPainter {
TargetFacePainter({
this.hitPoints = const <HitPoint>[],
this.highlightIndex,
this.showAllHits = true,
this.hitColor = const Color(0xFF1A1A1A),
this.ringStrokeWidth = 1.0,
});
final List<HitPoint> hitPoints;
final int? highlightIndex;
final bool showAllHits;
final Color hitColor;
final double ringStrokeWidth;
// Ring colors from center outward: 10,9 gold | 8,7 red | 6,5 blue | 4,3 black | 2,1 white
static const _ringColors = <Color>[
Color(0xFFFFD23F), // 10
Color(0xFFFFD23F), // 9
Color(0xFFE5484D), // 8
Color(0xFFE5484D), // 7
Color(0xFF3B82C4), // 6
Color(0xFF3B82C4), // 5
Color(0xFF2B2B2B), // 4
Color(0xFF2B2B2B), // 3
Color(0xFFF5F5F5), // 2
Color(0xFFF5F5F5), // 1
];
@override
void paint(Canvas canvas, Size size) {
final center = Offset(size.width / 2, size.height / 2);
final radius = size.shortestSide / 2;
// Draw rings from outermost (1) inward to (10).
for (var i = _ringColors.length - 1; i >= 0; i--) {
final ringRadius = radius * (i + 1) / _ringColors.length;
final paint = Paint()..color = _ringColors[i];
canvas.drawCircle(center, ringRadius, paint);
}
// X ring (center dot).
canvas.drawCircle(
center,
radius * 0.05,
Paint()..color = const Color(0xFF8A6D00),
);
// Ring separator lines.
final stroke = Paint()
..color = const Color(0x55000000)
..style = PaintingStyle.stroke
..strokeWidth = ringStrokeWidth;
for (var i = 1; i <= _ringColors.length; i++) {
canvas.drawCircle(center, radius * i / _ringColors.length, stroke);
}
if (!showAllHits) return;
for (var i = 0; i < hitPoints.length; i++) {
final hp = hitPoints[i];
final point = Offset(
center.dx + hp.normalizedX * radius,
center.dy + hp.normalizedY * radius,
);
final isHighlight = i == highlightIndex;
final dotRadius = isHighlight ? radius * 0.045 : radius * 0.03;
canvas.drawCircle(
point,
dotRadius + 2,
Paint()..color = Colors.white,
);
canvas.drawCircle(
point,
dotRadius,
Paint()..color = isHighlight ? const Color(0xFF16A34A) : hitColor,
);
}
}
@override
bool shouldRepaint(covariant TargetFacePainter oldDelegate) {
return oldDelegate.hitPoints.length != hitPoints.length ||
oldDelegate.highlightIndex != highlightIndex ||
oldDelegate.hitColor != hitColor;
}
}