This commit is contained in:
2026-08-08 10:57:54 +08:00
parent d0d48c1ffc
commit 604af909b0
184 changed files with 12521 additions and 12722 deletions
@@ -0,0 +1,61 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../core/constants/app_defaults.dart';
import '../../../core/providers/app_providers.dart';
import '../../../data/local/models/models.dart';
final appConfigProvider =
AsyncNotifierProvider<AppConfigNotifier, AppConfig>(AppConfigNotifier.new);
class AppConfigNotifier extends AsyncNotifier<AppConfig> {
@override
Future<AppConfig> build() async {
final isar = await ref.watch(isarProvider.future);
final config = await isar.appConfigs.get(AppDefaults.singletonId);
if (config != null) return config;
final fresh = AppConfig.createDefault();
await isar.writeTxn(() => isar.appConfigs.put(fresh));
return fresh;
}
Future<AppConfig> _persist(AppConfig config) async {
final isar = await ref.read(isarProvider.future);
await isar.writeTxn(() => isar.appConfigs.put(config));
state = AsyncData(config);
return config;
}
/// Called after a scoring session is saved successfully.
Future<void> incrementTrialCount() async {
final current = state.value;
if (current == null || current.isVipUnlocked) return;
final next = current.copyWith(
usedTrialCount: current.usedTrialCount + 1,
);
await _persist(next);
}
/// Marks the app as unlocked via IAP purchase or restore.
Future<void> markUnlocked({required bool fromRestore}) async {
final current = state.value;
if (current == null) return;
final next = current.copyWith(
isVipUnlocked: true,
vipUnlockedAt: DateTime.now(),
entitlementSource:
fromRestore ? EntitlementSource.restore : EntitlementSource.purchase,
);
await _persist(next);
}
Future<void> setLastError(String? message) async {
final current = state.value;
if (current == null) return;
await _persist(current.copyWith(lastIapErrorMessage: message));
}
/// Debug-only: instantly unlock without going through App Store.
Future<void> debugUnlock() async {
await markUnlocked(fromRestore: false);
}
}
@@ -0,0 +1,223 @@
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/config/application/app_config_controller.dart';
import 'package:arcx/features/home/presentation/widgets/heatmap_card.dart';
import 'package:arcx/features/home/presentation/widgets/profile_card.dart';
import 'package:arcx/features/home/presentation/widgets/score_distribution_card.dart';
import 'package:arcx/features/home/presentation/widgets/stats_card.dart';
import 'package:arcx/features/iap/presentation/iap_unlock_dialog.dart';
import 'package:arcx/features/profile/presentation/profile_edit_dialog.dart';
import 'package:arcx/features/records/presentation/point_book_list_page.dart';
import 'package:arcx/features/scoring/application/scoring_service.dart';
import 'package:arcx/features/scoring/presentation/point_book_create_page.dart';
import 'package:arcx/features/scoring/presentation/point_book_edit_page.dart';
import 'package:arcx/features/stats/application/stats.dart';
class PointBookHomeScreen extends ConsumerWidget {
const PointBookHomeScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final statsAsync = ref.watch(statsProvider);
final configAsync = ref.watch(appConfigProvider);
return Scaffold(
backgroundColor: AppTheme.surfaceMuted,
appBar: AppBar(title: const Text('记分本')),
body: statsAsync.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (e, _) => Center(child: Text('数据加载失败:$e')),
data: (stats) => RefreshIndicator(
onRefresh: () async => ref.invalidate(statsProvider),
child: ListView(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 32),
children: [
ProfileCard(onEdit: () => _editProfile(context)),
const SizedBox(height: 12),
_EntitlementBanner(configAsync: configAsync),
const SizedBox(height: 12),
StatsCard(stats: stats),
const SizedBox(height: 12),
HeatmapCard(stats: stats),
const SizedBox(height: 12),
ScoreDistributionCard(stats: stats),
const SizedBox(height: 20),
_EntryButtons(
onOpenRecords: () => _openRecords(context),
onStartScoring: () => _startScoring(context, ref),
),
],
),
),
),
);
}
Future<void> _editProfile(BuildContext context) async {
await showDialog<void>(
context: context,
builder: (_) => const ProfileEditDialog(),
);
}
Future<void> _openRecords(BuildContext context) async {
await Navigator.of(context).push(
MaterialPageRoute<void>(builder: (_) => const PointBookListPage()),
);
}
Future<void> _startScoring(BuildContext context, WidgetRef ref) async {
final config = ref.read(appConfigProvider).value;
if (config == null) return;
// IAP / trial gate.
if (!config.canStartScoring) {
await showDialog<void>(
context: context,
builder: (_) => const IapUnlockDialog(),
);
return;
}
// Draft check.
final notifier = ref.read(pointRecordsProvider.notifier);
final draft = await notifier.currentDraft();
if (draft != null && context.mounted) {
final choice = await _showDraftConfirm(context);
if (choice == null) return;
if (choice == true) {
await _goToEdit(context, draft);
return;
}
await notifier.clearDrafts();
}
if (!context.mounted) return;
await _goToCreate(context);
}
Future<void> _goToCreate(BuildContext context) async {
final created = await Navigator.of(context).push<PointRecord>(
MaterialPageRoute<PointRecord>(
builder: (_) => const PointBookCreatePage(),
),
);
if (created != null && context.mounted) {
await _goToEdit(context, created, replacePrevious: true);
}
}
Future<void> _goToEdit(
BuildContext context,
PointRecord record, {
bool replacePrevious = false,
}) async {
final route = MaterialPageRoute<void>(
builder: (_) => PointBookEditPage(recordId: record.id),
);
if (replacePrevious) {
Navigator.of(context).pushReplacement(route);
} else {
await Navigator.of(context).push(route);
}
}
Future<bool?> _showDraftConfirm(BuildContext context) {
return showDialog<bool>(
context: context,
builder: (_) => AlertDialog(
title: const Text('存在未完成草稿'),
content: const Text('检测到上次有未完成的记分草稿,是否继续编辑?选择“重新计分”将删除该草稿。'),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text('重新计分'),
),
FilledButton(
onPressed: () => Navigator.pop(context, true),
child: const Text('继续编辑'),
),
],
),
);
}
}
class _EntitlementBanner extends StatelessWidget {
const _EntitlementBanner({required this.configAsync});
final AsyncValue<AppConfig> configAsync;
@override
Widget build(BuildContext context) {
return configAsync.when(
loading: () => const SizedBox.shrink(),
error: (_, __) => const SizedBox.shrink(),
data: (config) {
final unlocked = config.isVipUnlocked;
return Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
decoration: BoxDecoration(
color: unlocked
? AppTheme.primary.withValues(alpha: 0.12)
: AppTheme.accent.withValues(alpha: 0.18),
borderRadius: BorderRadius.circular(12),
),
child: Row(
children: [
Icon(
unlocked ? Icons.verified_outlined : Icons.timer_outlined,
color: unlocked ? AppTheme.primary : AppTheme.accent,
size: 20,
),
const SizedBox(width: 8),
Expanded(
child: Text(
unlocked
? '已解锁无限记分权益'
: '免费试用剩余 ${config.remainingTrialCount}/${config.freeTrialLimit}',
style: TextStyle(
fontSize: 13,
color: unlocked ? AppTheme.primaryDark : AppTheme.accent,
fontWeight: FontWeight.w600,
),
),
),
],
),
);
},
);
}
}
class _EntryButtons extends StatelessWidget {
const _EntryButtons({required this.onOpenRecords, required this.onStartScoring});
final VoidCallback onOpenRecords;
final VoidCallback onStartScoring;
@override
Widget build(BuildContext context) {
return Row(
children: [
Expanded(
child: OutlinedButton.icon(
onPressed: onOpenRecords,
icon: const Icon(Icons.list_alt_outlined),
label: const Text('计分记录'),
),
),
const SizedBox(width: 12),
Expanded(
child: ElevatedButton.icon(
onPressed: onStartScoring,
icon: const Icon(Icons.play_arrow),
label: const Text('开始记分'),
),
),
],
);
}
}
@@ -0,0 +1,63 @@
import 'package:flutter/material.dart';
import 'package:arcx/app/app_theme.dart';
import 'package:arcx/data/local/models/models.dart';
import 'package:arcx/features/scoring/presentation/widgets/target_face_painter.dart';
import 'package:arcx/features/stats/application/stats.dart';
class HeatmapCard extends StatelessWidget {
const HeatmapCard({super.key, required this.stats});
final StatsSummary stats;
@override
Widget build(BuildContext context) {
final hasHits = stats.hitPoints.isNotEmpty;
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'落点热力图',
style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600),
),
const SizedBox(height: 12),
Center(
child: AspectRatio(
aspectRatio: 1,
child: hasHits
? CustomPaint(
painter: TargetFacePainter(
hitPoints: stats.hitPoints,
hitColor: AppTheme.hitPoint,
),
child: const SizedBox.expand(),
)
: CustomPaint(
painter: TargetFacePainter(
hitPoints: const [],
showAllHits: false,
),
child: const SizedBox.expand(),
),
),
),
if (!hasHits)
Padding(
padding: const EdgeInsets.only(top: 8),
child: Text(
'暂无落点数据,完成一次记分后展示',
style: TextStyle(
fontSize: 13,
color: AppTheme.textSecondary,
),
),
),
],
),
),
);
}
}
@@ -0,0 +1,93 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:arcx/app/app_theme.dart';
import 'package:arcx/core/constants/app_defaults.dart';
import 'package:arcx/data/local/models/models.dart';
import 'package:arcx/features/profile/application/profile_controller.dart';
class ProfileCard extends ConsumerWidget {
const ProfileCard({super.key, required this.onEdit});
final VoidCallback onEdit;
@override
Widget build(BuildContext context, WidgetRef ref) {
final profileAsync = ref.watch(profileProvider);
return profileAsync.when(
loading: () => const _CardShell(child: SizedBox(height: 64)),
error: (e, _) => _CardShell(child: Text('加载失败:$e')),
data: (profile) => _CardShell(
child: Row(
children: [
_Avatar(profile: profile),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
profile.nickname,
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 4),
Text(
'本地用户',
style: TextStyle(
fontSize: 13,
color: AppTheme.textSecondary,
),
),
],
),
),
IconButton(
icon: const Icon(Icons.edit_outlined),
color: AppTheme.primary,
onPressed: onEdit,
),
],
),
),
);
}
}
class _Avatar extends StatelessWidget {
const _Avatar({required this.profile});
final UserProfile profile;
@override
Widget build(BuildContext context) {
final source = profile.avatarSource;
final image =
source == AvatarSource.localFile &&
profile.avatarLocalPath != null &&
profile.avatarLocalPath!.isNotEmpty
? FileImage(File(profile.avatarLocalPath!))
: const AssetImage(AppDefaults.defaultAvatarAssetPath);
return CircleAvatar(
radius: 30,
backgroundColor: AppTheme.surfaceMuted,
backgroundImage: image as ImageProvider,
);
}
}
class _CardShell extends StatelessWidget {
const _CardShell({required this.child});
final Widget child;
@override
Widget build(BuildContext context) {
return Card(
child: Padding(padding: const EdgeInsets.all(16), child: child),
);
}
}
@@ -0,0 +1,118 @@
import 'package:flutter/material.dart';
import 'package:arcx/app/app_theme.dart';
import 'package:arcx/features/stats/application/stats.dart';
class ScoreDistributionCard extends StatelessWidget {
const ScoreDistributionCard({super.key, required this.stats});
final StatsSummary stats;
@override
Widget build(BuildContext context) {
final buckets = stats.scoreDistribution;
final hasData = buckets.isNotEmpty;
final maxCount = hasData
? buckets.map((b) => b.count).reduce((a, b) => a > b ? a : b)
: 1;
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'环值分布',
style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600),
),
const SizedBox(height: 12),
if (!hasData)
Padding(
padding: const EdgeInsets.symmetric(vertical: 24),
child: Center(
child: Text(
'暂无数据',
style: TextStyle(
fontSize: 13,
color: AppTheme.textSecondary,
),
),
),
)
else
SizedBox(
height: 140,
child: Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
for (var i = 0; i < buckets.length; i++) ...[
Expanded(
child: _Bar(
label: buckets[i].label,
count: buckets[i].count,
ratio: buckets[i].count / maxCount,
),
),
if (i < buckets.length - 1) const SizedBox(width: 6),
],
],
),
),
],
),
),
);
}
}
class _Bar extends StatelessWidget {
const _Bar({
required this.label,
required this.count,
required this.ratio,
});
final String label;
final int count;
final double ratio;
@override
Widget build(BuildContext context) {
return LayoutBuilder(
builder: (context, constraints) {
final maxH = constraints.maxHeight - 28; // reserve space for label
final h = (maxH * ratio).clamp(4.0, maxH);
return Column(
mainAxisAlignment: MainAxisAlignment.end,
children: [
Text(
'$count',
style: TextStyle(
fontSize: 11,
color: AppTheme.textSecondary,
),
),
const SizedBox(height: 4),
Container(
width: double.infinity,
height: h,
decoration: BoxDecoration(
color: AppTheme.primary,
borderRadius: BorderRadius.circular(4),
),
),
const SizedBox(height: 4),
Text(
label,
style: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
),
),
],
);
},
);
}
}
@@ -0,0 +1,144 @@
import 'package:flutter/material.dart';
import 'package:arcx/app/app_theme.dart';
import 'package:arcx/features/stats/application/stats.dart';
class StatsCard extends StatelessWidget {
const StatsCard({super.key, required this.stats});
final StatsSummary stats;
@override
Widget build(BuildContext context) {
final hasData = stats.hasData;
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
const Text(
'训练统计',
style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600),
),
const Spacer(),
Text(
'今日',
style: TextStyle(fontSize: 13, color: AppTheme.textSecondary),
),
],
),
const SizedBox(height: 12),
_StatGrid(
items: [
_StatItem(
label: '今日射箭',
value: hasData ? '${stats.todayArrowCount}' : '-',
unit: '',
),
_StatItem(
label: '今日消耗',
value: hasData ? stats.todayCalories.toStringAsFixed(1) : '-',
unit: '千卡',
),
_StatItem(
label: '运动强度',
value: hasData ? stats.todayIntensityLabel : '-',
unit: hasData
? stats.todayIntensityRaw.toStringAsFixed(1)
: '',
),
],
),
const Divider(height: 28),
_StatGrid(
items: [
_StatItem(
label: '训练天数',
value: hasData ? '${stats.trainingDays}' : '-',
unit: '',
),
_StatItem(
label: '累计射箭',
value: hasData ? '${stats.totalArrowCount}' : '-',
unit: '',
),
_StatItem(
label: '平均环数',
value: hasData ? stats.averageScoreLabel : '-',
unit: '',
),
],
),
],
),
),
);
}
}
class _StatGrid extends StatelessWidget {
const _StatGrid({required this.items});
final List<_StatItem> items;
@override
Widget build(BuildContext context) {
return Row(
children: [
for (var i = 0; i < items.length; i++) ...[
Expanded(child: items[i]),
if (i < items.length - 1) const SizedBox(width: 8),
],
],
);
}
}
class _StatItem extends StatelessWidget {
const _StatItem({
required this.label,
required this.value,
required this.unit,
});
final String label;
final String value;
final String unit;
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
label,
style: TextStyle(fontSize: 12, color: AppTheme.textSecondary),
),
const SizedBox(height: 4),
Row(
crossAxisAlignment: CrossAxisAlignment.baseline,
textBaseline: TextBaseline.alphabetic,
children: [
Text(
value,
style: const TextStyle(
fontSize: 22,
fontWeight: FontWeight.w700,
color: AppTheme.primaryDark,
),
),
if (unit.isNotEmpty) ...[
const SizedBox(width: 2),
Text(
unit,
style: TextStyle(fontSize: 12, color: AppTheme.textSecondary),
),
],
],
),
],
);
}
}
@@ -0,0 +1,226 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:in_app_purchase/in_app_purchase.dart';
import '../../../core/providers/app_providers.dart';
import '../../config/application/app_config_controller.dart';
/// Observable state for the in-app purchase flow.
class IapState {
const IapState({
this.product,
this.isLoading = false,
this.isPurchasing = false,
this.errorMessage,
this.productsAvailable = false,
this.lastEvent,
});
final ProductDetails? product;
final bool isLoading;
final bool isPurchasing;
final String? errorMessage;
final bool productsAvailable;
/// Last human-readable event (e.g. "解锁成功").
final String? lastEvent;
IapState copyWith({
ProductDetails? product,
bool? isLoading,
bool? isPurchasing,
String? errorMessage,
bool? productsAvailable,
String? lastEvent,
}) {
return IapState(
product: product ?? this.product,
isLoading: isLoading ?? this.isLoading,
isPurchasing: isPurchasing ?? this.isPurchasing,
errorMessage: errorMessage,
productsAvailable: productsAvailable ?? this.productsAvailable,
lastEvent: lastEvent,
);
}
}
final iapControllerProvider = NotifierProvider<IapController, IapState>(
IapController.new,
);
class IapController extends Notifier<IapState> {
late final InAppPurchase _store = InAppPurchase.instance;
StreamSubscription<List<PurchaseDetails>>? _sub;
@override
IapState build() {
ref.onDispose(() {
_sub?.cancel();
_sub = null;
});
_init();
return const IapState(isLoading: true);
}
Future<void> _init() async {
final available = await _store.isAvailable();
if (!available) {
state = state.copyWith(
isLoading: false,
productsAvailable: false,
errorMessage: '无法连接到 App Store,请检查网络设置',
);
return;
}
_sub = _store.purchaseStream.listen(
_onPurchaseUpdated,
onError: (Object e) {
state = state.copyWith(isPurchasing: false, errorMessage: '支付失败:$e');
},
);
await _loadProduct();
}
Future<void> _loadProduct() async {
final response = await _store.queryProductDetails({kUnlockProductId});
final error = response.error;
if (error != null) {
state = state.copyWith(
isLoading: false,
productsAvailable: false,
errorMessage: '商品信息加载失败:${error.message}',
);
return;
}
final products = response.productDetails;
if (products.isEmpty) {
// No product configured in App Store Connect yet.
state = state.copyWith(
isLoading: false,
productsAvailable: false,
errorMessage: null,
);
return;
}
final product = products.first;
state = state.copyWith(
isLoading: false,
productsAvailable: true,
product: product,
errorMessage: null,
);
}
Future<void> buy() async {
final product = state.product;
if (product == null) {
state = state.copyWith(errorMessage: '商品尚未就绪,请稍后再试');
return;
}
state = state.copyWith(
isPurchasing: true,
errorMessage: null,
lastEvent: null,
);
final param = PurchaseParam(productDetails: product);
final ok = await _store.buyNonConsumable(purchaseParam: param);
if (!ok) {
state = state.copyWith(isPurchasing: false, errorMessage: '支付已取消');
}
}
Future<void> restore() async {
state = state.copyWith(
isLoading: true,
errorMessage: null,
lastEvent: null,
);
await _store.restorePurchases();
// The purchase stream will deliver restored purchases; clear loading after
// a short delay in case nothing arrives.
Future<void>.delayed(const Duration(seconds: 2), () {
if (state.isLoading) {
state = state.copyWith(
isLoading: false,
lastEvent: state.lastEvent ?? '未找到历史购买记录',
);
}
});
}
void _onPurchaseUpdated(List<PurchaseDetails> purchases) {
for (final purchase in purchases) {
_handle(purchase);
}
}
Future<void> _handle(PurchaseDetails purchase) async {
switch (purchase.status) {
case PurchaseStatus.purchased:
case PurchaseStatus.restored:
if (purchase.pendingCompletePurchase) {
await _store.completePurchase(purchase);
}
await ref
.read(appConfigProvider.notifier)
.markUnlocked(
fromRestore: purchase.status == PurchaseStatus.restored,
);
state = IapState(
product: state.product,
productsAvailable: state.productsAvailable,
lastEvent: purchase.status == PurchaseStatus.restored
? '权益已恢复'
: '解锁成功!已获得无限记分权益',
);
break;
case PurchaseStatus.error:
state = state.copyWith(
isPurchasing: false,
errorMessage: '支付失败:${purchase.error?.message ?? '未知错误'}',
);
break;
case PurchaseStatus.canceled:
state = state.copyWith(isPurchasing: false, lastEvent: '支付已取消');
break;
case PurchaseStatus.pending:
// Purchase awaiting approval/processing; keep the loading state.
state = state.copyWith(isPurchasing: true);
break;
}
}
/// Debug-only unlock used so the app remains usable without App Store
/// Connect configuration during development.
Future<void> debugUnlock() async {
await ref.read(appConfigProvider.notifier).debugUnlock();
state = state.copyWith(lastEvent: '已通过开发模式解锁');
}
void clearEvent() {
state = state.copyWith(lastEvent: null, errorMessage: null);
}
}
@visibleForTesting
IapState iapStateForTest({
ProductDetails? product,
bool isLoading = false,
bool isPurchasing = false,
String? errorMessage,
bool productsAvailable = false,
String? lastEvent,
}) {
return IapState(
product: product,
isLoading: isLoading,
isPurchasing: isPurchasing,
errorMessage: errorMessage,
productsAvailable: productsAvailable,
lastEvent: lastEvent,
);
}
@@ -0,0 +1,156 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:arcx/app/app_theme.dart';
import 'package:arcx/features/iap/application/iap_controller.dart';
class IapUnlockDialog extends ConsumerWidget {
const IapUnlockDialog({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final iap = ref.watch(iapControllerProvider);
final product = iap.product;
ref.listen(iapControllerProvider, (prev, next) {
if (next.lastEvent != null) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(next.lastEvent!)),
);
if (next.lastEvent!.contains('解锁') ||
next.lastEvent!.contains('恢复')) {
Navigator.of(context).pop();
}
} else if (next.errorMessage != null) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(next.errorMessage!)),
);
}
});
return AlertDialog(
title: const Text('解锁记分本'),
content: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'免费试用次数已用完。购买后即可永久解锁无限记分权益,无需联网即可使用。',
style: TextStyle(fontSize: 14),
),
const SizedBox(height: 16),
_PriceRow(
title: product?.title ?? '记分本永久解锁',
price: iap.productsAvailable && product != null
? product.price
: '',
),
const SizedBox(height: 8),
_FeatureItem(text: '无限次记分训练'),
_FeatureItem(text: '本地永久保存训练数据'),
_FeatureItem(text: '一次购买,永久使用'),
if (iap.isLoading) ...[
const SizedBox(height: 12),
const Center(child: CircularProgressIndicator()),
],
],
),
),
actions: [
TextButton(
onPressed: iap.isPurchasing
? null
: () => Navigator.of(context).pop(),
child: const Text('暂不解锁'),
),
TextButton(
onPressed: iap.isPurchasing ? null : () => ref.read(iapControllerProvider.notifier).restore(),
child: const Text('恢复购买'),
),
FilledButton(
onPressed: iap.isPurchasing || iap.isLoading
? null
: () => _unlock(context, ref, iap),
child: iap.isPurchasing
? const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white),
)
: const Text('立即解锁'),
),
],
);
}
Future<void> _unlock(
BuildContext context,
WidgetRef ref,
IapState iap,
) async {
// In debug builds with no App Store product configured, fall back to a
// dev unlock so the app remains usable for testing.
if (kDebugMode && !iap.productsAvailable) {
await ref.read(iapControllerProvider.notifier).debugUnlock();
if (context.mounted) Navigator.of(context).pop();
return;
}
await ref.read(iapControllerProvider.notifier).buy();
}
}
class _PriceRow extends StatelessWidget {
const _PriceRow({required this.title, required this.price});
final String title;
final String price;
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
decoration: BoxDecoration(
color: AppTheme.surfaceMuted,
borderRadius: BorderRadius.circular(8),
),
child: Row(
children: [
Expanded(
child: Text(
title,
style: const TextStyle(fontWeight: FontWeight.w600),
),
),
Text(
price,
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.w700,
color: AppTheme.primary,
),
),
],
),
);
}
}
class _FeatureItem extends StatelessWidget {
const _FeatureItem({required this.text});
final String text;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.only(top: 8),
child: Row(
children: [
const Icon(Icons.check_circle, color: AppTheme.primary, size: 18),
const SizedBox(width: 8),
Text(text, style: const TextStyle(fontSize: 13)),
],
),
);
}
}
@@ -0,0 +1,80 @@
import 'dart:io';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:image_picker/image_picker.dart';
import 'package:path/path.dart' as p;
import 'package:path_provider/path_provider.dart';
import '../../../core/constants/app_defaults.dart';
import '../../../core/providers/app_providers.dart';
import '../../../data/local/models/models.dart';
final profileProvider =
AsyncNotifierProvider<ProfileNotifier, UserProfile>(ProfileNotifier.new);
class ProfileNotifier extends AsyncNotifier<UserProfile> {
@override
Future<UserProfile> build() async {
final isar = await ref.watch(isarProvider.future);
final profile = await isar.userProfiles.get(AppDefaults.singletonId);
if (profile != null) return profile;
// Bootstrap should have created one; create defensively.
final fresh = UserProfile.createDefault();
await isar.writeTxn(() => isar.userProfiles.put(fresh));
return fresh;
}
Future<void> updateNickname(String nickname) async {
final trimmed = nickname.trim();
if (trimmed.isEmpty) return;
final current = state.value;
if (current == null) return;
final updated = current.copyWith(nickname: trimmed);
final isar = await ref.read(isarProvider.future);
await isar.writeTxn(() => isar.userProfiles.put(updated));
state = AsyncData(updated);
}
/// Picks an image from gallery or camera, copies it into the app sandbox
/// and stores the local file path on the profile.
Future<bool> updateAvatar({required bool fromCamera}) async {
final current = state.value;
if (current == null) return false;
final picker = ImagePicker();
final xfile = await picker.pickImage(
source: fromCamera ? ImageSource.camera : ImageSource.gallery,
maxWidth: 512,
maxHeight: 512,
imageQuality: 85,
);
if (xfile == null) return false;
final docs = await getApplicationDocumentsDirectory();
final destPath = p.join(docs.path, AppDefaults.avatarFileName);
final sourceFile = File(xfile.path);
await sourceFile.copy(destPath);
final updated = current.copyWith(
avatarLocalPath: destPath,
avatarSource: AvatarSource.localFile,
);
final isar = await ref.read(isarProvider.future);
await isar.writeTxn(() => isar.userProfiles.put(updated));
state = AsyncData(updated);
return true;
}
Future<void> resetAvatar() async {
final current = state.value;
if (current == null) return;
final updated = current.copyWith(
avatarAssetPath: AppDefaults.defaultAvatarAssetPath,
avatarLocalPath: null,
avatarSource: AvatarSource.asset,
);
final isar = await ref.read(isarProvider.future);
await isar.writeTxn(() => isar.userProfiles.put(updated));
state = AsyncData(updated);
}
}
@@ -0,0 +1,169 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:arcx/app/app_theme.dart';
import 'package:arcx/core/constants/app_defaults.dart';
import 'package:arcx/data/local/models/models.dart';
import 'package:arcx/features/profile/application/profile_controller.dart';
class ProfileEditDialog extends ConsumerStatefulWidget {
const ProfileEditDialog({super.key});
@override
ConsumerState<ProfileEditDialog> createState() => _ProfileEditDialogState();
}
class _ProfileEditDialogState extends ConsumerState<ProfileEditDialog> {
late final TextEditingController _controller;
bool _saving = false;
@override
void initState() {
super.initState();
final profile = ref.read(profileProvider).value;
_controller = TextEditingController(text: profile?.nickname ?? '');
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final profile = ref.watch(profileProvider).value;
return AlertDialog(
title: const Text('编辑个人资料'),
content: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
GestureDetector(
onTap: _saving ? null : () => _pickAvatar(context),
child: _AvatarPreview(profile: profile),
),
if (profile?.avatarSource == AvatarSource.localFile)
TextButton.icon(
onPressed: _saving ? null : _resetAvatar,
icon: const Icon(Icons.refresh, size: 18),
label: const Text('恢复默认头像'),
),
const SizedBox(height: 8),
TextField(
controller: _controller,
maxLength: 12,
decoration: const InputDecoration(
labelText: '昵称',
hintText: '1~12 个字符',
counterText: '',
),
),
],
),
),
actions: [
TextButton(
onPressed: _saving ? null : () => Navigator.pop(context),
child: const Text('取消'),
),
FilledButton(
onPressed: _saving ? null : _save,
child: const Text('保存'),
),
],
);
}
Future<void> _pickAvatar(BuildContext context) async {
final choice = await showModalBottomSheet<AvatarAction>(
context: context,
builder: (_) => SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
ListTile(
leading: const Icon(Icons.photo_outlined),
title: const Text('从相册选择'),
onTap: () => Navigator.pop(context, AvatarAction.gallery),
),
ListTile(
leading: const Icon(Icons.camera_alt_outlined),
title: const Text('拍摄照片'),
onTap: () => Navigator.pop(context, AvatarAction.camera),
),
],
),
),
);
if (choice == null) return;
setState(() => _saving = true);
await ref
.read(profileProvider.notifier)
.updateAvatar(fromCamera: choice == AvatarAction.camera);
if (mounted) setState(() => _saving = false);
}
Future<void> _resetAvatar() async {
setState(() => _saving = true);
await ref.read(profileProvider.notifier).resetAvatar();
if (mounted) setState(() => _saving = false);
}
Future<void> _save() async {
final name = _controller.text.trim();
if (name.isEmpty) {
ScaffoldMessenger.of(
context,
).showSnackBar(const SnackBar(content: Text('昵称不能为空')));
return;
}
setState(() => _saving = true);
await ref.read(profileProvider.notifier).updateNickname(name);
if (mounted) {
setState(() => _saving = false);
Navigator.pop(context);
}
}
}
enum AvatarAction { gallery, camera }
class _AvatarPreview extends StatelessWidget {
const _AvatarPreview({required this.profile});
final UserProfile? profile;
@override
Widget build(BuildContext context) {
final p = profile;
final ImageProvider image;
if (p != null &&
p.avatarSource == AvatarSource.localFile &&
p.avatarLocalPath != null &&
p.avatarLocalPath!.isNotEmpty) {
image = FileImage(File(p.avatarLocalPath!));
} else {
image = const AssetImage(AppDefaults.defaultAvatarAssetPath);
}
return Stack(
alignment: Alignment.bottomRight,
children: [
CircleAvatar(
radius: 44,
backgroundColor: AppTheme.surfaceMuted,
backgroundImage: image,
),
Container(
padding: const EdgeInsets.all(6),
decoration: const BoxDecoration(
color: AppTheme.primary,
shape: BoxShape.circle,
),
child: const Icon(Icons.edit, color: Colors.white, size: 16),
),
],
);
}
}
@@ -0,0 +1,314 @@
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';
import 'package:arcx/features/scoring/presentation/point_book_edit_page.dart';
class PointBookListPage extends ConsumerStatefulWidget {
const PointBookListPage({super.key});
@override
ConsumerState<PointBookListPage> createState() => _PointBookListPageState();
}
class _PointBookListPageState extends ConsumerState<PointBookListPage> {
BowType? _bowFilter;
TargetFaceType? _targetFilter;
@override
Widget build(BuildContext context) {
final recordsAsync = ref.watch(pointRecordsProvider);
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),
),
Expanded(
child: recordsAsync.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (e, _) => Center(child: Text('加载失败:$e')),
data: (records) {
final filtered = _applyFilters(records);
if (filtered.isEmpty) {
return _EmptyState(hasAny: records.isNotEmpty);
}
return ListView.separated(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 24),
itemCount: filtered.length,
separatorBuilder: (_, __) => const SizedBox(height: 8),
itemBuilder: (_, index) {
final record = filtered[index];
return _RecordTile(
record: record,
onTap: () => _open(context, record),
onDelete: () => _confirmDelete(context, record),
);
},
);
},
),
),
],
),
);
}
List<PointRecord> _applyFilters(List<PointRecord> records) {
var result = records;
if (_bowFilter != null) {
result = result.where((r) => r.bowType == _bowFilter).toList();
}
if (_targetFilter != null) {
result = result.where((r) => r.targetFaceType == _targetFilter).toList();
}
return result;
}
Future<void> _open(BuildContext context, PointRecord record) async {
await Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (_) => PointBookEditPage(recordId: record.id, readOnly: true),
),
);
}
Future<void> _confirmDelete(BuildContext context, PointRecord record) async {
final ok = await showDialog<bool>(
context: context,
builder: (_) => AlertDialog(
title: const Text('删除记录'),
content: const Text('确定删除该记分记录吗?此操作不可撤销。'),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text('取消'),
),
FilledButton(
style: FilledButton.styleFrom(backgroundColor: Colors.red),
onPressed: () => Navigator.pop(context, true),
child: const Text('删除'),
),
],
),
);
if (ok == true) {
await ref.read(pointRecordsProvider.notifier).delete(record.id);
}
}
}
class _FilterBar extends StatelessWidget {
const _FilterBar({
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;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 4),
child: Row(
children: [
Expanded(
child: _Dropdown<BowType>(
label: '弓型',
value: bowFilter,
items: BowType.values,
labelOf: _bowLabel,
onChanged: onBowChanged,
),
),
const SizedBox(width: 12),
Expanded(
child: _Dropdown<TargetFaceType>(
label: '靶纸',
value: targetFilter,
items: TargetFaceType.values,
labelOf: _targetLabel,
onChanged: onTargetChanged,
),
),
],
),
);
}
}
class _Dropdown<T> extends StatelessWidget {
const _Dropdown({
required this.label,
required this.value,
required this.items,
required this.labelOf,
required this.onChanged,
});
final String label;
final T? value;
final List<T> items;
final String Function(T) labelOf;
final ValueChanged<T?> onChanged;
@override
Widget build(BuildContext context) {
return DropdownButtonFormField<T>(
value: value,
decoration: InputDecoration(
labelText: label,
isDense: true,
contentPadding:
const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
border: OutlineInputBorder(borderRadius: BorderRadius.circular(10)),
),
items: [
DropdownMenuItem<T>(value: null, child: Text('全部')),
...items.map((e) => DropdownMenuItem<T>(
value: e,
child: Text(labelOf(e)),
)),
],
onChanged: onChanged,
);
}
}
class _RecordTile extends StatelessWidget {
const _RecordTile({
required this.record,
required this.onTap,
required this.onDelete,
});
final PointRecord record;
final VoidCallback onTap;
final VoidCallback onDelete;
@override
Widget build(BuildContext context) {
final isDraft = record.isDraft;
return Card(
child: ListTile(
onTap: onTap,
title: Row(
children: [
Expanded(
child: Text(
record.title,
style: const TextStyle(fontWeight: FontWeight.w600),
),
),
if (isDraft)
Container(
padding:
const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
decoration: BoxDecoration(
color: AppTheme.accent.withValues(alpha: 0.18),
borderRadius: BorderRadius.circular(6),
),
child: Text(
'草稿',
style: TextStyle(
fontSize: 11,
color: AppTheme.accent,
fontWeight: FontWeight.w600,
),
),
),
],
),
subtitle: Text(
'${_bowLabel(record.bowType)} · ${record.distanceMeters}m · ${_targetLabel(record.targetFaceType)}\n'
'${_formatDate(record.createdAt)} ${record.totalArrows}${record.averageScore != null ? '${record.averageScore!.toStringAsFixed(1)}' : ''}',
style: TextStyle(fontSize: 12, color: AppTheme.textSecondary),
),
isThreeLine: true,
trailing: IconButton(
icon: const Icon(Icons.delete_outline, color: Colors.red),
onPressed: onDelete,
),
),
);
}
}
class _EmptyState extends StatelessWidget {
const _EmptyState({required this.hasAny});
final bool hasAny;
@override
Widget build(BuildContext context) {
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.history,
size: 64,
color: AppTheme.textSecondary.withValues(alpha: 0.4),
),
const SizedBox(height: 12),
Text(
hasAny ? '当前筛选条件下无记录' : '暂无记分记录',
style: TextStyle(color: AppTheme.textSecondary),
),
],
),
);
}
}
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,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;
}
}
+135
View File
@@ -0,0 +1,135 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:isar/isar.dart';
import '../../../core/providers/app_providers.dart';
import '../../../data/local/models/models.dart';
import '../../scoring/application/scoring_service.dart';
/// Pure-data summary of local archery statistics, derived from completed
/// [PointRecord]s. All display strings are localized in zh-CN.
class StatsSummary {
const StatsSummary({
this.todayArrowCount = 0,
this.trainingDays = 0,
this.totalArrowCount = 0,
this.averageScore,
this.scoreDistribution = const <ScoreBucket>[],
this.hitPoints = const <HitPoint>[],
});
final int todayArrowCount;
final int trainingDays;
final int totalArrowCount;
final double? averageScore;
final List<ScoreBucket> scoreDistribution;
final List<HitPoint> hitPoints;
bool get hasData => totalArrowCount > 0;
double get todayCalories => todayArrowCount * 1.6;
/// Capped at 10 per PRD: todayArrows * 5 / 60.
double get todayIntensityRaw {
final v = todayArrowCount * 5 / 60.0;
return v > 10 ? 10 : v;
}
String get todayIntensityLabel {
final v = todayIntensityRaw;
if (v > 6) return '重度';
if (v >= 4) return '中度';
return '轻度';
}
String get averageScoreLabel =>
averageScore == null ? '-' : averageScore!.toStringAsFixed(1);
}
/// One bucket in the ring-value distribution. [label] follows the
/// "M / X / 1~10" convention from the PRD.
class ScoreBucket {
const ScoreBucket({required this.label, required this.count});
final String label;
final int count;
}
final statsProvider = FutureProvider<StatsSummary>((ref) async {
// Re-compute whenever the records list changes.
ref.watch(pointRecordsProvider);
final isar = await ref.watch(isarProvider.future);
final records = await isar.pointRecords
.filter()
.statusEqualTo(PointRecordStatus.completed)
.sortByCreatedAtDesc()
.findAll();
return _compute(records);
});
StatsSummary _compute(List<PointRecord> records) {
if (records.isEmpty) return const StatsSummary();
final today = DateTime.now();
final todayDate = DateTime(today.year, today.month, today.day);
int todayArrows = 0;
int totalArrows = 0;
double scoreSum = 0;
int scoredArrows = 0;
final days = <DateTime>{};
final allHits = <HitPoint>[];
final counts = <String, int>{};
for (final r in records) {
final day = DateTime(r.createdAt.year, r.createdAt.month, r.createdAt.day);
days.add(day);
totalArrows += r.scores.length;
if (day == todayDate) {
todayArrows += r.scores.length;
}
for (final s in r.scores) {
scoreSum += s;
scoredArrows += 1;
final key = _bucketLabel(s);
counts[key] = (counts[key] ?? 0) + 1;
}
allHits.addAll(r.hitPoints);
}
final order = const [
'X',
'10',
'9',
'8',
'7',
'6',
'5',
'4',
'3',
'2',
'1',
'M',
];
final distribution = <ScoreBucket>[];
for (final label in order) {
final c = counts[label] ?? 0;
if (c > 0) distribution.add(ScoreBucket(label: label, count: c));
}
return StatsSummary(
todayArrowCount: todayArrows,
trainingDays: days.length,
totalArrowCount: totalArrows,
averageScore: scoredArrows == 0 ? null : scoreSum / scoredArrows,
scoreDistribution: distribution,
hitPoints: allHits,
);
}
String _bucketLabel(int score) {
if (score == 0) return 'M';
if (score == 10) return '10';
return '$score';
}