Skip to content

fix: collapse a checked checklist row as one piece#3595

Merged
matthiasn merged 1 commit into
mainfrom
fix/checklist_animation
Jul 25, 2026
Merged

fix: collapse a checked checklist row as one piece#3595
matthiasn merged 1 commit into
mainfrom
fix/checklist_animation

Conversation

@matthiasn

Copy link
Copy Markdown
Owner

Checking an item off under the Open filter played a scrambled exit: the row shrank away while the checkbox held its full size and got sliced in half by the shrinking row, and the row's contents jumped sideways the instant the animation began.

Root cause

The hide/show wrapper in checklist_item_row_state.dart was an AnimatedCrossFade from the row to a zero-sized SizedBox.shrink. That has two independent defects, both measured rather than inferred:

1. It clips, it doesn't scale. AnimatedCrossFade collapses via AnimatedSize, which lays the child out at its natural size and animates only the box, clipping the overflow. The fixed 44×44 checkbox therefore kept its full dimensions while the band closed over it.

point in the 300ms collapse row checkbox
25% bottom at y=45.8 bottom at y=54.0 — hanging ~8px below the row, cropped
50% height at 50% of rest on-screen height at 100%

2. The outgoing row is re-laid-out against zero-size constraints. When crossFadeState flips, the row stops being the sizing child and is laid out against the zero-sized second child. On the very first frame, before any size animation has run, the checkbox jumped from x=64 to x=247 and y=28 to y=46. Under the card's loose width constraints the box also collapsed horizontally (366 → 319 → 183 px), pulling the row in from both sides toward Alignment.topCenter.

Fix

New design-system motion primitive SizeFadeCollapse (components/motion/), the exit counterpart to SizeFadeEntrance. It drives reserved height, paint scale and opacity from one tween sharing one anchor (top-start), so painted size equals reserved size on every frame. Nothing is cropped, the box keeps full width, and the row leaves as a single piece.

// before
child = AnimatedCrossFade(
  duration: checklistCompletionFadeDuration,
  sizeCurve: Curves.easeInOut,
  crossFadeState: _showRow ? CrossFadeState.showFirst : CrossFadeState.showSecond,
  firstChild: child,
  secondChild: const SizedBox.shrink(),
);

// after
child = SizeFadeCollapse(
  duration: checklistCompletionFadeDuration,
  collapsed: !_showRow,
  child: child,
);

Behaviour preserved from the old wrapper: reduced motion snaps instead of animating; a collapsing row is IgnorePointered / ExcludeFocused / ExcludeSemanticsed as soon as it starts leaving, so it can neither be tapped nor announced. Its subtree keeps ticking until the collapse finishes, so an in-flight strike-through wipe or checkbox pop is not frozen half-played. The collapse is reversible — unchecking runs the same tween backwards.

duration is required rather than defaulted: task_details_page.dart computes its scroll-anchor delay from checklistCompletionAnimationDuration + checklistCompletionFadeDuration, and a silent default would let the two drift apart unnoticed.

Verification

Geometry across the collapse after the fix — the checkbox now shrinks with the row and stays inside it at every step, and the row's width never changes:

t row height row width checkbox inside row
0ms 52.0 366.0 20.0×20.0 yes
25ms 46.8 366.0 18.0×18.0 yes
50ms 30.8 366.0 11.9×11.9 yes
75ms 20.4 366.0 7.8×7.8 yes

Confirmed visually too, by rendering the animation frame by frame through the in-app screenshot harness (throwaway capture test, not committed).

  • 4 new regression tests in checklist_item_row_test.dart, each verified to fail against the old implementation
  • 8 new tests for SizeFadeCollapse (geometry invariant per frame, leading-edge anchor, reverse, reduced motion, pointer/semantics exclusion, duration retiming)
  • Existing tests that asserted on AnimatedCrossFade.crossFadeState migrated to SizeFadeCollapse.collapsed
  • 2542 tests pass (1 skipped), analyzer clean, formatter clean
  • READMEs updated (features/tasks, features/design_system), CHANGELOG + flatpak metainfo updated under 0.9.1069

Deliberately not done

