fix(learn): a widget dropped while arranging Home stays where it was dropped, and its handle drags at once

Arranging Home kept no order of its own: a drop showed the old order
until the layout saved came back through the settings, once the
dashboard's changes settled 300 ms later, so the widget snapped back and
then jumped. _Arranging now holds the layout as arranged, shows each
change at once and saves it behind, taking a layout from the settings
only once its own changes are back (or, with none pending, as changed
on another device). The handle was an IconButton with an empty
onPressed inside the drag listener, whose tap and tooltip competed with
the drag; it is a plain icon now, named for screen readers.

Whats-New: Arranging Home's widgets, a widget stays where you drop it, and dragging starts at once.
Whats-New-De: Beim Anordnen der Widgets auf der Startseite bleibt ein Widget, wo du es ablegst, und das Ziehen beginnt sofort.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
Tizian 「ティツィアーン」 2026-11-04 21:23:13 +01:00
commit 12bc31b982
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
2 changed files with 93 additions and 9 deletions

View file

@ -328,15 +328,51 @@ class const _AllHidden({required final VoidCallback onArrange})
/// with a handle beside it to drag it elsewhere and an eye on its corner to
/// hide or show it; the widgets themselves don't react meanwhile. A widget
/// picked up lifts a little, with a tap the hand can feel.
///
/// Each change shows at once and is saved behind it: the layout saved
/// comes back only once the dashboard's changes settle, and a drop showed
/// the old order until then (the developer's review of 4 Nov 2026).
class const _Arranging({
required final HomeLayout layout,
required final Widget Function(HomeWidget widget) section,
required final ValueChanged<HomeLayout> onChanged,
}) extends StatelessWidget {
}) extends StatefulWidget {
@override
State<_Arranging> createState() => _ArrangingState();
}
class _ArrangingState() extends State<_Arranging> {
/// The layout as arranged here, ahead of the one saved.
late var _layout = widget.layout;
/// The layouts changed here and not yet back from the settings.
final _saving = <HomeLayout>[];
@override
void didUpdateWidget(_Arranging oldWidget) {
super.didUpdateWidget(oldWidget);
final saved = widget.layout;
if (_saving.contains(saved)) {
// One of ours, back: those before it are too.
_saving.removeRange(0, _saving.indexOf(saved) + 1);
} else if (_saving.isEmpty && saved != _layout) {
// Changed elsewhere, as on another device.
_layout = saved;
}
}
void _change(HomeLayout layout) {
setState(() => _layout = layout);
_saving.add(layout);
widget.onChanged(layout);
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final theme = Theme.of(context);
final layout = _layout;
final section = widget.section;
String label(HomeWidget widget) => switch (widget) {
HomeWidget.pomodoro => l10n.pomodoroTitle,
HomeWidget.goal => l10n.homeWidgetGoal,
@ -375,7 +411,7 @@ class const _Arranging({
final order = [...layout.order];
final moved = order.removeAt(from);
order.insert(to, moved);
onChanged(HomeLayout(order: order, hidden: layout.hidden));
_change(HomeLayout(order: order, hidden: layout.hidden));
},
children: [
for (final (i, widget) in layout.order.indexed)
@ -384,14 +420,21 @@ class const _Arranging({
padding: const EdgeInsets.only(bottom: 16),
child: Row(
children: [
// A plain handle: a button's tap and a tooltip's long press
// would compete with the drag, which then started late.
ReorderableDragStartListener(
index: i,
child: IconButton(
tooltip: l10n.homeDragToReorder,
onPressed: () {},
icon: Icon(
LucideIcons.gripVertical,
color: theme.colorScheme.onSurfaceVariant,
child: MouseRegion(
cursor: SystemMouseCursors.grab,
child: Semantics(
label: l10n.homeDragToReorder,
child: Padding(
padding: const EdgeInsets.all(12),
child: Icon(
LucideIcons.gripVertical,
color: theme.colorScheme.onSurfaceVariant,
),
),
),
),
),
@ -417,7 +460,7 @@ class const _Arranging({
child: _EyeBadge(
hidden: layout.hidden.contains(widget),
label: label(widget),
onTap: () => onChanged(
onTap: () => _change(
HomeLayout(
order: layout.order,
hidden: layout.hidden.contains(widget)

View file

@ -367,4 +367,45 @@ void main() {
await tester.pumpAndSettle();
expect(find.text('Arrange widgets'), findsOneWidget);
});
testWidgets('a widget dropped elsewhere stays where it was dropped at '
"once, rather than snapping back until it is saved (the developer's "
'review of 4 Nov 2026)', (tester) async {
await showHome(tester);
await tester.longPress(find.text('Set a study goal'));
await tester.pumpAndSettle();
double top(String name) =>
tester.getTopLeft(find.byTooltip('Hide $name')).dy;
final handles = find.byIcon(LucideIcons.gripVertical);
final first = tester.getTopLeft(handles.at(0)).dy;
final second = tester.getTopLeft(handles.at(1)).dy;
final names = [
for (final name in [
'Daily goal',
'Pick up learning',
'Pomodoro',
'PDF reading deadlines',
'Week overview',
'Activity heatmap',
])
if (find.byTooltip('Hide $name').evaluate().isNotEmpty) name,
]..sort((a, b) => top(a).compareTo(top(b)));
final moved = names.first;
// The handle takes the drag at once, without a tap of its own first.
final drag = await tester.startGesture(tester.getCenter(handles.at(0)));
for (var step = 0; step < 6; step++) {
await drag.moveBy(Offset(0, (second - first) * 1.5 / 6));
await tester.pump(const Duration(milliseconds: 50));
}
await drag.up();
// Dropped, well before the dashboard's changes settle (300 ms).
for (var frame = 0; frame < 12; frame++) {
await tester.pump(const Duration(milliseconds: 16));
}
expect(top(moved), greaterThan(top(names[1])), reason: 'moved down');
await tester.pumpAndSettle();
expect(top(moved), greaterThan(top(names[1])), reason: 'and stays');
});
}