diff --git a/.Jules/palette.md b/.Jules/palette.md new file mode 100644 index 0000000..c1cc027 --- /dev/null +++ b/.Jules/palette.md @@ -0,0 +1,5 @@ +# Palette Journal + +## 2026-03-31 - Custom Checkbox Semantics and Tooltip Overlay Patterns +经验心得: 在 Flutter 中,通过 GestureDetector 和 Container 构建的自定义 Checkbox 默认不具备任何屏幕阅读器可读属性。如果不显示声明 `Semantics(label, value, checked, button)`,TalkBack 与 VoiceOver 会完全忽略其交互状态。此外,针对全图标按钮(如 M3EIconButton / GlassIconButton),统一外包裹 `Tooltip` 既能提升桌面端的悬停提示体验,又能为移动端无障碍套件自动注入语义属性。 +后续行动: 凡在此 codebase 中定义非原生 UI 控件(如自定义 Checkbox、Radio 或 Toggle 卡片)时,必须以 `Semantics` 包裹并配置 `label`、`value` 及 `checked`;全图标按钮一律包裹 `Tooltip` 提供显式文字说明。 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5631d35..24f802b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -82,11 +82,16 @@ jobs: - name: Generate Coverage Report if: always() && hashFiles('coverage/lcov.info') != '' run: | - sudo apt-get update -qq && sudo apt-get install -y -qq lcov - genhtml coverage/lcov.info --output-directory coverage/html - COVERAGE=$(lcov --summary coverage/lcov.info 2>&1 | grep lines | grep -oP '\d+\.\d+%' | head -1 || echo "N/A") - echo "### ✅ Test Coverage: $COVERAGE" >> "$GITHUB_STEP_SUMMARY" - echo "" >> "$GITHUB_STEP_SUMMARY" + sudo apt-get update -qq || true + sudo apt-get install -y -qq lcov || true + if command -v genhtml >/dev/null 2>&1; then + genhtml coverage/lcov.info --output-directory coverage/html + COVERAGE=$(lcov --summary coverage/lcov.info 2>&1 | grep lines | grep -oP '\d+\.\d+%' | head -1 || echo "N/A") + echo "### ✅ Test Coverage: $COVERAGE" >> "$GITHUB_STEP_SUMMARY" + echo "" >> "$GITHUB_STEP_SUMMARY" + else + echo "::warning::lcov/genhtml package install failed; skipping HTML coverage generation." + fi - name: Upload Coverage Artifact if: always() && hashFiles('coverage/html') != '' diff --git a/lib/pages/TaskCreationPage/task_creation_page.dart b/lib/pages/TaskCreationPage/task_creation_page.dart index 25f2e02..e095ada 100644 --- a/lib/pages/TaskCreationPage/task_creation_page.dart +++ b/lib/pages/TaskCreationPage/task_creation_page.dart @@ -28,10 +28,13 @@ class TaskCreationPage { onPrimary: Theme.of(context).colorScheme.onSurface, ), ), - child: M3EIconButton( - icon: Icon(Icons.close_rounded), - variant: M3EIconButtonVariant.filled, - onPressed: () => Navigator.of(context).pop(), + child: Tooltip( + message: '关闭', + child: M3EIconButton( + icon: const Icon(Icons.close_rounded), + variant: M3EIconButtonVariant.filled, + onPressed: () => Navigator.of(context).pop(), + ), ), ), ), @@ -46,13 +49,16 @@ class TaskCreationPage { onPrimary: Theme.of(context).colorScheme.onPrimaryContainer, ), ), - child: M3EIconButton( - icon: Icon( - Icons.add_rounded, - color: Theme.of(context).colorScheme.onPrimaryContainer, + child: Tooltip( + message: '创建任务', + child: M3EIconButton( + icon: Icon( + Icons.add_rounded, + color: Theme.of(context).colorScheme.onPrimaryContainer, + ), + variant: M3EIconButtonVariant.filled, + onPressed: () => _handleCreate(context, ref), ), - variant: M3EIconButtonVariant.filled, - onPressed: () => _handleCreate(context, ref), ), ), ); diff --git a/lib/shared/custom_appbar.dart b/lib/shared/custom_appbar.dart index d0857a8..dd495de 100644 --- a/lib/shared/custom_appbar.dart +++ b/lib/shared/custom_appbar.dart @@ -110,9 +110,12 @@ class CustomAppbar extends StatelessWidget implements PreferredSizeWidget { top: 0, bottom: 0, child: Center( - child: GlassIconButton( - icon: const Icon(Icons.arrow_back_rounded), - onPressed: () => context.pop(), + child: Tooltip( + message: '返回', + child: GlassIconButton( + icon: const Icon(Icons.arrow_back_rounded), + onPressed: () => context.pop(), + ), ), ), ), diff --git a/lib/shared/task_card.dart b/lib/shared/task_card.dart index 955229d..6b9c485 100644 --- a/lib/shared/task_card.dart +++ b/lib/shared/task_card.dart @@ -115,29 +115,39 @@ class _TaskCardState extends ConsumerState { // ── Checkbox ───────────────────────────────────────────────── Padding( padding: const EdgeInsets.only(top: 2, right: 12), - child: GestureDetector( - onTap: () { - setState(() => _completed = !_completed); - widget.onToggle?.call(_completed); - }, - child: AnimatedContainer( - duration: const Duration(milliseconds: 250), - curve: Curves.easeInOut, - width: 22, - height: 22, - decoration: BoxDecoration( - shape: BoxShape.circle, - color: _completed ? cs.primary : Colors.transparent, - border: Border.all( - color: _completed - ? cs.primary - : cs.onSurface.withValues(alpha: 0.4), - width: 2, + child: Semantics( + label: '完成任务', + value: _completed ? '已完成' : '未完成', + checked: _completed, + button: true, + child: GestureDetector( + onTap: () { + setState(() => _completed = !_completed); + widget.onToggle?.call(_completed); + }, + child: AnimatedContainer( + duration: const Duration(milliseconds: 250), + curve: Curves.easeInOut, + width: 22, + height: 22, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: _completed ? cs.primary : Colors.transparent, + border: Border.all( + color: _completed + ? cs.primary + : cs.onSurface.withValues(alpha: 0.4), + width: 2, + ), ), + child: _completed + ? Icon( + Icons.check_rounded, + size: 14, + color: cs.onPrimary, + ) + : null, ), - child: _completed - ? Icon(Icons.check_rounded, size: 14, color: cs.onPrimary) - : null, ), ), ), diff --git a/test/widgets/task_card_test.dart b/test/widgets/task_card_test.dart new file mode 100644 index 0000000..5ee81b6 --- /dev/null +++ b/test/widgets/task_card_test.dart @@ -0,0 +1,63 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:janus/shared/task_card.dart'; +import 'package:janus/theme/theme.dart'; + +void main() { + testWidgets('TaskCard exposes correct Semantics for checkbox', ( + tester, + ) async { + bool toggledState = false; + + await tester.pumpWidget( + ProviderScope( + child: MaterialApp( + theme: AppTheme.light, + home: Scaffold( + body: TaskCard( + title: '测试任务', + isCompleted: false, + priority: '中', + ddl: DateTime.now().add(const Duration(hours: 1)), + est: const TimeOfDay(hour: 1, minute: 0), + onToggle: (completed) { + toggledState = completed; + }, + ), + ), + ), + ), + ); + + // Verify initial checkbox semantics + final checkboxSemantics = find.byWidgetPredicate( + (widget) => + widget is Semantics && + widget.properties.label == '完成任务' && + widget.properties.value == '未完成' && + widget.properties.checked == false && + widget.properties.button == true, + ); + + expect(checkboxSemantics, findsOneWidget); + + // Tap checkbox semantics target + await tester.tap(checkboxSemantics); + await tester.pumpAndSettle(); + + expect(toggledState, isTrue); + + // Verify updated checkbox semantics + final updatedSemantics = find.byWidgetPredicate( + (widget) => + widget is Semantics && + widget.properties.label == '完成任务' && + widget.properties.value == '已完成' && + widget.properties.checked == true && + widget.properties.button == true, + ); + + expect(updatedSemantics, findsOneWidget); + }); +}