Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .Jules/palette.md
Original file line number Diff line number Diff line change
@@ -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` 提供显式文字说明。
15 changes: 10 additions & 5 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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') != ''
Expand Down
26 changes: 16 additions & 10 deletions lib/pages/TaskCreationPage/task_creation_page.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
),
),
),
),
Expand All @@ -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),
),
),
);
Expand Down
9 changes: 6 additions & 3 deletions lib/shared/custom_appbar.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
),
),
),
),
Expand Down
52 changes: 31 additions & 21 deletions lib/shared/task_card.dart
Original file line number Diff line number Diff line change
Expand Up @@ -115,29 +115,39 @@ class _TaskCardState extends ConsumerState<TaskCard> {
// ── 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,
),
),
),
Expand Down
63 changes: 63 additions & 0 deletions test/widgets/task_card_test.dart
Original file line number Diff line number Diff line change
@@ -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);
});
}
Loading