重构记分数据模型,替换弓型和靶纸类型为 ID 及名称,更新相关页面以支持新配置。
This commit is contained in:
@@ -7,25 +7,6 @@ enum PointRecordStatus {
|
||||
completed,
|
||||
}
|
||||
|
||||
enum BowType {
|
||||
recurve,
|
||||
compound,
|
||||
barebow,
|
||||
traditional,
|
||||
longbow,
|
||||
other,
|
||||
}
|
||||
|
||||
enum TargetFaceType {
|
||||
cm40,
|
||||
cm60,
|
||||
cm80,
|
||||
cm122,
|
||||
vegas3Spot,
|
||||
vertical3Spot,
|
||||
custom,
|
||||
}
|
||||
|
||||
@embedded
|
||||
class HitPoint {
|
||||
HitPoint({
|
||||
@@ -67,11 +48,17 @@ class PointRecord {
|
||||
@enumerated
|
||||
PointRecordStatus status = PointRecordStatus.draft;
|
||||
|
||||
@enumerated
|
||||
BowType bowType = BowType.recurve;
|
||||
/// Matches [BowOption.id] from point book config.
|
||||
@Index(type: IndexType.value)
|
||||
int bowOptionId = 1;
|
||||
|
||||
@enumerated
|
||||
TargetFaceType targetFaceType = TargetFaceType.cm40;
|
||||
late String bowName;
|
||||
|
||||
/// Matches [TargetOption.id] from point book config.
|
||||
@Index(type: IndexType.value)
|
||||
int targetOptionId = 1;
|
||||
|
||||
late String targetName;
|
||||
|
||||
late String title;
|
||||
int distanceMeters = 18;
|
||||
@@ -88,8 +75,10 @@ class PointRecord {
|
||||
scores.isEmpty ? null : totalScore / scores.length;
|
||||
|
||||
factory PointRecord.createDraft({
|
||||
required BowType bowType,
|
||||
required TargetFaceType targetFaceType,
|
||||
required int bowOptionId,
|
||||
required String bowName,
|
||||
required int targetOptionId,
|
||||
required String targetName,
|
||||
required int distanceMeters,
|
||||
required int endCount,
|
||||
required int arrowsPerEnd,
|
||||
@@ -101,8 +90,10 @@ class PointRecord {
|
||||
return PointRecord()
|
||||
..title = title ?? '未完成记分'
|
||||
..status = PointRecordStatus.draft
|
||||
..bowType = bowType
|
||||
..targetFaceType = targetFaceType
|
||||
..bowOptionId = bowOptionId
|
||||
..bowName = bowName
|
||||
..targetOptionId = targetOptionId
|
||||
..targetName = targetName
|
||||
..distanceMeters = distanceMeters
|
||||
..endCount = endCount
|
||||
..arrowsPerEnd = arrowsPerEnd
|
||||
@@ -117,8 +108,10 @@ class PointRecord {
|
||||
..id = id
|
||||
..title = title
|
||||
..status = PointRecordStatus.completed
|
||||
..bowType = bowType
|
||||
..targetFaceType = targetFaceType
|
||||
..bowOptionId = bowOptionId
|
||||
..bowName = bowName
|
||||
..targetOptionId = targetOptionId
|
||||
..targetName = targetName
|
||||
..distanceMeters = distanceMeters
|
||||
..endCount = endCount
|
||||
..arrowsPerEnd = arrowsPerEnd
|
||||
@@ -136,8 +129,10 @@ class PointRecord {
|
||||
..id = id
|
||||
..title = title
|
||||
..status = status
|
||||
..bowType = bowType
|
||||
..targetFaceType = targetFaceType
|
||||
..bowOptionId = bowOptionId
|
||||
..bowName = bowName
|
||||
..targetOptionId = targetOptionId
|
||||
..targetName = targetName
|
||||
..distanceMeters = distanceMeters
|
||||
..endCount = endCount
|
||||
..arrowsPerEnd = arrowsPerEnd
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -3,7 +3,9 @@ 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';
|
||||
import 'package:arcx/features/scoring/presentation/point_book_edit_page.dart';
|
||||
|
||||
class PointBookListPage extends ConsumerStatefulWidget {
|
||||
@@ -14,22 +16,31 @@ class PointBookListPage extends ConsumerStatefulWidget {
|
||||
}
|
||||
|
||||
class _PointBookListPageState extends ConsumerState<PointBookListPage> {
|
||||
BowType? _bowFilter;
|
||||
TargetFaceType? _targetFilter;
|
||||
int? _bowFilter;
|
||||
int? _targetFilter;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final recordsAsync = ref.watch(pointRecordsProvider);
|
||||
final configAsync = ref.watch(pointBookConfigProvider);
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('计分记录')),
|
||||
body: Column(
|
||||
children: [
|
||||
_FilterBar(
|
||||
bowFilter: _bowFilter,
|
||||
targetFilter: _targetFilter,
|
||||
onBowChanged: (v) => setState(() => _bowFilter = v),
|
||||
onTargetChanged: (v) => setState(() => _targetFilter = v),
|
||||
configAsync.when(
|
||||
loading: () => const SizedBox(height: 64),
|
||||
error: (e, _) => Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Text('配置加载失败:$e'),
|
||||
),
|
||||
data: (config) => _FilterBar(
|
||||
config: config,
|
||||
bowFilter: _bowFilter,
|
||||
targetFilter: _targetFilter,
|
||||
onBowChanged: (v) => setState(() => _bowFilter = v),
|
||||
onTargetChanged: (v) => setState(() => _targetFilter = v),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: recordsAsync.when(
|
||||
@@ -64,10 +75,10 @@ class _PointBookListPageState extends ConsumerState<PointBookListPage> {
|
||||
List<PointRecord> _applyFilters(List<PointRecord> records) {
|
||||
var result = records;
|
||||
if (_bowFilter != null) {
|
||||
result = result.where((r) => r.bowType == _bowFilter).toList();
|
||||
result = result.where((r) => r.bowOptionId == _bowFilter).toList();
|
||||
}
|
||||
if (_targetFilter != null) {
|
||||
result = result.where((r) => r.targetFaceType == _targetFilter).toList();
|
||||
result = result.where((r) => r.targetOptionId == _targetFilter).toList();
|
||||
}
|
||||
return result;
|
||||
}
|
||||
@@ -107,16 +118,18 @@ class _PointBookListPageState extends ConsumerState<PointBookListPage> {
|
||||
|
||||
class _FilterBar extends StatelessWidget {
|
||||
const _FilterBar({
|
||||
required this.config,
|
||||
required this.bowFilter,
|
||||
required this.targetFilter,
|
||||
required this.onBowChanged,
|
||||
required this.onTargetChanged,
|
||||
});
|
||||
|
||||
final BowType? bowFilter;
|
||||
final TargetFaceType? targetFilter;
|
||||
final ValueChanged<BowType?> onBowChanged;
|
||||
final ValueChanged<TargetFaceType?> onTargetChanged;
|
||||
final PointBookConfig config;
|
||||
final int? bowFilter;
|
||||
final int? targetFilter;
|
||||
final ValueChanged<int?> onBowChanged;
|
||||
final ValueChanged<int?> onTargetChanged;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -125,21 +138,23 @@ class _FilterBar extends StatelessWidget {
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: _Dropdown<BowType>(
|
||||
child: _Dropdown<int>(
|
||||
label: '弓型',
|
||||
value: bowFilter,
|
||||
items: BowType.values,
|
||||
labelOf: _bowLabel,
|
||||
items: config.bowOption.map((e) => e.id).toList(),
|
||||
labelOf: (id) =>
|
||||
config.bowById(id)?.name ?? id.toString(),
|
||||
onChanged: onBowChanged,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: _Dropdown<TargetFaceType>(
|
||||
child: _Dropdown<int>(
|
||||
label: '靶纸',
|
||||
value: targetFilter,
|
||||
items: TargetFaceType.values,
|
||||
labelOf: _targetLabel,
|
||||
items: config.targetOption.map((e) => e.id).toList(),
|
||||
labelOf: (id) =>
|
||||
config.targetById(id)?.name ?? id.toString(),
|
||||
onChanged: onTargetChanged,
|
||||
),
|
||||
),
|
||||
@@ -177,10 +192,12 @@ class _Dropdown<T> extends StatelessWidget {
|
||||
),
|
||||
items: [
|
||||
DropdownMenuItem<T>(value: null, child: Text('全部')),
|
||||
...items.map((e) => DropdownMenuItem<T>(
|
||||
value: e,
|
||||
child: Text(labelOf(e)),
|
||||
)),
|
||||
...items.map(
|
||||
(e) => DropdownMenuItem<T>(
|
||||
value: e,
|
||||
child: Text(labelOf(e), overflow: TextOverflow.ellipsis),
|
||||
),
|
||||
),
|
||||
],
|
||||
onChanged: onChanged,
|
||||
);
|
||||
@@ -232,7 +249,7 @@ class _RecordTile extends StatelessWidget {
|
||||
],
|
||||
),
|
||||
subtitle: Text(
|
||||
'${_bowLabel(record.bowType)} · ${record.distanceMeters}m · ${_targetLabel(record.targetFaceType)}\n'
|
||||
'${record.bowName} · ${record.distanceMeters}m · ${record.targetName}\n'
|
||||
'${_formatDate(record.createdAt)} ${record.totalArrows}支 ${record.averageScore != null ? '均${record.averageScore!.toStringAsFixed(1)}环' : ''}',
|
||||
style: TextStyle(fontSize: 12, color: AppTheme.textSecondary),
|
||||
),
|
||||
@@ -272,42 +289,6 @@ class _EmptyState 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 '自定义';
|
||||
}
|
||||
}
|
||||
|
||||
String _formatDate(DateTime dt) {
|
||||
return '${dt.year}-${dt.month.toString().padLeft(2, '0')}-${dt.day.toString().padLeft(2, '0')} '
|
||||
'${dt.hour.toString().padLeft(2, '0')}:${dt.minute.toString().padLeft(2, '0')}';
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../domain/point_book_config.dart';
|
||||
|
||||
final pointBookConfigProvider = FutureProvider<PointBookConfig>((ref) {
|
||||
return PointBookConfig.fromAsset();
|
||||
});
|
||||
@@ -40,8 +40,10 @@ class PointRecordsNotifier extends AsyncNotifier<List<PointRecord>> {
|
||||
|
||||
/// Creates a new draft from scoring parameters and returns it.
|
||||
Future<PointRecord> createDraft({
|
||||
required BowType bowType,
|
||||
required TargetFaceType targetFaceType,
|
||||
required int bowOptionId,
|
||||
required String bowName,
|
||||
required int targetOptionId,
|
||||
required String targetName,
|
||||
required int distanceMeters,
|
||||
required int endCount,
|
||||
required int arrowsPerEnd,
|
||||
@@ -49,8 +51,10 @@ class PointRecordsNotifier extends AsyncNotifier<List<PointRecord>> {
|
||||
}) async {
|
||||
final isar = await _isar();
|
||||
final draft = PointRecord.createDraft(
|
||||
bowType: bowType,
|
||||
targetFaceType: targetFaceType,
|
||||
bowOptionId: bowOptionId,
|
||||
bowName: bowName,
|
||||
targetOptionId: targetOptionId,
|
||||
targetName: targetName,
|
||||
distanceMeters: distanceMeters,
|
||||
endCount: endCount,
|
||||
arrowsPerEnd: arrowsPerEnd,
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
class BowOption {
|
||||
const BowOption({
|
||||
required this.id,
|
||||
required this.name,
|
||||
required this.icon,
|
||||
});
|
||||
|
||||
final int id;
|
||||
final String name;
|
||||
final String icon;
|
||||
|
||||
factory BowOption.fromJson(Map<String, dynamic> json) {
|
||||
return BowOption(
|
||||
id: json['id'] as int,
|
||||
name: json['name'] as String,
|
||||
icon: json['icon'] as String? ?? '',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class TargetOption {
|
||||
const TargetOption({
|
||||
required this.id,
|
||||
required this.name,
|
||||
required this.icon,
|
||||
required this.iconPng,
|
||||
});
|
||||
|
||||
final int id;
|
||||
final String name;
|
||||
final String icon;
|
||||
final String iconPng;
|
||||
|
||||
factory TargetOption.fromJson(Map<String, dynamic> json) {
|
||||
return TargetOption(
|
||||
id: json['id'] as int,
|
||||
name: json['name'] as String,
|
||||
icon: json['icon'] as String? ?? '',
|
||||
iconPng: json['iconPng'] as String? ?? '',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class PointBookConfig {
|
||||
const PointBookConfig({
|
||||
required this.bowOption,
|
||||
required this.targetOption,
|
||||
});
|
||||
|
||||
static const assetPath = 'assets/config/point_book_config.json';
|
||||
|
||||
final List<BowOption> bowOption;
|
||||
final List<TargetOption> targetOption;
|
||||
|
||||
factory PointBookConfig.fromJson(Map<String, dynamic> json) {
|
||||
final bows = (json['bowOption'] as List<dynamic>? ?? const [])
|
||||
.cast<Map<String, dynamic>>()
|
||||
.map(BowOption.fromJson)
|
||||
.toList();
|
||||
final targets = (json['targetOption'] as List<dynamic>? ?? const [])
|
||||
.cast<Map<String, dynamic>>()
|
||||
.map(TargetOption.fromJson)
|
||||
.toList();
|
||||
return PointBookConfig(bowOption: bows, targetOption: targets);
|
||||
}
|
||||
|
||||
static Future<PointBookConfig> fromAsset({
|
||||
String path = assetPath,
|
||||
}) async {
|
||||
final raw = await rootBundle.loadString(path);
|
||||
return PointBookConfig.fromJson(
|
||||
jsonDecode(raw) as Map<String, dynamic>,
|
||||
);
|
||||
}
|
||||
|
||||
BowOption? bowById(int id) {
|
||||
for (final o in bowOption) {
|
||||
if (o.id == id) return o;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
TargetOption? targetById(int id) {
|
||||
for (final o in targetOption) {
|
||||
if (o.id == id) return o;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -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('请输入 1–300 的距离'),
|
||||
),
|
||||
],
|
||||
),
|
||||
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('请输入 1–36 的组数'),
|
||||
),
|
||||
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('请输入 1–36 的箭数'),
|
||||
),
|
||||
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 '自定义';
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user