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:
parent
1cc27ef0d6
commit
fd6647d752
10 changed files with 470 additions and 7 deletions
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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)."
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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'));
|
||||
}
|
||||
}
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue