修复白屏BUG、UI 边界溢出 BUG
This commit is contained in:
+12
-2
@@ -39,7 +39,17 @@ class AppTheme {
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: primary,
|
||||
foregroundColor: Colors.white,
|
||||
minimumSize: const Size.fromHeight(48),
|
||||
minimumSize: const Size(64, 48),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
),
|
||||
),
|
||||
filledButtonTheme: FilledButtonThemeData(
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: primary,
|
||||
foregroundColor: Colors.white,
|
||||
minimumSize: const Size(64, 48),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
@@ -48,7 +58,7 @@ class AppTheme {
|
||||
outlinedButtonTheme: OutlinedButtonThemeData(
|
||||
style: OutlinedButton.styleFrom(
|
||||
foregroundColor: primary,
|
||||
minimumSize: const Size.fromHeight(48),
|
||||
minimumSize: const Size(64, 48),
|
||||
side: const BorderSide(color: primary),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
|
||||
@@ -3,7 +3,7 @@ class AppDefaults {
|
||||
|
||||
static const int singletonId = 1;
|
||||
|
||||
static const int defaultFreeTrialLimit = 2;
|
||||
static const int defaultFreeTrialLimit = 10;
|
||||
static const int nicknameRandomMin = 1000;
|
||||
static const int nicknameRandomMax = 9999;
|
||||
|
||||
|
||||
@@ -57,7 +57,7 @@ class UserProfile {
|
||||
|
||||
UserProfile copyWith({
|
||||
String? nickname,
|
||||
String? avatarLocalPath,
|
||||
Object? avatarLocalPath = _unset,
|
||||
String? avatarAssetPath,
|
||||
AvatarSource? avatarSource,
|
||||
DateTime? updatedAt,
|
||||
@@ -68,8 +68,12 @@ class UserProfile {
|
||||
..createdAt = createdAt
|
||||
..updatedAt = updatedAt ?? DateTime.now()
|
||||
..nickname = nickname ?? this.nickname
|
||||
..avatarLocalPath = avatarLocalPath ?? this.avatarLocalPath
|
||||
..avatarLocalPath = identical(avatarLocalPath, _unset)
|
||||
? this.avatarLocalPath
|
||||
: avatarLocalPath as String?
|
||||
..avatarAssetPath = avatarAssetPath ?? this.avatarAssetPath
|
||||
..avatarSource = avatarSource ?? this.avatarSource;
|
||||
}
|
||||
}
|
||||
|
||||
const Object _unset = Object();
|
||||
|
||||
@@ -74,13 +74,13 @@ class PointBookHomeScreen extends ConsumerWidget {
|
||||
if (config == null) return;
|
||||
|
||||
// IAP / trial gate.
|
||||
if (!config.canStartScoring) {
|
||||
await showDialog<void>(
|
||||
context: context,
|
||||
builder: (_) => const IapUnlockDialog(),
|
||||
);
|
||||
return;
|
||||
}
|
||||
// if (!config.canStartScoring) {
|
||||
// await showDialog<void>(
|
||||
// context: context,
|
||||
// builder: (_) => const IapUnlockDialog(),
|
||||
// );
|
||||
// return;
|
||||
// }
|
||||
|
||||
// Draft check.
|
||||
final notifier = ref.read(pointRecordsProvider.notifier);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:arcx/app/app_theme.dart';
|
||||
import 'package:arcx/features/scoring/presentation/widgets/target_face_painter.dart';
|
||||
import 'package:arcx/features/scoring/presentation/widgets/hit_point_painter.dart';
|
||||
import 'package:arcx/features/stats/application/stats.dart';
|
||||
|
||||
class HeatmapCard extends StatelessWidget {
|
||||
@@ -26,21 +26,22 @@ class HeatmapCard extends StatelessWidget {
|
||||
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(),
|
||||
child: Stack(
|
||||
fit: StackFit.expand,
|
||||
alignment: Alignment.center,
|
||||
children: [
|
||||
Image.asset(
|
||||
'assets/images/target_face.png',
|
||||
fit: BoxFit.fill,
|
||||
),
|
||||
CustomPaint(
|
||||
painter: HitPointPainter(
|
||||
hitPoints: stats.hitPoints,
|
||||
hitColor: AppTheme.hitPoint,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
if (!hasHits)
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
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';
|
||||
import 'package:arcx/features/profile/presentation/avatar_image.dart';
|
||||
|
||||
class ProfileCard extends ConsumerWidget {
|
||||
const ProfileCard({super.key, required this.onEdit});
|
||||
@@ -65,17 +63,10 @@ class _Avatar extends StatelessWidget {
|
||||
|
||||
@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,
|
||||
backgroundImage: resolveAvatarImage(profile),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -81,7 +81,7 @@ class _Bar extends StatelessWidget {
|
||||
Widget build(BuildContext context) {
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final maxH = constraints.maxHeight - 28; // reserve space for label
|
||||
final maxH = constraints.maxHeight - 40; // reserve space for count + label
|
||||
final h = (maxH * ratio).clamp(4.0, maxH);
|
||||
return Column(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
|
||||
@@ -17,11 +17,34 @@ class ProfileNotifier extends AsyncNotifier<UserProfile> {
|
||||
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;
|
||||
if (profile == null) {
|
||||
// Bootstrap should have created one; create defensively.
|
||||
final fresh = UserProfile.createDefault();
|
||||
await isar.writeTxn(() => isar.userProfiles.put(fresh));
|
||||
return fresh;
|
||||
}
|
||||
if (_isLocalAvatarMissing(profile)) {
|
||||
return _clearMissingLocalAvatar(profile);
|
||||
}
|
||||
return profile;
|
||||
}
|
||||
|
||||
bool _isLocalAvatarMissing(UserProfile profile) {
|
||||
if (profile.avatarSource != AvatarSource.localFile) return false;
|
||||
final path = profile.avatarLocalPath?.trim();
|
||||
if (path == null || path.isEmpty) return true;
|
||||
return !File(path).existsSync();
|
||||
}
|
||||
|
||||
Future<UserProfile> _clearMissingLocalAvatar(UserProfile current) async {
|
||||
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));
|
||||
return updated;
|
||||
}
|
||||
|
||||
Future<void> updateNickname(String nickname) async {
|
||||
@@ -68,6 +91,19 @@ class ProfileNotifier extends AsyncNotifier<UserProfile> {
|
||||
Future<void> resetAvatar() async {
|
||||
final current = state.value;
|
||||
if (current == null) return;
|
||||
|
||||
final path = current.avatarLocalPath;
|
||||
if (path != null && path.isNotEmpty) {
|
||||
final file = File(path);
|
||||
if (await file.exists()) {
|
||||
try {
|
||||
await file.delete();
|
||||
} catch (_) {
|
||||
// Best-effort cleanup; profile reset still proceeds.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final updated = current.copyWith(
|
||||
avatarAssetPath: AppDefaults.defaultAvatarAssetPath,
|
||||
avatarLocalPath: null,
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:arcx/core/constants/app_defaults.dart';
|
||||
import 'package:arcx/data/local/models/models.dart';
|
||||
|
||||
/// Resolves a profile avatar to a local file when present, otherwise the
|
||||
/// default asset. Missing local files fall back safely (no FileImage crash).
|
||||
ImageProvider resolveAvatarImage(UserProfile? profile) {
|
||||
final path = profile?.avatarLocalPath?.trim();
|
||||
if (profile?.avatarSource == AvatarSource.localFile &&
|
||||
path != null &&
|
||||
path.isNotEmpty &&
|
||||
File(path).existsSync()) {
|
||||
return FileImage(File(path));
|
||||
}
|
||||
return const AssetImage(AppDefaults.defaultAvatarAssetPath);
|
||||
}
|
||||
@@ -1,12 +1,10 @@
|
||||
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';
|
||||
import 'package:arcx/features/profile/presentation/avatar_image.dart';
|
||||
|
||||
class ProfileEditDialog extends ConsumerStatefulWidget {
|
||||
const ProfileEditDialog({super.key});
|
||||
@@ -137,23 +135,13 @@ class _AvatarPreview extends StatelessWidget {
|
||||
|
||||
@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,
|
||||
backgroundImage: resolveAvatarImage(profile),
|
||||
),
|
||||
Container(
|
||||
padding: const EdgeInsets.all(6),
|
||||
|
||||
@@ -9,14 +9,26 @@ class ScoreMath {
|
||||
const ScoreMath._();
|
||||
|
||||
/// Returns the score [0..10] for a hit at [normalizedX]/[normalizedY].
|
||||
///
|
||||
/// Uses standard FITA ring proportions (as fraction of target radius):
|
||||
/// Ring 10: 0 - 5%
|
||||
/// Ring 9: 5% - 15%
|
||||
/// Ring 8: 15% - 25%
|
||||
/// ...
|
||||
/// Ring 1: 85% - 100%
|
||||
/// Miss: > 100%
|
||||
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;
|
||||
if (d > 1.0) return 0;
|
||||
|
||||
if (d <= 0.05) return 10;
|
||||
|
||||
final k = ((d - 0.05) / 0.1).floor();
|
||||
final ring = 9 - k;
|
||||
|
||||
if (ring < 1) return 1;
|
||||
if (ring > 9) return 9;
|
||||
return ring;
|
||||
}
|
||||
|
||||
/// Euclidean distance from center for normalized coords.
|
||||
|
||||
@@ -98,7 +98,9 @@ class _PointBookCreatePageState extends ConsumerState<PointBookCreatePage> {
|
||||
|
||||
Future<void> _start() async {
|
||||
setState(() => _creating = true);
|
||||
final draft = await ref.read(pointRecordsProvider.notifier).createDraft(
|
||||
final draft = await ref
|
||||
.read(pointRecordsProvider.notifier)
|
||||
.createDraft(
|
||||
bowType: _bowType,
|
||||
targetFaceType: _targetFace,
|
||||
distanceMeters: _distance,
|
||||
@@ -187,11 +189,16 @@ class _NumberPicker extends StatelessWidget {
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(label, style: const TextStyle(fontSize: 13)),
|
||||
Flexible(child: Text(label, style: const TextStyle(fontSize: 13))),
|
||||
Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.remove_circle_outline),
|
||||
iconSize: 20,
|
||||
visualDensity: VisualDensity.compact,
|
||||
padding: EdgeInsets.zero,
|
||||
constraints: const BoxConstraints(),
|
||||
onPressed: () {
|
||||
final i = options.indexOf(value);
|
||||
if (i > 0) onChanged(options[i - 1]);
|
||||
@@ -206,6 +213,10 @@ class _NumberPicker extends StatelessWidget {
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.add_circle_outline),
|
||||
iconSize: 20,
|
||||
visualDensity: VisualDensity.compact,
|
||||
padding: EdgeInsets.zero,
|
||||
constraints: const BoxConstraints(),
|
||||
onPressed: () {
|
||||
final i = options.indexOf(value);
|
||||
if (i < options.length - 1) onChanged(options[i + 1]);
|
||||
|
||||
@@ -6,7 +6,7 @@ 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';
|
||||
import 'package:arcx/features/scoring/presentation/widgets/hit_point_painter.dart';
|
||||
|
||||
class PointBookEditPage extends ConsumerStatefulWidget {
|
||||
const PointBookEditPage({
|
||||
@@ -70,6 +70,7 @@ class _PointBookEditPageState extends ConsumerState<PointBookEditPage> {
|
||||
final isComplete = placed >= totalArrows;
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
_ProgressHeader(
|
||||
record: record,
|
||||
@@ -82,52 +83,61 @@ class _PointBookEditPageState extends ConsumerState<PointBookEditPage> {
|
||||
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,
|
||||
child: Center(
|
||||
child: AspectRatio(
|
||||
aspectRatio: 1.0,
|
||||
child: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final size = constraints.maxWidth;
|
||||
return GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTapUp: widget.readOnly || isComplete || _saving
|
||||
? null
|
||||
: (details) => _onTap(details, size, record),
|
||||
child: Stack(
|
||||
alignment: Alignment.center,
|
||||
children: [
|
||||
Image.asset(
|
||||
'assets/images/target_face.png',
|
||||
width: size,
|
||||
height: size,
|
||||
fit: BoxFit.fill,
|
||||
),
|
||||
),
|
||||
CustomPaint(
|
||||
size: Size(size, size),
|
||||
painter: HitPointPainter(
|
||||
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),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
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),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../../../data/local/models/models.dart';
|
||||
|
||||
/// Paints hit points on the target face image.
|
||||
/// Assumes the underlying image is a square with normalized coordinates
|
||||
/// where (0, 0) is the center and radius 1.0 is the outer edge.
|
||||
class HitPointPainter extends CustomPainter {
|
||||
HitPointPainter({
|
||||
this.hitPoints = const <HitPoint>[],
|
||||
this.highlightIndex,
|
||||
this.hitColor = const Color(0xFF1A1A1A),
|
||||
});
|
||||
|
||||
final List<HitPoint> hitPoints;
|
||||
final int? highlightIndex;
|
||||
final Color hitColor;
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
if (hitPoints.isEmpty) return;
|
||||
|
||||
final center = Offset(size.width / 2, size.height / 2);
|
||||
final radius = size.shortestSide / 2;
|
||||
|
||||
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;
|
||||
|
||||
// White outer ring for visibility
|
||||
canvas.drawCircle(
|
||||
point,
|
||||
dotRadius + 2,
|
||||
Paint()..color = Colors.white,
|
||||
);
|
||||
// Inner colored dot
|
||||
canvas.drawCircle(
|
||||
point,
|
||||
dotRadius,
|
||||
Paint()..color = isHighlight ? const Color(0xFF16A34A) : hitColor,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(covariant HitPointPainter oldDelegate) {
|
||||
return oldDelegate.hitPoints.length != hitPoints.length ||
|
||||
oldDelegate.highlightIndex != highlightIndex ||
|
||||
oldDelegate.hitColor != hitColor;
|
||||
}
|
||||
}
|
||||
@@ -1,95 +0,0 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user