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
22 changed files with 470 additions and 7 deletions

File diff suppressed because one or more lines are too long

View file

@ -340,6 +340,8 @@
"weekStartLanguage": "Wie in der Sprache",
"resetSubTasksTitle": "Unteraufgaben zurücksetzen",
"resetSubTasksHint": "Wieder öffnen, wenn eine wiederkehrende Aufgabe erledigt ist",
"completionSoundTitle": "Ton beim Erledigen",
"completionSoundHint": "Ein kurzer Klang, wenn du eine Aufgabe erledigst",
"automaticReminderTitle": "Automatische Erinnerung",
"automaticReminderHint": "Für eine Aufgabe mit Uhrzeit, wenn sie keine eigene Erinnerung hat",
"automaticReminderNone": "Keine",

View file

@ -961,6 +961,14 @@
"@resetSubTasksHint": {
"description": "Settings of tasks and reminders (ADR 0033, D4)."
},
"completionSoundTitle": "Sound when done",
"@completionSoundTitle": {
"description": "Settings of tasks, on a desktop alone: whether a short chime plays as a task is marked done (decided 10 Nov 2026)."
},
"completionSoundHint": "A short chime as a task is marked done",
"@completionSoundHint": {
"description": "Explains completionSoundTitle."
},
"automaticReminderTitle": "Automatic reminder",
"@automaticReminderTitle": {
"description": "Settings of tasks and reminders (ADR 0033, D4)."

View file

@ -2140,6 +2140,18 @@ abstract class AppLocalizations {
/// **'Open them again when a recurring task is done'**
String get resetSubTasksHint;
/// Settings of tasks, on a desktop alone: whether a short chime plays as a task is marked done (decided 10 Nov 2026).
///
/// In en, this message translates to:
/// **'Sound when done'**
String get completionSoundTitle;
/// Explains completionSoundTitle.
///
/// In en, this message translates to:
/// **'A short chime as a task is marked done'**
String get completionSoundHint;
/// Settings of tasks and reminders (ADR 0033, D4).
///
/// In en, this message translates to:

View file

@ -1306,6 +1306,13 @@ class AppLocalizationsDe extends AppLocalizations {
String get resetSubTasksHint =>
'Wieder öffnen, wenn eine wiederkehrende Aufgabe erledigt ist';
@override
String get completionSoundTitle => 'Ton beim Erledigen';
@override
String get completionSoundHint =>
'Ein kurzer Klang, wenn du eine Aufgabe erledigst';
@override
String get automaticReminderTitle => 'Automatische Erinnerung';

View file

@ -1305,6 +1305,12 @@ class AppLocalizationsEn extends AppLocalizations {
String get resetSubTasksHint =>
'Open them again when a recurring task is done';
@override
String get completionSoundTitle => 'Sound when done';
@override
String get completionSoundHint => 'A short chime as a task is marked done';
@override
String get automaticReminderTitle => 'Automatic reminder';

View file

@ -33,6 +33,7 @@ import 'package:halkyon_todo_flutter/src/feature/section/data/section_repository
import 'package:halkyon_todo_flutter/src/feature/section/data/sync_section_gateway.dart';
import 'package:halkyon_todo_flutter/src/feature/settings/data/settings_repository.dart';
import 'package:halkyon_todo_flutter/src/feature/settings/data/sync_settings_gateway.dart';
import 'package:halkyon_todo_flutter/src/feature/task/data/completion_feedback.dart';
import 'package:halkyon_todo_flutter/src/feature/task/data/sync_task_gateway.dart';
import 'package:halkyon_todo_flutter/src/feature/task/data/task_repository.dart';
import 'package:halkyon_todo_flutter/src/feature/weather/data/weather_gateway.dart';
@ -98,6 +99,10 @@ class TodoPerson(
/// given; else for this session.
final PaneWidths? _paneWidths,
/// What a task done feels like: the device's, where it is given; else
/// nothing.
final CompletionFeedback? _completionFeedback,
/// The projects whose sub-projects are folded away (decided 10 Nov
/// 2026): the person's folder's, where it is given; else for this session.
final FoldedProjects? _foldedProjects,
@ -118,6 +123,10 @@ class TodoPerson(
/// How wide the device keeps its side panes (ADR 0033, D33).
late final PaneWidths paneWidths = _paneWidths ?? PaneWidths();
/// A tick or a chime as a task is done (decided 10 Nov 2026).
late final CompletionFeedback completionFeedback =
_completionFeedback ?? CompletionFeedback();
/// The projects folded in Browse and the sidebar, kept on the device.
late final FoldedProjects foldedProjects =
_foldedProjects ?? FoldedProjects();
@ -364,6 +373,7 @@ class TodoDependencies({
locationReminders: device.locationReminders,
locationAccess: device.locationAccess,
paneWidths: device.paneWidths,
completionFeedback: device.completionFeedback,
onClosed: closed.complete,
);
} on Object {
@ -405,6 +415,7 @@ Future<TodoPerson> _openPersonData(
required LocationReminderGateway locationReminders,
required LocationReminderAccess locationAccess,
required PaneWidths paneWidths,
required CompletionFeedback completionFeedback,
required void Function() onClosed,
}) async {
final db = await client.createSyncSession(
@ -471,6 +482,7 @@ Future<TodoPerson> _openPersonData(
locationReminders: locationReminders,
locationAccess: locationAccess,
paneWidths: paneWidths,
completionFeedback: completionFeedback,
foldedProjects: await FoldedProjects.open(
File(p.join(folder.path, 'folded_projects.json')),
),
@ -522,6 +534,9 @@ class TodoDevice({
/// How wide it keeps its side panes (D33).
required final PaneWidths paneWidths,
/// What a task done feels like, the sound turned off or not.
required final CompletionFeedback completionFeedback,
}) {
/// Whether the device may show reminders, asked once an app start,
/// whoever is signed in (D31).
@ -542,6 +557,9 @@ class TodoDevice({
appBuild: int.tryParse(package.buildNumber),
appVersion: package.version,
paneWidths: await PaneWidths.open(File(p.join(support, 'panes.json'))),
completionFeedback: await CompletionFeedback.forDevice(
File(p.join(support, 'completion_feedback.json')),
),
reminders: await _reminders(),
locationReminders: Platform.isAndroid
? const AndroidLocationReminders()

View file

@ -0,0 +1,112 @@
import 'dart:convert';
import 'dart:developer';
import 'dart:io';
import 'package:audioplayers/audioplayers.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
/// What a task done feels like (decided by the developer, 10 Nov 2026): a
/// light haptic tick on a phone, a short quiet chime of Todo's own on a
/// desktop ([sounds]), which Settings turns off there. The device's matter,
/// whoever is signed in, kept in [_file] where there is one; neither sealed
/// nor synced.
class CompletionFeedback({
final File? _file,
/// Whether this device chimes rather than ticks: a desktop's.
final bool sounds = false,
/// The tick; nothing in tests that don't say.
final Future<void> Function() _tick = _nothing,
/// The chime; nothing in tests that don't say.
final Future<void> Function() _chime = _nothing,
}) extends ChangeNotifier {
var _soundOn = true;
/// Opens what [file] holds; the sound on where it can't be read.
static Future<CompletionFeedback> open(
File file, {
required bool sounds,
Future<void> Function() tick = _nothing,
Future<void> Function() chime = _nothing,
}) async {
final feedback = CompletionFeedback(
file: file,
sounds: sounds,
tick: tick,
chime: chime,
);
try {
if (file.existsSync()) {
final json = jsonDecode(await file.readAsString());
if (json is Map<String, Object?> && json['sound'] is bool) {
feedback._soundOn = json['sound']! as bool;
}
}
} on Object catch (error) {
log('Completion sound not read: ${error.runtimeType}', name: 'Tasks');
}
return feedback;
}
/// The device's own: a tick on Android and iOS, else the chime from
/// `assets/sounds/task_done.wav`.
static Future<CompletionFeedback> forDevice(File file) {
final phone = Platform.isAndroid || Platform.isIOS;
return open(
file,
sounds: !phone,
tick: HapticFeedback.lightImpact,
chime: phone ? _nothing : _AssetChime().play,
);
}
/// Whether Settings offers turning the sound off: on a desktop alone.
bool get offersSound => sounds;
/// Whether a desktop chimes; on at first.
bool get soundOn => _soundOn;
/// Turns the chime [on] or off, and keeps it.
Future<void> setSoundOn({required bool on}) async {
if (_soundOn == on) {
return;
}
_soundOn = on;
notifyListeners();
try {
await _file?.writeAsString(jsonEncode({'sound': on}));
} on Object catch (error) {
log('Completion sound not kept: ${error.runtimeType}', name: 'Tasks');
}
}
/// A task, or several at once, was just marked done: once whatever their
/// number. Never throws: a device that can't play loses only the sound.
Future<void> completed() async {
try {
if (!sounds) {
await _tick();
} else if (_soundOn) {
await _chime();
}
} on Object catch (error) {
log('No completion feedback: ${error.runtimeType}', name: 'Tasks');
}
}
}
Future<void> _nothing() async {}
/// The chime on `audioplayers`, one player made at the first, kept for the
/// app's life.
class _AssetChime() {
late final _player = AudioPlayer();
Future<void> play() async {
await _player.stop();
await _player.play(AssetSource('sounds/task_done.wav'));
}
}

View file

@ -355,6 +355,12 @@ class _SelectionActions(
final context = _context;
final tasks = _tasks;
final chosen = _chosen.where((task) => !task.content.completed).toList();
if (chosen.isNotEmpty) {
// Once for them all.
unawaited(
PersonScope.of<TodoPerson>(context).completionFeedback.completed(),
);
}
final reopen = context.read<TodoCubit>().state.settings.tasks.resetSubTasks;
final now = clock.now();
final before = await _write(

View file

@ -386,9 +386,14 @@ class const TaskCheck({required final OpenTask task, super.key})
color: color,
),
onPressed: () async {
final tasks = PersonScope.of<TodoPerson>(context).tasks;
final person = PersonScope.of<TodoPerson>(context);
final tasks = person.tasks;
final stored = StoredTask(task.id, content);
final done = !content.completed;
if (done) {
// Felt as the tap lands, not once it is written.
unawaited(person.completionFeedback.completed());
}
final reopen = context
.read<TodoCubit>()
.state

View file

@ -75,11 +75,37 @@ class const TaskSettingsCard({super.key}) extends StatelessWidget {
settings.copyWith(tasks: tasks.copyWith(resetSubTasks: on)),
),
),
const _CompletionSound(),
],
);
}
}
/// Whether a desktop chimes as a task is done (decided 10 Nov 2026): the
/// device's, whoever is signed in; nothing on a phone, which ticks.
class const _CompletionSound() extends StatelessWidget {
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final feedback = PersonScope.of<TodoPerson>(context).completionFeedback;
if (!feedback.offersSound) {
return const SizedBox.shrink();
}
return ListenableBuilder(
listenable: feedback,
builder: (context, _) => SwitchListTile(
secondary: const Icon(LucideIcons.volume2),
title: ExplainedTitle(
l10n.completionSoundTitle,
help: l10n.completionSoundHint,
),
value: feedback.soundOn,
onChanged: (on) => feedback.setSoundOn(on: on),
),
);
}
}
/// Settings' *Reminders* (D4): the automatic reminder a task given a time
/// gets where it has none of its own (none at first), and how long a
/// reminder is snoozed for from its notification; above them, while

View file

@ -6,6 +6,7 @@
#include "generated_plugin_registrant.h"
#include <audioplayers_linux/audioplayers_linux_plugin.h>
#include <desktop_webview_window/desktop_webview_window_plugin.h>
#include <emoji_picker_flutter/emoji_picker_flutter_plugin.h>
#include <file_selector_linux/file_selector_plugin.h>
@ -14,6 +15,9 @@
#include <window_to_front/window_to_front_plugin.h>
void fl_register_plugins(FlPluginRegistry* registry) {
g_autoptr(FlPluginRegistrar) audioplayers_linux_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "AudioplayersLinuxPlugin");
audioplayers_linux_plugin_register_with_registrar(audioplayers_linux_registrar);
g_autoptr(FlPluginRegistrar) desktop_webview_window_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "DesktopWebviewWindowPlugin");
desktop_webview_window_plugin_register_with_registrar(desktop_webview_window_registrar);

View file

@ -3,6 +3,7 @@
#
list(APPEND FLUTTER_PLUGIN_LIST
audioplayers_linux
desktop_webview_window
emoji_picker_flutter
file_selector_linux

View file

@ -5,6 +5,7 @@
import FlutterMacOS
import Foundation
import audioplayers_darwin
import connectivity_plus
import desktop_webview_window
import device_info_plus
@ -23,6 +24,7 @@ import url_launcher_macos
import window_to_front
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
AudioplayersDarwinPlugin.register(with: registry.registrar(forPlugin: "AudioplayersDarwinPlugin"))
ConnectivityPlusPlugin.register(with: registry.registrar(forPlugin: "ConnectivityPlusPlugin"))
DesktopWebviewWindowPlugin.register(with: registry.registrar(forPlugin: "DesktopWebviewWindowPlugin"))
DeviceInfoPlusMacosPlugin.register(with: registry.registrar(forPlugin: "DeviceInfoPlusMacosPlugin"))

View file

@ -13,6 +13,9 @@ environment:
resolution: workspace
dependencies:
# The chime of a task done on a desktop (decided 10 Nov 2026), as Learn's
# sounds: GStreamer on Linux.
audioplayers: ^6.8.1
clock: ^1.1.3
# Reactions on comments (ADR 0033, D36): every emoji, searched, recents on
# the device.
@ -101,6 +104,9 @@ flutter:
# (<version>.<language>.txt), drafted by tool/release.dart and edited
# (Train's ADR 0030, D61): halkyon_app's What's new shows them.
- assets/whats_new/
# The chime of a task done on a desktop, Todo's own, synthesized by
# ../tool/completion_sound.dart.
- assets/sounds/task_done.wav
# The app's icon, Lucide's list-todo (ADR 0031), and Lucide's licence for the
# licence list (the repository's tool/app_icons.py writes both).
- assets/icon/halkyon_todo_icon.png

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);

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);
});
});
}

View file

@ -6,6 +6,7 @@
#include "generated_plugin_registrant.h"
#include <audioplayers_windows/audioplayers_windows_plugin.h>
#include <connectivity_plus/connectivity_plus_windows_plugin.h>
#include <desktop_webview_window/desktop_webview_window_plugin.h>
#include <emoji_picker_flutter/emoji_picker_flutter_plugin_c_api.h>
@ -16,6 +17,8 @@
#include <window_to_front/window_to_front_plugin.h>
void RegisterPlugins(flutter::PluginRegistry* registry) {
AudioplayersWindowsPluginRegisterWithRegistrar(
registry->GetRegistrarForPlugin("AudioplayersWindowsPlugin"));
ConnectivityPlusWindowsPluginRegisterWithRegistrar(
registry->GetRegistrarForPlugin("ConnectivityPlusWindowsPlugin"));
DesktopWebviewWindowPluginRegisterWithRegistrar(

View file

@ -3,6 +3,7 @@
#
list(APPEND FLUTTER_PLUGIN_LIST
audioplayers_windows
connectivity_plus
desktop_webview_window
emoji_picker_flutter

Some files were not shown because too many files have changed in this diff Show more