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

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