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
+160
View File
@@ -0,0 +1,160 @@
import 'package:clide/kernel/kernel.dart';
import 'package:clide/widgets/src/clide_icon.dart';
import 'package:clide/widgets/src/clide_tappable.dart';
import 'package:clide/widgets/src/clide_text.dart';
import 'package:clide/widgets/src/icons/phosphor.dart';
import 'package:clide/widgets/src/spacing.dart';
import 'package:clide/widgets/src/typography.dart';
import 'package:flutter/widgets.dart';
/// Maps a [ToastSeverity] to its status token + leading glyph. Kept here
/// (render layer) so the [ToastService] stays theme-free.
({Color color, ClideIconPainter icon}) _styleFor(ToastSeverity s, SurfaceTokens t) {
switch (s) {
case ToastSeverity.success:
return (color: t.statusSuccess, icon: PhosphorIcons.checkCircle);
case ToastSeverity.warning:
return (color: t.statusWarning, icon: PhosphorIcons.warningCircle);
case ToastSeverity.error:
return (color: t.statusError, icon: PhosphorIcons.warningCircle);
case ToastSeverity.info:
return (color: t.statusInfo, icon: PhosphorIcons.circlesFour);
}
}
/// A single toast card (T-50). Severity-colored leading accent + glyph, the
/// message, and a dismiss affordance. Slides + fades in on mount; clipped to
/// a bounded width. No Material — a plain themed container (D-7).
class ClideToast extends StatefulWidget {
const ClideToast({super.key, required this.entry, required this.onDismiss});
final ToastEntry entry;
final VoidCallback onDismiss;
@override
State<ClideToast> createState() => _ClideToastState();
}
class _ClideToastState extends State<ClideToast> {
bool _shown = false;
@override
void initState() {
super.initState();
// Flip after the first frame so the implicit animations run once, then
// settle (no perpetual ticker — keeps widget tests deterministic).
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) setState(() => _shown = true);
});
}
@override
Widget build(BuildContext context) {
final tokens = ClideTheme.of(context).surface;
final style = _styleFor(widget.entry.severity, tokens);
return AnimatedSlide(
offset: _shown ? Offset.zero : const Offset(0.25, 0),
duration: const Duration(milliseconds: 200),
curve: Curves.easeOut,
child: AnimatedOpacity(
opacity: _shown ? 1 : 0,
duration: const Duration(milliseconds: 200),
child: Semantics(
container: true,
liveRegion: true,
label: widget.entry.message,
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 360),
child: Container(
decoration: BoxDecoration(
color: tokens.dropdownBackground,
border: Border(left: BorderSide(color: style.color, width: 3)),
borderRadius: BorderRadius.circular(4),
boxShadow: [BoxShadow(color: tokens.shadowAmbient, blurRadius: 8, offset: const Offset(0, 2))],
),
padding: const EdgeInsets.fromLTRB(clideInsetText, clideInsetStandard, clideInsetStandard, clideInsetStandard),
child: Row(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ClideIcon(style.icon, size: clideIconStandard, color: style.color),
const SizedBox(width: clideGapStandard),
Flexible(child: ClideText(widget.entry.message, fontSize: clideFontBody, maxLines: 3, overflow: TextOverflow.ellipsis)),
const SizedBox(width: clideGapStandard),
Semantics(
button: true,
label: 'Dismiss notification',
child: ClideTappable(
onTap: widget.onDismiss,
builder: (ctx, hovered, _) => ClideIcon(
PhosphorIcons.xMark,
size: clideIconStandard,
color: hovered ? tokens.globalForeground : tokens.globalTextMuted,
),
),
),
],
),
),
),
),
),
);
}
}
/// Renders the live [ToastService] queue anchored bottom-right (T-50).
/// Mounted as a child of the app-root [Stack] (alongside the palette /
/// quick-open overlays). Occupies only its corner — non-toast space stays
/// interactive. Newest toast sits at the bottom.
class ToastOverlay extends StatefulWidget {
const ToastOverlay({super.key});
@override
State<ToastOverlay> createState() => _ToastOverlayState();
}
class _ToastOverlayState extends State<ToastOverlay> {
ToastService? _toast;
@override
void didChangeDependencies() {
super.didChangeDependencies();
final next = ClideKernel.of(context).toast;
if (!identical(_toast, next)) {
_toast?.removeListener(_onChanged);
_toast = next;
_toast!.addListener(_onChanged);
}
}
void _onChanged() {
if (mounted) setState(() {});
}
@override
void dispose() {
_toast?.removeListener(_onChanged);
super.dispose();
}
@override
Widget build(BuildContext context) {
final entries = _toast?.entries ?? const <ToastEntry>[];
if (entries.isEmpty) return const SizedBox.shrink();
return Positioned(
right: clideGapMajor,
bottom: clideGapMajor,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.end,
children: [
for (final e in entries) ...[
ClideToast(key: ValueKey('toast.${e.id}'), entry: e, onDismiss: () => _toast?.dismiss(e.id)),
const SizedBox(height: clideGapStandard),
],
],
),
);
}
}