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
2 changed files with 470 additions and 7 deletions
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
Loading…
Reference in a new issue