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

@ -0,0 +1,83 @@
import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_todo_flutter/src/feature/task/data/completion_feedback.dart';
import 'package:path/path.dart' as p;
void main() {
late int ticks;
late int chimes;
setUp(() {
ticks = 0;
chimes = 0;
});
Future<void> tick() async => ticks++;
Future<void> chime() async => chimes++;
test('a phone ticks once a task is done, and offers no sound', () async {
final feedback = CompletionFeedback(tick: tick, chime: chime);
await feedback.completed();
expect(feedback.offersSound, isFalse);
expect((ticks, chimes), (1, 0));
});
test('a desktop chimes, while its sound is on, and never ticks', () async {
final feedback = CompletionFeedback(
sounds: true,
tick: tick,
chime: chime,
);
expect(feedback.offersSound, isTrue);
expect(feedback.soundOn, isTrue, reason: 'on at first');
await feedback.completed();
expect((ticks, chimes), (0, 1));
await feedback.setSoundOn(on: false);
await feedback.completed();
expect((ticks, chimes), (0, 1));
});
test("a sound that can't play takes nothing with it", () async {
final feedback = CompletionFeedback(
sounds: true,
chime: () async => throw StateError('no GStreamer'),
);
await expectLater(feedback.completed(), completes);
});
group('kept on the device', () {
late Directory folder;
setUp(() async {
folder = await Directory.systemTemp.createTemp('completion');
addTearDown(() => folder.delete(recursive: true));
});
test('the sound turned off stays off when the app opens again', () async {
final file = File(p.join(folder.path, 'completion_feedback.json'));
final feedback = await CompletionFeedback.open(file, sounds: true);
var changes = 0;
feedback.addListener(() => changes++);
await feedback.setSoundOn(on: false);
expect(changes, 1);
final again = await CompletionFeedback.open(file, sounds: true);
expect(again.soundOn, isFalse);
});
test('a file that is no setting leaves the sound on', () async {
final file = File(p.join(folder.path, 'completion_feedback.json'));
await file.writeAsString('not json');
expect((await CompletionFeedback.open(file, sounds: true)).soundOn, true);
await file.writeAsString('[1]');
expect((await CompletionFeedback.open(file, sounds: true)).soundOn, true);
});
});
}