9 changed files with 1236 additions and 413 deletions
+91
View File
@@ -0,0 +1,91 @@
{
"bowOption": [
{
"id": 1,
"name": "反曲弓",
"icon": "https://static.shelingxingqiu.com/attachment/2025-08-04/dbt8n02c0mwlgpcihn.png"
},
{
"id": 2,
"name": "复合弓",
"icon": "https://static.shelingxingqiu.com/attachment/2025-08-04/dbt8n02d17qt8548j4.png"
},
{
"id": 3,
"name": "美洲猎弓",
"icon": "https://static.shelingxingqiu.com/attachment/2025-08-04/dbt8n02cv1b8ucjp4y.png"
},
{
"id": 4,
"name": "传统弓",
"icon": "https://static.shelingxingqiu.com/attachment/2025-08-04/dbt8n02cohc2omofsb.png"
},
{
"id": 5,
"name": "光弓",
"icon": "https://static.shelingxingqiu.com/attachment/2025-08-04/dbt8n01ru48k0bwwz8.png"
}
],
"targetOption": [
{
"id": 1,
"name": "40 全环靶",
"icon": "https://static.shelingxingqiu.com/target20251126/%E5%85%A8%E7%8E%AF%E9%9D%B6@3x.svg",
"iconPng": "https://static.shelingxingqiu.com/shootTarget/shoot@2x.png"
},
{
"id": 2,
"name": "80 全环靶",
"icon": "https://static.shelingxingqiu.com/target20251126/%E5%85%A8%E7%8E%AF%E9%9D%B6@3x.svg",
"iconPng": "https://static.shelingxingqiu.com/shootTarget/shoot@2x.png"
},
{
"id": 3,
"name": "122 全环靶",
"icon": "https://static.shelingxingqiu.com/target20251126/%E5%85%A8%E7%8E%AF%E9%9D%B6@3x.svg",
"iconPng": "https://static.shelingxingqiu.com/shootTarget/shoot@2x.png"
},
{
"id": 4,
"name": "40 半环靶",
"icon": "https://static.shelingxingqiu.com/target20251126/%E5%8D%8A%E7%8E%AF%E9%9D%B6.svg",
"iconPng": "https://static.shelingxingqiu.com/shootTarget/shoot@2x(1).png"
},
{
"id": 5,
"name": "60 半环靶",
"icon": "https://static.shelingxingqiu.com/target20251126/%E5%8D%8A%E7%8E%AF%E9%9D%B6.svg",
"iconPng": "https://static.shelingxingqiu.com/shootTarget/shoot@2x(1).png"
},
{
"id": 6,
"name": "80 半环靶",
"icon": "https://static.shelingxingqiu.com/target20251126/%E5%8D%8A%E7%8E%AF%E9%9D%B6.svg",
"iconPng": "https://static.shelingxingqiu.com/shootTarget/shoot@2x(1).png"
},
{
"id": 7,
"name": "三连靶",
"icon": "https://static.shelingxingqiu.com/20260310target/%E4%B8%89%E8%BF%9E.svg",
"iconPng": "https://static.shelingxingqiu.com/shootTarget/shoot@2x(2).png"
},
{
"id": 8,
"name": "品字靶",
"icon": "https://static.shelingxingqiu.com/target20251126/%E5%93%81%E5%AD%97%E9%9D%B6.svg",
"iconPng": "https://static.shelingxingqiu.com/shootTarget/shoot@2x(3).png"
},
{
"id": 9,
"name": "复合 三连靶",
"icon": "https://static.shelingxingqiu.com/20260310target/%E5%A4%8D%E5%90%88%E4%B8%89%E8%BF%9E.svg",
"iconPng": "https://static.shelingxingqiu.com/shootTarget/shoot@2x(5).png"
},
{
"id": 10,
"name": "复合 品字靶",
"icon": "https://static.shelingxingqiu.com/target20251126/%E5%A4%8D%E5%90%88%E5%93%81%E5%AD%97%E9%9D%B6.svg",
"iconPng": "https://static.shelingxingqiu.com/shootTarget/shoot@2x(4).png"
}
]
}
+26 -31
View File
@@ -7,25 +7,6 @@ enum PointRecordStatus {
completed, completed,
} }
enum BowType {
recurve,
compound,
barebow,
traditional,
longbow,
other,
}
enum TargetFaceType {
cm40,
cm60,
cm80,
cm122,
vegas3Spot,
vertical3Spot,
custom,
}
@embedded @embedded
class HitPoint { class HitPoint {
HitPoint({ HitPoint({
@@ -67,11 +48,17 @@ class PointRecord {
@enumerated @enumerated
PointRecordStatus status = PointRecordStatus.draft; PointRecordStatus status = PointRecordStatus.draft;
@enumerated /// Matches [BowOption.id] from point book config.
BowType bowType = BowType.recurve; @Index(type: IndexType.value)
int bowOptionId = 1;
@enumerated late String bowName;
TargetFaceType targetFaceType = TargetFaceType.cm40;
/// Matches [TargetOption.id] from point book config.
@Index(type: IndexType.value)
int targetOptionId = 1;
late String targetName;
late String title; late String title;
int distanceMeters = 18; int distanceMeters = 18;
@@ -88,8 +75,10 @@ class PointRecord {
scores.isEmpty ? null : totalScore / scores.length; scores.isEmpty ? null : totalScore / scores.length;
factory PointRecord.createDraft({ factory PointRecord.createDraft({
required BowType bowType, required int bowOptionId,
required TargetFaceType targetFaceType, required String bowName,
required int targetOptionId,
required String targetName,
required int distanceMeters, required int distanceMeters,
required int endCount, required int endCount,
required int arrowsPerEnd, required int arrowsPerEnd,
@@ -101,8 +90,10 @@ class PointRecord {
return PointRecord() return PointRecord()
..title = title ?? '未完成记分' ..title = title ?? '未完成记分'
..status = PointRecordStatus.draft ..status = PointRecordStatus.draft
..bowType = bowType ..bowOptionId = bowOptionId
..targetFaceType = targetFaceType ..bowName = bowName
..targetOptionId = targetOptionId
..targetName = targetName
..distanceMeters = distanceMeters ..distanceMeters = distanceMeters
..endCount = endCount ..endCount = endCount
..arrowsPerEnd = arrowsPerEnd ..arrowsPerEnd = arrowsPerEnd
@@ -117,8 +108,10 @@ class PointRecord {
..id = id ..id = id
..title = title ..title = title
..status = PointRecordStatus.completed ..status = PointRecordStatus.completed
..bowType = bowType ..bowOptionId = bowOptionId
..targetFaceType = targetFaceType ..bowName = bowName
..targetOptionId = targetOptionId
..targetName = targetName
..distanceMeters = distanceMeters ..distanceMeters = distanceMeters
..endCount = endCount ..endCount = endCount
..arrowsPerEnd = arrowsPerEnd ..arrowsPerEnd = arrowsPerEnd
@@ -136,8 +129,10 @@ class PointRecord {
..id = id ..id = id
..title = title ..title = title
..status = status ..status = status
..bowType = bowType ..bowOptionId = bowOptionId
..targetFaceType = targetFaceType ..bowName = bowName
..targetOptionId = targetOptionId
..targetName = targetName
..distanceMeters = distanceMeters ..distanceMeters = distanceMeters
..endCount = endCount ..endCount = endCount
..arrowsPerEnd = arrowsPerEnd ..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/app/app_theme.dart';
import 'package:arcx/data/local/models/models.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/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'; import 'package:arcx/features/scoring/presentation/point_book_edit_page.dart';
class PointBookListPage extends ConsumerStatefulWidget { class PointBookListPage extends ConsumerStatefulWidget {
@@ -14,23 +16,32 @@ class PointBookListPage extends ConsumerStatefulWidget {
} }
class _PointBookListPageState extends ConsumerState<PointBookListPage> { class _PointBookListPageState extends ConsumerState<PointBookListPage> {
BowType? _bowFilter; int? _bowFilter;
TargetFaceType? _targetFilter; int? _targetFilter;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final recordsAsync = ref.watch(pointRecordsProvider); final recordsAsync = ref.watch(pointRecordsProvider);
final configAsync = ref.watch(pointBookConfigProvider);
return Scaffold( return Scaffold(
appBar: AppBar(title: const Text('计分记录')), appBar: AppBar(title: const Text('计分记录')),
body: Column( body: Column(
children: [ children: [
_FilterBar( 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, bowFilter: _bowFilter,
targetFilter: _targetFilter, targetFilter: _targetFilter,
onBowChanged: (v) => setState(() => _bowFilter = v), onBowChanged: (v) => setState(() => _bowFilter = v),
onTargetChanged: (v) => setState(() => _targetFilter = v), onTargetChanged: (v) => setState(() => _targetFilter = v),
), ),
),
Expanded( Expanded(
child: recordsAsync.when( child: recordsAsync.when(
loading: () => const Center(child: CircularProgressIndicator()), loading: () => const Center(child: CircularProgressIndicator()),
@@ -64,10 +75,10 @@ class _PointBookListPageState extends ConsumerState<PointBookListPage> {
List<PointRecord> _applyFilters(List<PointRecord> records) { List<PointRecord> _applyFilters(List<PointRecord> records) {
var result = records; var result = records;
if (_bowFilter != null) { if (_bowFilter != null) {
result = result.where((r) => r.bowType == _bowFilter).toList(); result = result.where((r) => r.bowOptionId == _bowFilter).toList();
} }
if (_targetFilter != null) { if (_targetFilter != null) {
result = result.where((r) => r.targetFaceType == _targetFilter).toList(); result = result.where((r) => r.targetOptionId == _targetFilter).toList();
} }
return result; return result;
} }
@@ -107,16 +118,18 @@ class _PointBookListPageState extends ConsumerState<PointBookListPage> {
class _FilterBar extends StatelessWidget { class _FilterBar extends StatelessWidget {
const _FilterBar({ const _FilterBar({
required this.config,
required this.bowFilter, required this.bowFilter,
required this.targetFilter, required this.targetFilter,
required this.onBowChanged, required this.onBowChanged,
required this.onTargetChanged, required this.onTargetChanged,
}); });
final BowType? bowFilter; final PointBookConfig config;
final TargetFaceType? targetFilter; final int? bowFilter;
final ValueChanged<BowType?> onBowChanged; final int? targetFilter;
final ValueChanged<TargetFaceType?> onTargetChanged; final ValueChanged<int?> onBowChanged;
final ValueChanged<int?> onTargetChanged;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@@ -125,21 +138,23 @@ class _FilterBar extends StatelessWidget {
child: Row( child: Row(
children: [ children: [
Expanded( Expanded(
child: _Dropdown<BowType>( child: _Dropdown<int>(
label: '弓型', label: '弓型',
value: bowFilter, value: bowFilter,
items: BowType.values, items: config.bowOption.map((e) => e.id).toList(),
labelOf: _bowLabel, labelOf: (id) =>
config.bowById(id)?.name ?? id.toString(),
onChanged: onBowChanged, onChanged: onBowChanged,
), ),
), ),
const SizedBox(width: 12), const SizedBox(width: 12),
Expanded( Expanded(
child: _Dropdown<TargetFaceType>( child: _Dropdown<int>(
label: '靶纸', label: '靶纸',
value: targetFilter, value: targetFilter,
items: TargetFaceType.values, items: config.targetOption.map((e) => e.id).toList(),
labelOf: _targetLabel, labelOf: (id) =>
config.targetById(id)?.name ?? id.toString(),
onChanged: onTargetChanged, onChanged: onTargetChanged,
), ),
), ),
@@ -177,10 +192,12 @@ class _Dropdown<T> extends StatelessWidget {
), ),
items: [ items: [
DropdownMenuItem<T>(value: null, child: Text('全部')), DropdownMenuItem<T>(value: null, child: Text('全部')),
...items.map((e) => DropdownMenuItem<T>( ...items.map(
(e) => DropdownMenuItem<T>(
value: e, value: e,
child: Text(labelOf(e)), child: Text(labelOf(e), overflow: TextOverflow.ellipsis),
)), ),
),
], ],
onChanged: onChanged, onChanged: onChanged,
); );
@@ -232,7 +249,7 @@ class _RecordTile extends StatelessWidget {
], ],
), ),
subtitle: Text( 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)}' : ''}', '${_formatDate(record.createdAt)} ${record.totalArrows}${record.averageScore != null ? '${record.averageScore!.toStringAsFixed(1)}' : ''}',
style: TextStyle(fontSize: 12, color: AppTheme.textSecondary), 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) { String _formatDate(DateTime dt) {
return '${dt.year}-${dt.month.toString().padLeft(2, '0')}-${dt.day.toString().padLeft(2, '0')} ' 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')}'; '${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. /// Creates a new draft from scoring parameters and returns it.
Future<PointRecord> createDraft({ Future<PointRecord> createDraft({
required BowType bowType, required int bowOptionId,
required TargetFaceType targetFaceType, required String bowName,
required int targetOptionId,
required String targetName,
required int distanceMeters, required int distanceMeters,
required int endCount, required int endCount,
required int arrowsPerEnd, required int arrowsPerEnd,
@@ -49,8 +51,10 @@ class PointRecordsNotifier extends AsyncNotifier<List<PointRecord>> {
}) async { }) async {
final isar = await _isar(); final isar = await _isar();
final draft = PointRecord.createDraft( final draft = PointRecord.createDraft(
bowType: bowType, bowOptionId: bowOptionId,
targetFaceType: targetFaceType, bowName: bowName,
targetOptionId: targetOptionId,
targetName: targetName,
distanceMeters: distanceMeters, distanceMeters: distanceMeters,
endCount: endCount, endCount: endCount,
arrowsPerEnd: arrowsPerEnd, 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/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:arcx/app/app_theme.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/application/scoring_service.dart';
import 'package:arcx/features/scoring/domain/point_book_config.dart';
class PointBookCreatePage extends ConsumerStatefulWidget { class PointBookCreatePage extends ConsumerStatefulWidget {
const PointBookCreatePage({super.key}); const PointBookCreatePage({super.key});
@@ -14,95 +16,187 @@ class PointBookCreatePage extends ConsumerStatefulWidget {
} }
class _PointBookCreatePageState extends ConsumerState<PointBookCreatePage> { class _PointBookCreatePageState extends ConsumerState<PointBookCreatePage> {
BowType _bowType = BowType.recurve; static const _distances = [10, 18, 25, 30, 40, 50, 60, 70, 90];
TargetFaceType _targetFace = TargetFaceType.cm40; 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 _distance = 18;
int _endCount = 6; int _endCount = 6;
int _arrowsPerEnd = 6; int _arrowsPerEnd = 6;
bool _creating = false; bool _creating = false;
static const _distances = [10, 18, 25, 30, 40, 50, 60, 70, 90]; final _distanceCustom = TextEditingController();
static const _endOptions = [3, 4, 5, 6, 8, 10, 12]; final _endCustom = TextEditingController();
static const _arrowOptions = [3, 4, 5, 6, 12]; final _arrowCustom = TextEditingController();
@override
void dispose() {
_distanceCustom.dispose();
_endCustom.dispose();
_arrowCustom.dispose();
super.dispose();
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( final configAsync = ref.watch(pointBookConfigProvider);
appBar: AppBar(title: const Text('新建记分')),
body: ListView( 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), padding: const EdgeInsets.all(16),
keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag,
children: [ children: [
_SectionTitle('弓型'), const _SectionTitle('弓型'),
_ChipGroup( _ChipGroup<int>(
options: BowType.values, options: config.bowOption.map((e) => e.id).toList(),
value: _bowType, value: _bowOptionId!,
labelOf: _bowLabel, labelOf: (id) => config.bowById(id)?.name ?? '$id',
onChanged: (v) => setState(() => _bowType = v), onChanged: (v) {
_unfocus();
setState(() => _bowOptionId = v);
},
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
_SectionTitle('距离'), const _SectionTitle('距离'),
_ChipGroup<int>( _PresetWithCustom(
options: _distances, options: _distances,
value: _distance, value: _distance,
labelOf: (v) => '${v}m', labelOf: (v) => '${v}m',
onChanged: (v) => setState(() => _distance = v), 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: 16), const SizedBox(height: 16),
_SectionTitle('靶纸类型'), const _SectionTitle('靶纸类型'),
_ChipGroup( _ChipGroup<int>(
options: TargetFaceType.values, options: config.targetOption.map((e) => e.id).toList(),
value: _targetFace, value: _targetOptionId!,
labelOf: _targetLabel, labelOf: (id) => config.targetById(id)?.name ?? '$id',
onChanged: (v) => setState(() => _targetFace = v), onChanged: (v) {
_unfocus();
setState(() => _targetOptionId = v);
},
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
Row( const _SectionTitle('组数'),
children: [ _PresetWithCustom(
Expanded(
child: _NumberPicker(
label: '组数',
value: _endCount,
options: _endOptions, options: _endOptions,
onChanged: (v) => setState(() => _endCount = v), 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 SizedBox(width: 12), const _SectionTitle('每组箭数'),
Expanded( _PresetWithCustom(
child: _NumberPicker(
label: '每组箭数',
value: _arrowsPerEnd,
options: _arrowOptions, options: _arrowOptions,
onChanged: (v) => setState(() => _arrowsPerEnd = v), 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), const SizedBox(height: 32),
_Summary( _Summary(
bowType: _bowType, bowName: bow.name,
distance: _distance, distance: _distance,
targetFace: _targetFace, targetName: target.name,
endCount: _endCount, endCount: _endCount,
arrowsPerEnd: _arrowsPerEnd, arrowsPerEnd: _arrowsPerEnd,
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
FilledButton.icon( FilledButton.icon(
onPressed: _creating ? null : _start, onPressed: _creating
? null
: () {
_unfocus();
_start(bow: bow, target: target);
},
icon: const Icon(Icons.play_arrow), icon: const Icon(Icons.play_arrow),
label: const Text('开始记分'), 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); setState(() => _creating = true);
final draft = await ref final draft = await ref.read(pointRecordsProvider.notifier).createDraft(
.read(pointRecordsProvider.notifier) bowOptionId: bow.id,
.createDraft( bowName: bow.name,
bowType: _bowType, targetOptionId: target.id,
targetFaceType: _targetFace, targetName: target.name,
distanceMeters: _distance, distanceMeters: _distance,
endCount: _endCount, endCount: _endCount,
arrowsPerEnd: _arrowsPerEnd, arrowsPerEnd: _arrowsPerEnd,
@@ -164,84 +258,129 @@ class _ChipGroup<T> extends StatelessWidget {
} }
} }
class _NumberPicker extends StatelessWidget { /// Preset chips plus a trailing custom number field.
const _NumberPicker({ class _PresetWithCustom extends StatelessWidget {
required this.label, const _PresetWithCustom({
required this.value,
required this.options, 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 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Container( return Wrap(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), spacing: 8,
decoration: BoxDecoration( runSpacing: 8,
color: Colors.white, crossAxisAlignment: WrapCrossAlignment.center,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: Colors.black12),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [ children: [
Flexible(child: Text(label, style: const TextStyle(fontSize: 13))), ...options.map((o) {
Row( final selected = _isPreset && o == value;
mainAxisSize: MainAxisSize.min, return ChoiceChip(
children: [ label: Text(labelOf(o)),
IconButton( selected: selected,
icon: const Icon(Icons.remove_circle_outline), onSelected: (_) => onPreset(o),
iconSize: 20, selectedColor: AppTheme.primary,
visualDensity: VisualDensity.compact, labelStyle: TextStyle(
padding: EdgeInsets.zero, color: selected ? Colors.white : Colors.black87,
constraints: const BoxConstraints(), ),
onPressed: () { );
final i = options.indexOf(value); }),
if (i > 0) onChanged(options[i - 1]); 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,
),
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,
),
),
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) {
Text( if (raw.trim().isEmpty) return;
'$value', final n = int.tryParse(raw.trim());
style: const TextStyle( if (n != null && n >= min && n <= max) {
fontSize: 18, onCustom(n);
fontWeight: FontWeight.w700, }
),
),
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]);
}, },
onSubmitted: _applyCustom,
onEditingComplete: () => _applyCustom(customController.text),
),
), ),
], ],
),
],
),
); );
} }
} }
class _Summary extends StatelessWidget { class _Summary extends StatelessWidget {
const _Summary({ const _Summary({
required this.bowType, required this.bowName,
required this.distance, required this.distance,
required this.targetFace, required this.targetName,
required this.endCount, required this.endCount,
required this.arrowsPerEnd, required this.arrowsPerEnd,
}); });
final BowType bowType; final String bowName;
final int distance; final int distance;
final TargetFaceType targetFace; final String targetName;
final int endCount; final int endCount;
final int arrowsPerEnd; final int arrowsPerEnd;
@@ -258,7 +397,7 @@ class _Summary extends StatelessWidget {
children: [ children: [
Expanded( Expanded(
child: Text( child: Text(
'${_bowLabel(bowType)} · ${distance}m · ${_targetLabel(targetFace)}', '$bowName · ${distance}m · $targetName',
style: const TextStyle(fontSize: 13), 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 '自定义';
}
}
+1
View File
@@ -49,3 +49,4 @@ flutter:
assets: assets:
- assets/images/ - assets/images/
- assets/branding/ - assets/branding/
- assets/config/