init
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user