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
22 changed files with 470 additions and 7 deletions
File diff suppressed because one or more lines are too long
BIN
apps/todo/halkyon_todo_flutter/assets/sounds/task_done.wav
Normal file
BIN
apps/todo/halkyon_todo_flutter/assets/sounds/task_done.wav
Normal file
Binary file not shown.
|
|
@ -340,6 +340,8 @@
|
||||||
"weekStartLanguage": "Wie in der Sprache",
|
"weekStartLanguage": "Wie in der Sprache",
|
||||||
"resetSubTasksTitle": "Unteraufgaben zurücksetzen",
|
"resetSubTasksTitle": "Unteraufgaben zurücksetzen",
|
||||||
"resetSubTasksHint": "Wieder öffnen, wenn eine wiederkehrende Aufgabe erledigt ist",
|
"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",
|
"automaticReminderTitle": "Automatische Erinnerung",
|
||||||
"automaticReminderHint": "Für eine Aufgabe mit Uhrzeit, wenn sie keine eigene Erinnerung hat",
|
"automaticReminderHint": "Für eine Aufgabe mit Uhrzeit, wenn sie keine eigene Erinnerung hat",
|
||||||
"automaticReminderNone": "Keine",
|
"automaticReminderNone": "Keine",
|
||||||
|
|
|
||||||
|
|
@ -961,6 +961,14 @@
|
||||||
"@resetSubTasksHint": {
|
"@resetSubTasksHint": {
|
||||||
"description": "Settings of tasks and reminders (ADR 0033, D4)."
|
"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": "Automatic reminder",
|
||||||
"@automaticReminderTitle": {
|
"@automaticReminderTitle": {
|
||||||
"description": "Settings of tasks and reminders (ADR 0033, D4)."
|
"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'**
|
/// **'Open them again when a recurring task is done'**
|
||||||
String get resetSubTasksHint;
|
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).
|
/// Settings of tasks and reminders (ADR 0033, D4).
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
|
|
|
||||||
|
|
@ -1306,6 +1306,13 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||||
String get resetSubTasksHint =>
|
String get resetSubTasksHint =>
|
||||||
'Wieder öffnen, wenn eine wiederkehrende Aufgabe erledigt ist';
|
'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
|
@override
|
||||||
String get automaticReminderTitle => 'Automatische Erinnerung';
|
String get automaticReminderTitle => 'Automatische Erinnerung';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1305,6 +1305,12 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||||
String get resetSubTasksHint =>
|
String get resetSubTasksHint =>
|
||||||
'Open them again when a recurring task is done';
|
'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
|
@override
|
||||||
String get automaticReminderTitle => 'Automatic reminder';
|
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/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/settings_repository.dart';
|
||||||
import 'package:halkyon_todo_flutter/src/feature/settings/data/sync_settings_gateway.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/sync_task_gateway.dart';
|
||||||
import 'package:halkyon_todo_flutter/src/feature/task/data/task_repository.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';
|
import 'package:halkyon_todo_flutter/src/feature/weather/data/weather_gateway.dart';
|
||||||
|
|
@ -98,6 +99,10 @@ class TodoPerson(
|
||||||
/// given; else for this session.
|
/// given; else for this session.
|
||||||
final PaneWidths? _paneWidths,
|
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
|
/// 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.
|
/// 2026): the person's folder's, where it is given; else for this session.
|
||||||
final FoldedProjects? _foldedProjects,
|
final FoldedProjects? _foldedProjects,
|
||||||
|
|
@ -118,6 +123,10 @@ class TodoPerson(
|
||||||
/// How wide the device keeps its side panes (ADR 0033, D33).
|
/// How wide the device keeps its side panes (ADR 0033, D33).
|
||||||
late final PaneWidths paneWidths = _paneWidths ?? PaneWidths();
|
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.
|
/// The projects folded in Browse and the sidebar, kept on the device.
|
||||||
late final FoldedProjects foldedProjects =
|
late final FoldedProjects foldedProjects =
|
||||||
_foldedProjects ?? FoldedProjects();
|
_foldedProjects ?? FoldedProjects();
|
||||||
|
|
@ -364,6 +373,7 @@ class TodoDependencies({
|
||||||
locationReminders: device.locationReminders,
|
locationReminders: device.locationReminders,
|
||||||
locationAccess: device.locationAccess,
|
locationAccess: device.locationAccess,
|
||||||
paneWidths: device.paneWidths,
|
paneWidths: device.paneWidths,
|
||||||
|
completionFeedback: device.completionFeedback,
|
||||||
onClosed: closed.complete,
|
onClosed: closed.complete,
|
||||||
);
|
);
|
||||||
} on Object {
|
} on Object {
|
||||||
|
|
@ -405,6 +415,7 @@ Future<TodoPerson> _openPersonData(
|
||||||
required LocationReminderGateway locationReminders,
|
required LocationReminderGateway locationReminders,
|
||||||
required LocationReminderAccess locationAccess,
|
required LocationReminderAccess locationAccess,
|
||||||
required PaneWidths paneWidths,
|
required PaneWidths paneWidths,
|
||||||
|
required CompletionFeedback completionFeedback,
|
||||||
required void Function() onClosed,
|
required void Function() onClosed,
|
||||||
}) async {
|
}) async {
|
||||||
final db = await client.createSyncSession(
|
final db = await client.createSyncSession(
|
||||||
|
|
@ -471,6 +482,7 @@ Future<TodoPerson> _openPersonData(
|
||||||
locationReminders: locationReminders,
|
locationReminders: locationReminders,
|
||||||
locationAccess: locationAccess,
|
locationAccess: locationAccess,
|
||||||
paneWidths: paneWidths,
|
paneWidths: paneWidths,
|
||||||
|
completionFeedback: completionFeedback,
|
||||||
foldedProjects: await FoldedProjects.open(
|
foldedProjects: await FoldedProjects.open(
|
||||||
File(p.join(folder.path, 'folded_projects.json')),
|
File(p.join(folder.path, 'folded_projects.json')),
|
||||||
),
|
),
|
||||||
|
|
@ -522,6 +534,9 @@ class TodoDevice({
|
||||||
|
|
||||||
/// How wide it keeps its side panes (D33).
|
/// How wide it keeps its side panes (D33).
|
||||||
required final PaneWidths paneWidths,
|
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,
|
/// Whether the device may show reminders, asked once an app start,
|
||||||
/// whoever is signed in (D31).
|
/// whoever is signed in (D31).
|
||||||
|
|
@ -542,6 +557,9 @@ class TodoDevice({
|
||||||
appBuild: int.tryParse(package.buildNumber),
|
appBuild: int.tryParse(package.buildNumber),
|
||||||
appVersion: package.version,
|
appVersion: package.version,
|
||||||
paneWidths: await PaneWidths.open(File(p.join(support, 'panes.json'))),
|
paneWidths: await PaneWidths.open(File(p.join(support, 'panes.json'))),
|
||||||
|
completionFeedback: await CompletionFeedback.forDevice(
|
||||||
|
File(p.join(support, 'completion_feedback.json')),
|
||||||
|
),
|
||||||
reminders: await _reminders(),
|
reminders: await _reminders(),
|
||||||
locationReminders: Platform.isAndroid
|
locationReminders: Platform.isAndroid
|
||||||
? const AndroidLocationReminders()
|
? 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 context = _context;
|
||||||
final tasks = _tasks;
|
final tasks = _tasks;
|
||||||
final chosen = _chosen.where((task) => !task.content.completed).toList();
|
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 reopen = context.read<TodoCubit>().state.settings.tasks.resetSubTasks;
|
||||||
final now = clock.now();
|
final now = clock.now();
|
||||||
final before = await _write(
|
final before = await _write(
|
||||||
|
|
|
||||||
|
|
@ -386,9 +386,14 @@ class const TaskCheck({required final OpenTask task, super.key})
|
||||||
color: color,
|
color: color,
|
||||||
),
|
),
|
||||||
onPressed: () async {
|
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 stored = StoredTask(task.id, content);
|
||||||
final done = !content.completed;
|
final done = !content.completed;
|
||||||
|
if (done) {
|
||||||
|
// Felt as the tap lands, not once it is written.
|
||||||
|
unawaited(person.completionFeedback.completed());
|
||||||
|
}
|
||||||
final reopen = context
|
final reopen = context
|
||||||
.read<TodoCubit>()
|
.read<TodoCubit>()
|
||||||
.state
|
.state
|
||||||
|
|
|
||||||
|
|
@ -75,11 +75,37 @@ class const TaskSettingsCard({super.key}) extends StatelessWidget {
|
||||||
settings.copyWith(tasks: tasks.copyWith(resetSubTasks: on)),
|
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
|
/// 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
|
/// 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
|
/// reminder is snoozed for from its notification; above them, while
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@
|
||||||
|
|
||||||
#include "generated_plugin_registrant.h"
|
#include "generated_plugin_registrant.h"
|
||||||
|
|
||||||
|
#include <audioplayers_linux/audioplayers_linux_plugin.h>
|
||||||
#include <desktop_webview_window/desktop_webview_window_plugin.h>
|
#include <desktop_webview_window/desktop_webview_window_plugin.h>
|
||||||
#include <emoji_picker_flutter/emoji_picker_flutter_plugin.h>
|
#include <emoji_picker_flutter/emoji_picker_flutter_plugin.h>
|
||||||
#include <file_selector_linux/file_selector_plugin.h>
|
#include <file_selector_linux/file_selector_plugin.h>
|
||||||
|
|
@ -14,6 +15,9 @@
|
||||||
#include <window_to_front/window_to_front_plugin.h>
|
#include <window_to_front/window_to_front_plugin.h>
|
||||||
|
|
||||||
void fl_register_plugins(FlPluginRegistry* registry) {
|
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 =
|
g_autoptr(FlPluginRegistrar) desktop_webview_window_registrar =
|
||||||
fl_plugin_registry_get_registrar_for_plugin(registry, "DesktopWebviewWindowPlugin");
|
fl_plugin_registry_get_registrar_for_plugin(registry, "DesktopWebviewWindowPlugin");
|
||||||
desktop_webview_window_plugin_register_with_registrar(desktop_webview_window_registrar);
|
desktop_webview_window_plugin_register_with_registrar(desktop_webview_window_registrar);
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@
|
||||||
#
|
#
|
||||||
|
|
||||||
list(APPEND FLUTTER_PLUGIN_LIST
|
list(APPEND FLUTTER_PLUGIN_LIST
|
||||||
|
audioplayers_linux
|
||||||
desktop_webview_window
|
desktop_webview_window
|
||||||
emoji_picker_flutter
|
emoji_picker_flutter
|
||||||
file_selector_linux
|
file_selector_linux
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@
|
||||||
import FlutterMacOS
|
import FlutterMacOS
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
|
import audioplayers_darwin
|
||||||
import connectivity_plus
|
import connectivity_plus
|
||||||
import desktop_webview_window
|
import desktop_webview_window
|
||||||
import device_info_plus
|
import device_info_plus
|
||||||
|
|
@ -23,6 +24,7 @@ import url_launcher_macos
|
||||||
import window_to_front
|
import window_to_front
|
||||||
|
|
||||||
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
||||||
|
AudioplayersDarwinPlugin.register(with: registry.registrar(forPlugin: "AudioplayersDarwinPlugin"))
|
||||||
ConnectivityPlusPlugin.register(with: registry.registrar(forPlugin: "ConnectivityPlusPlugin"))
|
ConnectivityPlusPlugin.register(with: registry.registrar(forPlugin: "ConnectivityPlusPlugin"))
|
||||||
DesktopWebviewWindowPlugin.register(with: registry.registrar(forPlugin: "DesktopWebviewWindowPlugin"))
|
DesktopWebviewWindowPlugin.register(with: registry.registrar(forPlugin: "DesktopWebviewWindowPlugin"))
|
||||||
DeviceInfoPlusMacosPlugin.register(with: registry.registrar(forPlugin: "DeviceInfoPlusMacosPlugin"))
|
DeviceInfoPlusMacosPlugin.register(with: registry.registrar(forPlugin: "DeviceInfoPlusMacosPlugin"))
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,9 @@ environment:
|
||||||
resolution: workspace
|
resolution: workspace
|
||||||
|
|
||||||
dependencies:
|
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
|
clock: ^1.1.3
|
||||||
# Reactions on comments (ADR 0033, D36): every emoji, searched, recents on
|
# Reactions on comments (ADR 0033, D36): every emoji, searched, recents on
|
||||||
# the device.
|
# the device.
|
||||||
|
|
@ -101,6 +104,9 @@ flutter:
|
||||||
# (<version>.<language>.txt), drafted by tool/release.dart and edited
|
# (<version>.<language>.txt), drafted by tool/release.dart and edited
|
||||||
# (Train's ADR 0030, D61): halkyon_app's What's new shows them.
|
# (Train's ADR 0030, D61): halkyon_app's What's new shows them.
|
||||||
- assets/whats_new/
|
- 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
|
# 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).
|
# licence list (the repository's tool/app_icons.py writes both).
|
||||||
- assets/icon/halkyon_todo_icon.png
|
- assets/icon/halkyon_todo_icon.png
|
||||||
|
|
|
||||||
|
|
@ -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/section/model/section.dart';
|
||||||
import 'package:halkyon_todo_flutter/src/feature/settings/data/settings_repository.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/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/data/task_repository.dart';
|
||||||
import 'package:halkyon_todo_flutter/src/feature/task/model/repeat.dart';
|
import 'package:halkyon_todo_flutter/src/feature/task/model/repeat.dart';
|
||||||
import 'package:halkyon_todo_flutter/src/feature/task/model/task.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.
|
/// The projects folded in Browse and the sidebar.
|
||||||
late FoldedProjects folded;
|
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.
|
/// How many syncs ran.
|
||||||
late int syncs;
|
late int syncs;
|
||||||
|
|
||||||
|
|
@ -133,6 +140,11 @@ void main() {
|
||||||
locationGateway = MemoryLocationGateway();
|
locationGateway = MemoryLocationGateway();
|
||||||
speech = FakeSpeechInput();
|
speech = FakeSpeechInput();
|
||||||
folded = FoldedProjects();
|
folded = FoldedProjects();
|
||||||
|
felt = [];
|
||||||
|
completion = CompletionFeedback(
|
||||||
|
tick: () async => felt.add('tick'),
|
||||||
|
chime: () async => felt.add('chime'),
|
||||||
|
);
|
||||||
store = MemoryInstanceStore();
|
store = MemoryInstanceStore();
|
||||||
looks = MemoryLookStore();
|
looks = MemoryLookStore();
|
||||||
gateway = FakeAuthGateway();
|
gateway = FakeAuthGateway();
|
||||||
|
|
@ -239,6 +251,7 @@ void main() {
|
||||||
locationReminders: locationGateway,
|
locationReminders: locationGateway,
|
||||||
calendarFeeds: calendarFeeds,
|
calendarFeeds: calendarFeeds,
|
||||||
foldedProjects: folded,
|
foldedProjects: folded,
|
||||||
|
completionFeedback: completion,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
|
@ -3917,13 +3930,16 @@ void main() {
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
expect(find.text('Water plants'), findsNothing);
|
expect(find.text('Water plants'), findsNothing);
|
||||||
expect(find.text('Task done'), findsOneWidget);
|
expect(find.text('Task done'), findsOneWidget);
|
||||||
|
expect(felt, ['tick'], reason: "a phone's tick");
|
||||||
|
|
||||||
await tester.tap(find.text('Undo'));
|
await tester.tap(find.text('Undo'));
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
expect(find.text('Water plants'), findsOneWidget);
|
expect(find.text('Water plants'), findsOneWidget);
|
||||||
|
expect(felt, ['tick'], reason: 'nothing on an undo');
|
||||||
|
|
||||||
await tester.tap(find.byTooltip('Mark as done'));
|
await tester.tap(find.byTooltip('Mark as done'));
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
expect(felt, ['tick', 'tick']);
|
||||||
await tester.tap(find.byTooltip('Display'));
|
await tester.tap(find.byTooltip('Display'));
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
await tester.tap(find.text('Completed tasks'));
|
await tester.tap(find.text('Completed tasks'));
|
||||||
|
|
@ -6023,6 +6039,7 @@ void main() {
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
expect(find.text('Water plants'), findsNothing);
|
expect(find.text('Water plants'), findsNothing);
|
||||||
expect(find.text('2 tasks done'), findsOneWidget);
|
expect(find.text('2 tasks done'), findsOneWidget);
|
||||||
|
expect(felt, ['tick'], reason: 'once for both');
|
||||||
await tester.tap(find.text('Undo'));
|
await tester.tap(find.text('Undo'));
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
expect(find.text('Water plants'), findsOneWidget);
|
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; '
|
testWidgets('a task added with a time gets the automatic reminder; '
|
||||||
'reminders are snoozed for as long as set', (tester) async {
|
'reminders are snoozed for as long as set', (tester) async {
|
||||||
await start(tester, usedBefore: true);
|
await start(tester, usedBefore: true);
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -6,6 +6,7 @@
|
||||||
|
|
||||||
#include "generated_plugin_registrant.h"
|
#include "generated_plugin_registrant.h"
|
||||||
|
|
||||||
|
#include <audioplayers_windows/audioplayers_windows_plugin.h>
|
||||||
#include <connectivity_plus/connectivity_plus_windows_plugin.h>
|
#include <connectivity_plus/connectivity_plus_windows_plugin.h>
|
||||||
#include <desktop_webview_window/desktop_webview_window_plugin.h>
|
#include <desktop_webview_window/desktop_webview_window_plugin.h>
|
||||||
#include <emoji_picker_flutter/emoji_picker_flutter_plugin_c_api.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>
|
#include <window_to_front/window_to_front_plugin.h>
|
||||||
|
|
||||||
void RegisterPlugins(flutter::PluginRegistry* registry) {
|
void RegisterPlugins(flutter::PluginRegistry* registry) {
|
||||||
|
AudioplayersWindowsPluginRegisterWithRegistrar(
|
||||||
|
registry->GetRegistrarForPlugin("AudioplayersWindowsPlugin"));
|
||||||
ConnectivityPlusWindowsPluginRegisterWithRegistrar(
|
ConnectivityPlusWindowsPluginRegisterWithRegistrar(
|
||||||
registry->GetRegistrarForPlugin("ConnectivityPlusWindowsPlugin"));
|
registry->GetRegistrarForPlugin("ConnectivityPlusWindowsPlugin"));
|
||||||
DesktopWebviewWindowPluginRegisterWithRegistrar(
|
DesktopWebviewWindowPluginRegisterWithRegistrar(
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@
|
||||||
#
|
#
|
||||||
|
|
||||||
list(APPEND FLUTTER_PLUGIN_LIST
|
list(APPEND FLUTTER_PLUGIN_LIST
|
||||||
|
audioplayers_windows
|
||||||
connectivity_plus
|
connectivity_plus
|
||||||
desktop_webview_window
|
desktop_webview_window
|
||||||
emoji_picker_flutter
|
emoji_picker_flutter
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue