diff --git a/.agents/skills/grill-me/SKILL.md b/.agents/skills/grill-me/SKILL.md new file mode 100644 index 0000000..9470cfc --- /dev/null +++ b/.agents/skills/grill-me/SKILL.md @@ -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. diff --git a/.agents/skills/grill-me/agents/openai.yaml b/.agents/skills/grill-me/agents/openai.yaml new file mode 100644 index 0000000..4d6fb0c --- /dev/null +++ b/.agents/skills/grill-me/agents/openai.yaml @@ -0,0 +1,5 @@ +interface: + display_name: "Grill Me" + short_description: "Sharpen a plan through interview" +policy: + allow_implicit_invocation: false diff --git a/.agents/skills/grill-with-docs/SKILL.md b/.agents/skills/grill-with-docs/SKILL.md new file mode 100644 index 0000000..bed05d2 --- /dev/null +++ b/.agents/skills/grill-with-docs/SKILL.md @@ -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. diff --git a/.agents/skills/grill-with-docs/agents/openai.yaml b/.agents/skills/grill-with-docs/agents/openai.yaml new file mode 100644 index 0000000..5dbe278 --- /dev/null +++ b/.agents/skills/grill-with-docs/agents/openai.yaml @@ -0,0 +1,5 @@ +interface: + display_name: "Grill with Docs" + short_description: "Grill a design and write its docs" +policy: + allow_implicit_invocation: false diff --git a/.agents/skills/grilling/SKILL.md b/.agents/skills/grilling/SKILL.md new file mode 100644 index 0000000..95bd01e --- /dev/null +++ b/.agents/skills/grilling/SKILL.md @@ -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** - ****: + +➡️ +``` + +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. diff --git a/.agents/skills/grilling/agents/openai.yaml b/.agents/skills/grilling/agents/openai.yaml new file mode 100644 index 0000000..ddbdb96 --- /dev/null +++ b/.agents/skills/grilling/agents/openai.yaml @@ -0,0 +1,3 @@ +interface: + display_name: "Grilling" + short_description: "Stress-test thinking a round of questions at a time" diff --git a/.trae/documents/fix-ui-layout-overflow-and-white-screen.md b/.trae/documents/fix-ui-layout-overflow-and-white-screen.md new file mode 100644 index 0000000..325f962 --- /dev/null +++ b/.trae/documents/fix-ui-layout-overflow-and-white-screen.md @@ -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. 返回首页 → 查看热力图 → 确认命中点可见 diff --git a/assets/images/target_face.png b/assets/images/target_face.png new file mode 100644 index 0000000..f0154c9 Binary files /dev/null and b/assets/images/target_face.png differ diff --git a/lib/app/app_theme.dart b/lib/app/app_theme.dart index b18349d..8d36dfc 100644 --- a/lib/app/app_theme.dart +++ b/lib/app/app_theme.dart @@ -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), diff --git a/lib/core/constants/app_defaults.dart b/lib/core/constants/app_defaults.dart index 35ecd08..2433c2c 100644 --- a/lib/core/constants/app_defaults.dart +++ b/lib/core/constants/app_defaults.dart @@ -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; diff --git a/lib/data/local/models/user_profile.dart b/lib/data/local/models/user_profile.dart index 81b96be..0e86897 100644 --- a/lib/data/local/models/user_profile.dart +++ b/lib/data/local/models/user_profile.dart @@ -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(); diff --git a/lib/features/home/presentation/point_book_home_screen.dart b/lib/features/home/presentation/point_book_home_screen.dart index cb2c3d5..d4f8f8b 100644 --- a/lib/features/home/presentation/point_book_home_screen.dart +++ b/lib/features/home/presentation/point_book_home_screen.dart @@ -74,13 +74,13 @@ class PointBookHomeScreen extends ConsumerWidget { if (config == null) return; // IAP / trial gate. - if (!config.canStartScoring) { - await showDialog( - context: context, - builder: (_) => const IapUnlockDialog(), - ); - return; - } + // if (!config.canStartScoring) { + // await showDialog( + // context: context, + // builder: (_) => const IapUnlockDialog(), + // ); + // return; + // } // Draft check. final notifier = ref.read(pointRecordsProvider.notifier); diff --git a/lib/features/home/presentation/widgets/heatmap_card.dart b/lib/features/home/presentation/widgets/heatmap_card.dart index 762f531..277c3e0 100644 --- a/lib/features/home/presentation/widgets/heatmap_card.dart +++ b/lib/features/home/presentation/widgets/heatmap_card.dart @@ -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) diff --git a/lib/features/home/presentation/widgets/profile_card.dart b/lib/features/home/presentation/widgets/profile_card.dart index 6462c01..5d656b0 100644 --- a/lib/features/home/presentation/widgets/profile_card.dart +++ b/lib/features/home/presentation/widgets/profile_card.dart @@ -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), ); } } diff --git a/lib/features/home/presentation/widgets/score_distribution_card.dart b/lib/features/home/presentation/widgets/score_distribution_card.dart index 9f64013..4e2722c 100644 --- a/lib/features/home/presentation/widgets/score_distribution_card.dart +++ b/lib/features/home/presentation/widgets/score_distribution_card.dart @@ -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, diff --git a/lib/features/profile/application/profile_controller.dart b/lib/features/profile/application/profile_controller.dart index 8da8539..79a7530 100644 --- a/lib/features/profile/application/profile_controller.dart +++ b/lib/features/profile/application/profile_controller.dart @@ -17,11 +17,34 @@ class ProfileNotifier extends AsyncNotifier { Future 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 _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 updateNickname(String nickname) async { @@ -68,6 +91,19 @@ class ProfileNotifier extends AsyncNotifier { Future 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, diff --git a/lib/features/profile/presentation/avatar_image.dart b/lib/features/profile/presentation/avatar_image.dart new file mode 100644 index 0000000..de861da --- /dev/null +++ b/lib/features/profile/presentation/avatar_image.dart @@ -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); +} diff --git a/lib/features/profile/presentation/profile_edit_dialog.dart b/lib/features/profile/presentation/profile_edit_dialog.dart index 009a5af..2ef7789 100644 --- a/lib/features/profile/presentation/profile_edit_dialog.dart +++ b/lib/features/profile/presentation/profile_edit_dialog.dart @@ -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), diff --git a/lib/features/scoring/application/score_math.dart b/lib/features/scoring/application/score_math.dart index 225e045..218d3ce 100644 --- a/lib/features/scoring/application/score_math.dart +++ b/lib/features/scoring/application/score_math.dart @@ -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. diff --git a/lib/features/scoring/presentation/point_book_create_page.dart b/lib/features/scoring/presentation/point_book_create_page.dart index 82ddc5b..519183b 100644 --- a/lib/features/scoring/presentation/point_book_create_page.dart +++ b/lib/features/scoring/presentation/point_book_create_page.dart @@ -98,7 +98,9 @@ class _PointBookCreatePageState extends ConsumerState { Future _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]); diff --git a/lib/features/scoring/presentation/point_book_edit_page.dart b/lib/features/scoring/presentation/point_book_edit_page.dart index f199f24..55ee79b 100644 --- a/lib/features/scoring/presentation/point_book_edit_page.dart +++ b/lib/features/scoring/presentation/point_book_edit_page.dart @@ -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 { final isComplete = placed >= totalArrows; return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, children: [ _ProgressHeader( record: record, @@ -82,52 +83,61 @@ class _PointBookEditPageState extends ConsumerState { 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), - ), - ), - ], - ), - ); - }, + ); + }, + ), + ), ), ), ), diff --git a/lib/features/scoring/presentation/widgets/hit_point_painter.dart b/lib/features/scoring/presentation/widgets/hit_point_painter.dart new file mode 100644 index 0000000..d37476b --- /dev/null +++ b/lib/features/scoring/presentation/widgets/hit_point_painter.dart @@ -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 [], + this.highlightIndex, + this.hitColor = const Color(0xFF1A1A1A), + }); + + final List 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; + } +} diff --git a/lib/features/scoring/presentation/widgets/target_face_painter.dart b/lib/features/scoring/presentation/widgets/target_face_painter.dart deleted file mode 100644 index f846241..0000000 --- a/lib/features/scoring/presentation/widgets/target_face_painter.dart +++ /dev/null @@ -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 [], - this.highlightIndex, - this.showAllHits = true, - this.hitColor = const Color(0xFF1A1A1A), - this.ringStrokeWidth = 1.0, - }); - - final List 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(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; - } -} diff --git a/skills-lock.json b/skills-lock.json new file mode 100644 index 0000000..a1b3be2 --- /dev/null +++ b/skills-lock.json @@ -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" + } + } +}