重构记分数据模型,替换弓型和靶纸类型为 ID 及名称,更新相关页面以支持新配置。

This commit is contained in:
2026-08-09 19:09:21 +08:00
parent e3d6fac6e1
commit 15700f918b
9 changed files with 1236 additions and 413 deletions
@@ -1,9 +1,11 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.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/point_book_config_provider.dart';
import 'package:arcx/features/scoring/application/scoring_service.dart';
import 'package:arcx/features/scoring/domain/point_book_config.dart';
class PointBookCreatePage extends ConsumerStatefulWidget {
const PointBookCreatePage({super.key});
@@ -14,95 +16,187 @@ class PointBookCreatePage extends ConsumerStatefulWidget {
}
class _PointBookCreatePageState extends ConsumerState<PointBookCreatePage> {
BowType _bowType = BowType.recurve;
TargetFaceType _targetFace = TargetFaceType.cm40;
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];
int? _bowOptionId;
int? _targetOptionId;
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];
final _distanceCustom = TextEditingController();
final _endCustom = TextEditingController();
final _arrowCustom = TextEditingController();
@override
void dispose() {
_distanceCustom.dispose();
_endCustom.dispose();
_arrowCustom.dispose();
super.dispose();
}
@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),
),
final configAsync = ref.watch(pointBookConfigProvider);
return GestureDetector(
onTap: _unfocus,
behavior: HitTestBehavior.translucent,
child: Scaffold(
appBar: AppBar(title: const Text('选择参数')),
body: configAsync.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (e, _) => Center(child: Text('配置加载失败:$e')),
data: (config) {
_ensureDefaults(config);
final bow = config.bowById(_bowOptionId!);
final target = config.targetById(_targetOptionId!);
if (bow == null || target == null) {
return const Center(child: Text('配置无效'));
}
return ListView(
padding: const EdgeInsets.all(16),
keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag,
children: [
const _SectionTitle(''),
_ChipGroup<int>(
options: config.bowOption.map((e) => e.id).toList(),
value: _bowOptionId!,
labelOf: (id) => config.bowById(id)?.name ?? '$id',
onChanged: (v) {
_unfocus();
setState(() => _bowOptionId = v);
},
),
const SizedBox(width: 12),
Expanded(
child: _NumberPicker(
label: '每组箭数',
value: _arrowsPerEnd,
options: _arrowOptions,
onChanged: (v) => setState(() => _arrowsPerEnd = v),
),
const SizedBox(height: 16),
const _SectionTitle('距离'),
_PresetWithCustom(
options: _distances,
value: _distance,
labelOf: (v) => '${v}m',
customController: _distanceCustom,
customHint: '自定义(m)',
min: 1,
max: 300,
onPreset: (v) {
_unfocus();
setState(() {
_distance = v;
_distanceCustom.clear();
});
},
onCustom: (v) => setState(() => _distance = v),
onInvalid: () => _toast('请输入 1300 的距离'),
),
],
),
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('开始记分'),
),
],
const SizedBox(height: 16),
const _SectionTitle('靶纸类型'),
_ChipGroup<int>(
options: config.targetOption.map((e) => e.id).toList(),
value: _targetOptionId!,
labelOf: (id) => config.targetById(id)?.name ?? '$id',
onChanged: (v) {
_unfocus();
setState(() => _targetOptionId = v);
},
),
const SizedBox(height: 16),
const _SectionTitle('组数'),
_PresetWithCustom(
options: _endOptions,
value: _endCount,
labelOf: (v) => '$v',
customController: _endCustom,
customHint: '自定义',
min: 1,
max: 36,
onPreset: (v) {
_unfocus();
setState(() {
_endCount = v;
_endCustom.clear();
});
},
onCustom: (v) => setState(() => _endCount = v),
onInvalid: () => _toast('请输入 136 的组数'),
),
const SizedBox(height: 16),
const _SectionTitle('每组箭数'),
_PresetWithCustom(
options: _arrowOptions,
value: _arrowsPerEnd,
labelOf: (v) => '$v',
customController: _arrowCustom,
customHint: '自定义',
min: 1,
max: 36,
onPreset: (v) {
_unfocus();
setState(() {
_arrowsPerEnd = v;
_arrowCustom.clear();
});
},
onCustom: (v) => setState(() => _arrowsPerEnd = v),
onInvalid: () => _toast('请输入 136 的箭数'),
),
const SizedBox(height: 32),
_Summary(
bowName: bow.name,
distance: _distance,
targetName: target.name,
endCount: _endCount,
arrowsPerEnd: _arrowsPerEnd,
),
const SizedBox(height: 16),
FilledButton.icon(
onPressed: _creating
? null
: () {
_unfocus();
_start(bow: bow, target: target);
},
icon: const Icon(Icons.play_arrow),
label: const Text('下一步'),
),
],
);
},
),
),
);
}
Future<void> _start() async {
void _unfocus() {
FocusManager.instance.primaryFocus?.unfocus();
}
void _ensureDefaults(PointBookConfig config) {
_bowOptionId ??= config.bowOption.isNotEmpty
? config.bowOption.first.id
: 1;
_targetOptionId ??= config.targetOption.isNotEmpty
? config.targetOption.first.id
: 1;
}
void _toast(String msg) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(msg)));
}
Future<void> _start({
required BowOption bow,
required TargetOption target,
}) async {
setState(() => _creating = true);
final draft = await ref
.read(pointRecordsProvider.notifier)
.createDraft(
bowType: _bowType,
targetFaceType: _targetFace,
final draft = await ref.read(pointRecordsProvider.notifier).createDraft(
bowOptionId: bow.id,
bowName: bow.name,
targetOptionId: target.id,
targetName: target.name,
distanceMeters: _distance,
endCount: _endCount,
arrowsPerEnd: _arrowsPerEnd,
@@ -164,84 +258,129 @@ class _ChipGroup<T> extends StatelessWidget {
}
}
class _NumberPicker extends StatelessWidget {
const _NumberPicker({
required this.label,
required this.value,
/// Preset chips plus a trailing custom number field.
class _PresetWithCustom extends StatelessWidget {
const _PresetWithCustom({
required this.options,
required this.onChanged,
required this.value,
required this.labelOf,
required this.customController,
required this.customHint,
required this.min,
required this.max,
required this.onPreset,
required this.onCustom,
required this.onInvalid,
});
final String label;
final int value;
final List<int> options;
final ValueChanged<int> onChanged;
final int value;
final String Function(int) labelOf;
final TextEditingController customController;
final String customHint;
final int min;
final int max;
final ValueChanged<int> onPreset;
final ValueChanged<int> onCustom;
final VoidCallback onInvalid;
bool get _isPreset => options.contains(value);
void _applyCustom(String raw) {
final n = int.tryParse(raw.trim());
if (n == null || n < min || n > max) {
onInvalid();
return;
}
onCustom(n);
}
@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: [
Flexible(child: Text(label, style: const TextStyle(fontSize: 13))),
Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
icon: const Icon(Icons.remove_circle_outline),
iconSize: 20,
visualDensity: VisualDensity.compact,
padding: EdgeInsets.zero,
constraints: const BoxConstraints(),
onPressed: () {
final i = options.indexOf(value);
if (i > 0) onChanged(options[i - 1]);
},
return Wrap(
spacing: 8,
runSpacing: 8,
crossAxisAlignment: WrapCrossAlignment.center,
children: [
...options.map((o) {
final selected = _isPreset && o == value;
return ChoiceChip(
label: Text(labelOf(o)),
selected: selected,
onSelected: (_) => onPreset(o),
selectedColor: AppTheme.primary,
labelStyle: TextStyle(
color: selected ? Colors.white : Colors.black87,
),
);
}),
SizedBox(
width: 96,
height: 40,
child: TextField(
controller: customController,
keyboardType: TextInputType.number,
inputFormatters: [FilteringTextInputFormatter.digitsOnly],
textAlign: TextAlign.center,
style: const TextStyle(fontSize: 14),
decoration: InputDecoration(
isDense: true,
hintText: customHint,
contentPadding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 10,
),
Text(
'$value',
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.w700,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(20),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(20),
borderSide: BorderSide(
color: !_isPreset ? AppTheme.primary : Colors.black26,
width: !_isPreset ? 1.5 : 1,
),
),
IconButton(
icon: const Icon(Icons.add_circle_outline),
iconSize: 20,
visualDensity: VisualDensity.compact,
padding: EdgeInsets.zero,
constraints: const BoxConstraints(),
onPressed: () {
final i = options.indexOf(value);
if (i < options.length - 1) onChanged(options[i + 1]);
},
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(20),
borderSide: const BorderSide(
color: AppTheme.primary,
width: 1.5,
),
),
],
),
onTapOutside: (_) {
FocusManager.instance.primaryFocus?.unfocus();
final raw = customController.text.trim();
if (raw.isNotEmpty) _applyCustom(raw);
},
onChanged: (raw) {
if (raw.trim().isEmpty) return;
final n = int.tryParse(raw.trim());
if (n != null && n >= min && n <= max) {
onCustom(n);
}
},
onSubmitted: _applyCustom,
onEditingComplete: () => _applyCustom(customController.text),
),
],
),
),
],
);
}
}
class _Summary extends StatelessWidget {
const _Summary({
required this.bowType,
required this.bowName,
required this.distance,
required this.targetFace,
required this.targetName,
required this.endCount,
required this.arrowsPerEnd,
});
final BowType bowType;
final String bowName;
final int distance;
final TargetFaceType targetFace;
final String targetName;
final int endCount;
final int arrowsPerEnd;
@@ -258,7 +397,7 @@ class _Summary extends StatelessWidget {
children: [
Expanded(
child: Text(
'${_bowLabel(bowType)} · ${distance}m · ${_targetLabel(targetFace)}',
'$bowName · ${distance}m · $targetName',
style: const TextStyle(fontSize: 13),
),
),
@@ -275,39 +414,3 @@ class _Summary extends StatelessWidget {
);
}
}
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 '自定义';
}
}