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