SizeFadeEntrance has the same clip-not-scale geometry, so a row entering the list is revealed by cropping rather than scaling — insert and remove are now asymmetric. That is outside the reported bug and changing it is a product call, so it is flagged rather than folded in. Related: checklist_card.dart already carries a comment about AnimatedCrossFade squeezing an outgoing body at progressively narrower widths — the same defect class, worked around there by keeping a width-stable hidden endpoint.

Bead: lotti3-tp0

Checking an item off under the Open filter played a scrambled exit: the row
shrank away while the checkbox held its full size and got sliced in half by
the shrinking row, and the row's contents jumped sideways the instant the
animation began.

The hide wrapper was an AnimatedCrossFade to a zero-sized second child, which
has two independent problems:

- It collapses via AnimatedSize, which lays the child out at natural size and
  animates only the box, clipping the overflow. At 25% of the 300ms collapse
  the row's bottom sat at y=45.8 while the checkbox's bottom sat at y=54.0 —
  hanging below the visible row and cropped. At 50% the row was at half height
  and the checkbox still at 100%.
- Flipping crossFadeState makes the outgoing row stop being the sizing child,
  so it is re-laid-out against the zero-sized second child's constraints. On
  the very first frame, before any size animation, the checkbox jumped from
  x=64 to x=247 and y=28 to y=46. Under the card's loose width constraints the
  box also narrowed from both sides (366 -> 319 -> 183 px).

Replace it with SizeFadeCollapse, a new design-system motion primitive and the
exit counterpart to SizeFadeEntrance. It drives reserved height, paint scale
and opacity from one tween sharing one anchor (top-start), so painted size
equals reserved size on every frame: nothing is cropped, the box keeps full
width, and the whole row shrinks together. Reduced motion snaps. A collapsing
row is IgnorePointer'd / ExcludeFocus'ed / ExcludeSemantics'ed immediately but
keeps ticking until the collapse ends, so an in-flight strike-through wipe or
checkbox pop is not frozen half-played.

duration is required rather than defaulted because task_details_page computes
its scroll-anchor delay from checklistCompletionAnimationDuration +
checklistCompletionFadeDuration; a silent default would let the two drift.

Post-fix geometry across the collapse (row height / checkbox / inside row):
  0ms 52.0 20.0x20.0 true; 25ms 46.8 18.0x18.0 true;
  50ms 30.8 11.9x11.9 true; 75ms 20.4 7.8x7.8 true.

Adds four regression tests to checklist_item_row_test.dart, all of which fail
against the old implementation, plus eight tests for the new component.
@gemini-code-assist

Copy link
Copy Markdown
Contributor

Caution

The consumer version of Gemini Code Assist on GitHub has been sunset. All code review activity has officially ceased.

@coderabbitai

coderabbitai Bot commented Jul 25, 2026

Copy link
Copy Markdown

Warning

Review limit reached

@matthiasn, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 30 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: b3f6afdd-4669-4872-b38d-957eef330b83

📥 Commits

Reviewing files that changed from the base of the PR and between ff66a70 and 862bd67.

📒 Files selected for processing (8)
  • CHANGELOG.md
  • flatpak/com.matthiasn.lotti.metainfo.xml
  • lib/features/design_system/README.md
  • lib/features/design_system/components/motion/size_fade_collapse.dart
  • lib/features/tasks/README.md
  • lib/features/tasks/ui/checklists/checklist_item_row_state.dart
  • test/features/design_system/components/motion/size_fade_collapse_test.dart
  • test/features/tasks/ui/checklists/checklist_item_row_test.dart
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/checklist_animation

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@codecov

codecov Bot commented Jul 25, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 99.15%. Comparing base (ff66a70) to head (862bd67).

Additional details and impacted files
@@            Coverage Diff             @@
##             main    #3595      +/-   ##
==========================================
+ Coverage   94.37%   99.15%   +4.78%     
==========================================
  Files        1783     1784       +1     
  Lines      130926   130978      +52     
==========================================
+ Hits       123559   129872    +6313     
+ Misses       7367     1106    -6261     
Flag Coverage Δ
glados 14.07% <0.00%> (-0.01%) ⬇️
standard 98.90% <100.00%> (+5.55%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@matthiasn
matthiasn merged commit 4acf2c2 into main Jul 25, 2026
27 of 28 checks passed
@matthiasn
matthiasn deleted the fix/checklist_animation branch July 25, 2026 21:07
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant