feat(todo): a task done ticks on phones and chimes on desktops

The developer's request of 10 Nov 2026: completing a task should feel like
something. `CompletionFeedback`, held by `TodoDevice` and handed to each
person's `TodoPerson`, gives a light haptic tick on Android and iOS and, on
desktops, a short quiet chime of our own: two soft bell tones a fifth apart,
synthesized by `apps/todo/tool/completion_sound.dart` into
`assets/sounds/task_done.wav`, so it needs no one's licence. A task's check
and the selection's Complete call it once, however many tasks are done;
an undo is silent.

Settings' Tasks turns the chime off on a desktop, kept per device in
`<app support>/completion_feedback.json`; a phone is offered no switch. A
device that can't play loses only the sound. The player is `audioplayers`,
as Learn's sounds: on Linux it links GStreamer, so Todo's snap gains the
`audio-playback` plug and GStreamer's headers to build, as Learn's recipe has.

Whats-New: Completing a task now gives a light tap on phones and a short chime on computers, which Settings › Tasks turns off.
Whats-New-De: Eine erledigte Aufgabe gibt jetzt auf dem Handy ein leichtes Tippen und am Computer einen kurzen Klang, den du unter Einstellungen › Aufgaben ausschaltest.

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-10 23:04:59 +01:00
commit fd6647d752
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64

View file

@ -42,6 +42,7 @@ import 'package:halkyon_todo_flutter/src/feature/section/data/section_repository
import 'package:halkyon_todo_flutter/src/feature/section/model/section.dart';
import 'package:halkyon_todo_flutter/src/feature/settings/data/settings_repository.dart';
import 'package:halkyon_todo_flutter/src/feature/settings/model/todo_settings.dart';
import 'package:halkyon_todo_flutter/src/feature/task/data/completion_feedback.dart';
import 'package:halkyon_todo_flutter/src/feature/task/data/task_repository.dart';
import 'package:halkyon_todo_flutter/src/feature/task/model/repeat.dart';
import 'package:halkyon_todo_flutter/src/feature/task/model/task.dart';
@ -99,6 +100,12 @@ void main() {
/// The projects folded in Browse and the sidebar.
late FoldedProjects folded;
/// What the device did as tasks were done: `tick` or `chime`, in order.
late List<String> felt;
/// What a task done feels like: a phone's unless a test says.
late CompletionFeedback completion;
/// How many syncs ran.
late int syncs;
@ -133,6 +140,11 @@ void main() {
locationGateway = MemoryLocationGateway();
speech = FakeSpeechInput();
folded = FoldedProjects();
felt = [];
completion = CompletionFeedback(
tick: () async => felt.add('tick'),
chime: () async => felt.add('chime'),
);
store = MemoryInstanceStore();
looks = MemoryLookStore();
gateway = FakeAuthGateway();
@ -239,6 +251,7 @@ void main() {
locationReminders: locationGateway,
calendarFeeds: calendarFeeds,
foldedProjects: folded,
completionFeedback: completion,
);
},
),
@ -3917,13 +3930,16 @@ void main() {
await tester.pumpAndSettle();
expect(find.text('Water plants'), findsNothing);
expect(find.text('Task done'), findsOneWidget);
expect(felt, ['tick'], reason: "a phone's tick");
await tester.tap(find.text('Undo'));
await tester.pumpAndSettle();
expect(find.text('Water plants'), findsOneWidget);
expect(felt, ['tick'], reason: 'nothing on an undo');
await tester.tap(find.byTooltip('Mark as done'));
await tester.pumpAndSettle();
expect(felt, ['tick', 'tick']);
await tester.tap(find.byTooltip('Display'));
await tester.pumpAndSettle();
await tester.tap(find.text('Completed tasks'));
@ -6023,6 +6039,7 @@ void main() {
await tester.pumpAndSettle();
expect(find.text('Water plants'), findsNothing);
expect(find.text('2 tasks done'), findsOneWidget);
expect(felt, ['tick'], reason: 'once for both');
await tester.tap(find.text('Undo'));
await tester.pumpAndSettle();
expect(find.text('Water plants'), findsOneWidget);
@ -6708,6 +6725,58 @@ void main() {
);
});
testWidgets("a desktop's chime of a task done is turned off in Tasks, "
'for the device; a phone, which ticks, is offered none', (
tester,
) async {
await start(tester, usedBefore: true);
await openSettings(tester);
expect(find.text('Sound when done'), findsNothing);
await tester.pumpWidget(const SizedBox());
completion = CompletionFeedback(
sounds: true,
tick: () async => felt.add('tick'),
chime: () async => felt.add('chime'),
);
await start(tester, usedBefore: true);
await seed(
tester,
TaskContent(title: 'Water plants', projectId: inbox()),
);
await goTab(tester, 'Inbox');
await tester.tap(find.byTooltip('Mark as done'));
await tester.pumpAndSettle();
expect(felt, ['chime']);
// The undo's snack bar gone first, as it would be by the time.
ScaffoldMessenger.of(
tester.element(find.byType(SnackBar)),
).removeCurrentSnackBar();
await tester.pumpAndSettle();
await openSettings(tester);
await tapInSettings(tester, 'Sound when done');
expect(completion.soundOn, isFalse);
expect(
tester
.widget<SwitchListTile>(
find.widgetWithText(SwitchListTile, 'Sound when done'),
)
.value,
isFalse,
);
await tester.pageBack();
await tester.pumpAndSettle();
await seed(
tester,
TaskContent(title: 'Post letter', projectId: inbox()),
);
await goTab(tester, 'Inbox');
await tester.tap(find.byTooltip('Mark as done'));
await tester.pumpAndSettle();
expect(felt, ['chime'], reason: 'quiet now');
});
testWidgets('a task added with a time gets the automatic reminder; '
'reminders are snoozed for as long as set', (tester) async {
await start(tester, usedBefore: true);