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