add toast notification system (T-50)

Non-modal operation-feedback toasts, bottom-right: a ClideToast card per
severity (success/warning/error/info), auto-dismiss (errors linger), queue
with a visible cap, slide+fade in, manual dismiss, live-region a11y.

ToastService is a MessageBus consumer — components raise a toast by publishing
to the 'toast' channel (publishToast helper), so emitters stay decoupled from
the UI. GitController's push/pull are the first emitters. ToastOverlay mounts
in the app-root Stack.

Also adds comprehensive GitController coverage: importing it for the toast
emitter test first pulled the whole file into the coverage denominator, so the
controller is now tested end to end (status/stage/commit/stash/push/pull).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-05 23:14:42 +02:00
co-authored by Claude Opus 4.8
parent 55ed3013e1
commit 1f09abcfd7
12 changed files with 747 additions and 4 deletions
+85
View File
@@ -0,0 +1,85 @@
/// Widget tests for ClideToast + ToastOverlay (T-50): render per severity,
/// manual dismiss, the live-region a11y contract, and the overlay reflecting
/// the ToastService queue. Toasts are shown sticky (Duration.zero) so no
/// auto-dismiss Timer is left pending at teardown.
library;
import 'package:clide/kernel/kernel.dart';
import 'package:clide/widgets/widgets.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter_test/flutter_test.dart';
import '../../helpers/kernel_fixture.dart';
void main() {
late KernelFixture f;
setUp(() async => f = await KernelFixture.create());
tearDown(() async => f.dispose());
Widget host(Widget child) => Directionality(
textDirection: TextDirection.ltr,
child: ClideKernel(
services: f.services,
child: ClideTheme(
controller: f.services.theme,
child: Align(alignment: Alignment.topLeft, child: child),
),
),
);
testWidgets('renders the message and calls onDismiss when the × is tapped', (tester) async {
var dismissed = false;
await tester.pumpWidget(host(ClideToast(
entry: const ToastEntry(id: 1, message: 'Pushed to origin/main', severity: ToastSeverity.success),
onDismiss: () => dismissed = true,
)));
await tester.pump(const Duration(milliseconds: 300)); // settle entrance
expect(find.text('Pushed to origin/main'), findsOneWidget);
// The dismiss × is the toast's only ClideTappable.
await tester.tap(find.byType(ClideTappable));
await tester.pump();
expect(dismissed, isTrue);
});
testWidgets('exposes the message as a live region (a11y)', (tester) async {
await tester.pumpWidget(host(ClideToast(
entry: const ToastEntry(id: 1, message: 'Heads up', severity: ToastSeverity.warning),
onDismiss: () {},
)));
await tester.pump(const Duration(milliseconds: 300));
// The message is wrapped in a live-region Semantics so screen readers
// announce it when it appears.
expect(
find.byWidgetPredicate((w) => w is Semantics && w.properties.liveRegion == true && w.properties.label == 'Heads up'),
findsOneWidget,
);
});
testWidgets('overlay renders a card per queued toast and dismiss removes one', (tester) async {
await tester.pumpWidget(host(const SizedBox(
width: 800,
height: 600,
child: Stack(children: [ToastOverlay()]),
)));
await tester.pump();
expect(find.byType(ClideToast), findsNothing);
f.services.toast.show('one', duration: Duration.zero);
f.services.toast.show('two', severity: ToastSeverity.error, duration: Duration.zero);
await tester.pump();
await tester.pump(const Duration(milliseconds: 300));
expect(find.byType(ClideToast), findsNWidgets(2));
expect(find.text('one'), findsOneWidget);
expect(find.text('two'), findsOneWidget);
// Dismissing via the service updates the overlay (tap-to-dismiss is
// covered by the ClideToast test above; here we assert reactivity).
f.services.toast.dismiss(f.services.toast.entries.first.id);
await tester.pump();
expect(find.byType(ClideToast), findsNWidgets(1));
expect(find.text('one'), findsNothing);
expect(find.text('two'), findsOneWidget);
});
}