修复白屏BUG、UI 边界溢出 BUG

This commit is contained in:
2026-08-09 01:35:48 +08:00
parent 4a357024bc
commit 4385c6941e
24 changed files with 485 additions and 206 deletions
+7
View File
@@ -0,0 +1,7 @@
---
name: grill-me
description: A relentless interview to sharpen a plan or design.
disable-model-invocation: true
---
Run a `/grilling` session.
@@ -0,0 +1,5 @@
interface:
display_name: "Grill Me"
short_description: "Sharpen a plan through interview"
policy:
allow_implicit_invocation: false
+7
View File
@@ -0,0 +1,7 @@
---
name: grill-with-docs
description: A relentless interview to sharpen a plan or design, which also creates docs (ADR's and glossary) as we go.
disable-model-invocation: true
---
Run a `/grilling` session, using the `/domain-modeling` skill.
@@ -0,0 +1,5 @@
interface:
display_name: "Grill with Docs"
short_description: "Grill a design and write its docs"
policy:
allow_implicit_invocation: false
+22
View File
@@ -0,0 +1,22 @@
---
name: grilling
description: Grill the user relentlessly about a plan, decision, or idea. Use when the user wants to stress-test their thinking, or uses any 'grill' trigger phrases.
---
Interview the user relentlessly until you reach a shared understanding. Map this as a **design tree**: every decision branches into the decisions that hang off it.
Work the tree in **rounds**. The **frontier** is every decision whose prerequisites are already settled — the questions you can ask _now_ without guessing at answers you haven't heard yet. Ask the whole frontier in one round: number each question and give your recommended answer. Then wait for the user's answers before the next round.
Each question should be formatted like so:
```
❓ **Q1** - **<question title>**: <question body, might be multiple paragraphs, including multiple choices>
➡️ <your recommended answer>
```
Each round the user answers reshapes the tree — settled decisions push the frontier outward and unblock questions that depended on them. Recompute the frontier and ask the next round. A question whose answer depends on another question still open in this round belongs to a _later_ round, not this one.
Finding _facts_ is your job, never the user's. When a frontier question needs a fact from the environment (filesystem, tools, etc.), dispatch a sub-agent to find it — don't ask the user for anything you could look up yourself. Don't block on it: a running exploration is an unsettled prerequisite, so only the questions downstream of it wait for the sub-agent to report — ask the rest of the frontier now. The _decisions_ are the user's — put each to them and wait.
The session is done when the frontier is empty: every branch of the design tree visited, nothing left silently assumed. Do not act on it until the user confirms you have reached a shared understanding.
@@ -0,0 +1,3 @@
interface:
display_name: "Grilling"
short_description: "Stress-test thinking a round of questions at a time"
@@ -0,0 +1,164 @@
# 修复 UI 边界溢出与记分页白屏问题
## 问题概述
1. **记分页白屏(严重)**:进入记分页后白屏,由 `OutlinedButton` 收到无限宽度约束导致整个 `Scaffold` 布局失败引起
2. **首页环值分布卡片溢出**`score_distribution_card.dart:86` Column 底部溢出 13px
3. **创建页数字选择器溢出**`point_book_create_page.dart:187` Row 右侧溢出 14px
## 根因分析
### 白屏根因:`SizedBox(height: size)` 与 `Expanded` 的约束冲突
`point_book_edit_page.dart` 第 82-141 行的结构:
```
Expanded( // 给子节点 tight height = 剩余高度 (如 200px)
child: Padding(
child: LayoutBuilder(
builder: (context, constraints) {
final size = constraints.maxWidth; // 如 350px (屏幕宽度)
return SizedBox(
width: size, // 350
height: size, // 350 ← 与 Expanded 的 200px 冲突!
child: GestureDetector(...),
);
},
),
),
)
```
**约束冲突原理**
- `Expanded` 给子节点 `BoxConstraints(minHeight: 200, maxHeight: 200)` (tight)
- `SizedBox(height: 350)` 给子节点 `BoxConstraints(minHeight: 350, maxHeight: 350)` (tight)
- 交集:`minHeight: max(200,350)=350, maxHeight: min(200,350)=200`**350 > 200,不可能约束!**
- 布局失败级联到整个 Column → Scaffold → 白屏
### 环值分布卡片溢出根因
`score_distribution_card.dart` 第 84 行:`maxH = constraints.maxHeight - 28`
柱状图 `_Bar` 的 Column 内容总高度 = count文字(~15) + spacing(4) + bar(最大 maxH=112) + spacing(4) + label文字(~16) = 151px,但可用高度仅 140px,溢出 11-13px。
### 创建页溢出根因
`point_book_create_page.dart` 第 187 行:`_NumberPicker` 内的 Row 包含两个默认尺寸 `IconButton`(各 48px)+ 文字 + 标签文字,在半屏宽度内放不下。
## 修改方案
### 1. `point_book_edit_page.dart` — 修复白屏(关键)
**文件**`lib/features/scoring/presentation/point_book_edit_page.dart`
**行号**82-141
`Expanded > Padding > LayoutBuilder > SizedBox` 结构改为 `Expanded > Padding > Center > AspectRatio > LayoutBuilder`
```dart
// 修改前(第 85-90 行):
child: LayoutBuilder(
builder: (context, constraints) {
final size = constraints.maxWidth;
return SizedBox(
width: size,
height: size,
child: GestureDetector(
// 修改后:
child: Center(
child: AspectRatio(
aspectRatio: 1.0,
child: LayoutBuilder(
builder: (context, constraints) {
final size = constraints.maxWidth;
return GestureDetector(
```
关键变更:
-`Padding``LayoutBuilder` 之间插入 `Center(child: AspectRatio(aspectRatio: 1.0))`
- 删除 `SizedBox(width: size, height: size)` 包装层
- `Center` 松解 `Expanded` 的 tight height 约束 → `AspectRatio` 计算 min(宽,高) 的正方形 → `LayoutBuilder` 获取的 `constraints.maxWidth == constraints.maxHeight` → 无冲突
- `Image.asset``CustomPaint``size: Size(size, size)` 保持不变
- 对应关闭括号:需要增加 `),` (AspectRatio) 和 `),` (Center) 的闭合
### 2. `point_book_create_page.dart` — 修复 Row 溢出 14px
**文件**`lib/features/scoring/presentation/point_book_create_page.dart`
**行号**187-216`_NumberPicker` 的 build 方法)
修改 `_NumberPicker` 中的两个 `IconButton`,使其更紧凑:
```dart
// 修改前(第 193-198 行):
IconButton(
icon: const Icon(Icons.remove_circle_outline),
onPressed: () {
// 修改后:
IconButton(
icon: const Icon(Icons.remove_circle_outline),
iconSize: 20,
visualDensity: VisualDensity.compact,
padding: EdgeInsets.zero,
constraints: const BoxConstraints(),
onPressed: () {
```
同样修改 `add` 按钮(第 207-211 行)。
并将标签文字包裹 `Flexible`(第 190 行):
```dart
// 修改前:
Text(label, style: const TextStyle(fontSize: 13)),
// 修改后:
Flexible(
child: Text(label, style: const TextStyle(fontSize: 13)),
),
```
### 3. `score_distribution_card.dart` — 修复 Column 溢出 13px
**文件**`lib/features/home/presentation/widgets/score_distribution_card.dart`
**行号**:第 84 行
```dart
// 修改前:
final maxH = constraints.maxHeight - 28; // reserve space for label
// 修改后:
final maxH = constraints.maxHeight - 40; // reserve space for count + label
```
将预留空间从 28px 增加到 40px,确保柱子最大高度不会导致 Column 溢出。
### 4. `heatmap_card.dart` — 修复 CustomPaint 尺寸为零(附带修复)
**文件**`lib/features/home/presentation/widgets/heatmap_card.dart`
**行号**:第 29 行
当前 `Stack``CustomPaint` 没有指定 `size`,在 `Stack` 中默认尺寸为 0x0,导致命中点不可见。
```dart
// 修改前(第 29 行):
child: Stack(
alignment: Alignment.center,
children: [
// 修改后:
child: Stack(
fit: StackFit.expand,
alignment: Alignment.center,
children: [
```
添加 `fit: StackFit.expand` 使所有非定位子节点(`Image.asset``CustomPaint`)填满 Stack。
## 验证步骤
1. 运行 `flutter analyze` 确认无错误
2. 启动 APP,进入首页 → 确认无黄色溢出条纹
3. 点击"开始记分" → 进入创建页 → 确认数字选择器无溢出
4. 完成创建 → 进入记分页 → 确认 **不白屏**,靶面正常显示
5. 点击靶面 → 确认命中点准确渲染
6. 返回首页 → 查看热力图 → 确认命中点可见
Binary file not shown.

After

Width:  |  Height:  |  Size: 200 KiB

+12 -2
View File
@@ -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),
+1 -1
View File
@@ -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;
+6 -2
View File
@@ -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,20 +26,21 @@ class HeatmapCard extends StatelessWidget {
Center(
child: AspectRatio(
aspectRatio: 1,
child: hasHits
? CustomPaint(
painter: TargetFacePainter(
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,
),
child: const SizedBox.expand(),
)
: CustomPaint(
painter: TargetFacePainter(
hitPoints: const [],
showAllHits: false,
),
child: const SizedBox.expand(),
],
),
),
),
@@ -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,12 +17,35 @@ 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;
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 {
final trimmed = nickname.trim();
@@ -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,28 +83,35 @@ class _PointBookEditPageState extends ConsumerState<PointBookEditPage> {
Expanded(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
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: [
SizedBox(
Image.asset(
'assets/images/target_face.png',
width: size,
height: size,
child: CustomPaint(
painter: TargetFacePainter(
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(
'无落点数据',
@@ -131,6 +139,8 @@ class _PointBookEditPageState extends ConsumerState<PointBookEditPage> {
),
),
),
),
),
const SizedBox(height: 12),
_EndSummary(record: record, scores: _scores),
if (!widget.readOnly)
@@ -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;
}
}
+23
View File
@@ -0,0 +1,23 @@
{
"version": 1,
"skills": {
"grill-me": {
"source": "mattpocock/skills",
"sourceType": "github",
"skillPath": "skills/productivity/grill-me/SKILL.md",
"computedHash": "f361db4e15e6bfd562a9282b1dccda513910a50061f9e838ce017be9c69dde3f"
},
"grill-with-docs": {
"source": "mattpocock/skills",
"sourceType": "github",
"skillPath": "skills/engineering/grill-with-docs/SKILL.md",
"computedHash": "9c460cbd94fd3c63cdef967dbdb6e66ca687103cdc380cd37834e4d10b738f78"
},
"grilling": {
"source": "mattpocock/skills",
"sourceType": "github",
"skillPath": "skills/productivity/grilling/SKILL.md",
"computedHash": "4ebdd12fe61ff3abf20cff6683740e2b9b3739454be0a3f4a928a1c4d07d6b34"
}
}
}