Restructure step 2: Status as one computed level, the Notifier's real policy, system notifications
ci / core (push) Successful in 28s
ci / server (push) Successful in 1m11s
ci / updater-windows (push) Failing after 6m16s

- Status: computed from connection, presence, hold, relay, player facts;
  no setters (health writes and HealthStatus gone); pill, strip and
  source line read it; transitions go to the Notifier.
- Notifier: noticeFor(event, context) with self presence, alone, window
  activity, playing and prefs; toasts for joins/leaves/returns/swaps/
  Up next adds (with picture)/problems/chat, the rest logged; while
  sitting out only chat, nudges, starting, Up next (+ joins if chosen);
  alone no social notices; replayed lines logged; owns its toast timer.
- System notifications (flutter_local_notifications: D-Bus on Linux,
  WinRT on Windows) when the window isn't focused; a click focuses it.
- Toasts sit above the control bar and move with it.
- Versions: each member's in the members card and avatar tooltip,
  the server's in the card header; the strip no longer shows it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
foxyzandClaude Opus 5.5 committed 2026-10-15 22:11:08 +03:00
1 parent 130c934b9b
commit 629be0d9ed
41 files changed
+2114 -741

No files matched your search

+87 -38
View File
@@ -5,29 +5,29 @@ import 'package:signals/signals_flutter.dart';
import '../session/session_coordinator.dart' show ToastItem;
import '../state/session_state.dart';
import '../state/settings.dart';
import 'avatar.dart';
import 'motion.dart';
/// One toast on screen: who (null = the app itself), what, and whether it
/// is on its way out.
/// One toast on screen: who (null = the app itself), what, a picture
/// (an Up next item's), and whether it is on its way out.
class ShellToast {
final int id;
final AvatarInfo? who;
final IconData? icon;
final String text;
final bool chat;
final String thumb;
bool leaving = false;
ShellToast({required this.id, required this.text, this.who, this.icon, this.chat = false});
ShellToast({required this.id, required this.text, this.who, this.icon, this.chat = false, this.thumb = ''});
}
/// Splits a coordinator toast into who and what: feed and chat toasts
/// name their [ToastItem.actor] and start with it ("Bob joined", "Bob:
/// hi"); the rest are the app's.
/// Splits a Notifier toast into who and what: feed and chat toasts name
/// their [ToastItem.actor] and start with it ("Bob joined", "Bob: hi");
/// the rest are the app's.
ShellToast shellToastFor(ToastItem t, AvatarInfo Function(String name) avatar) {
final n = t.actor;
if (n == null || n.isEmpty) return ShellToast(id: t.id, text: t.text, icon: Icons.info_outline);
if (t.text.startsWith('$n: ')) {
if (n == null || n.isEmpty) return ShellToast(id: t.id, text: t.text, icon: Icons.info_outline, thumb: t.thumb);
if (t.chat && t.text.startsWith('$n: ')) {
return ShellToast(id: t.id, who: avatar(n), text: t.text.substring(n.length + 2), chat: true);
}
final rest = t.text.startsWith('$n ') ? t.text.substring(n.length + 1) : t.text;
@@ -35,24 +35,30 @@ ShellToast shellToastFor(ToastItem t, AvatarInfo Function(String name) avatar) {
? Icons.login
: rest.startsWith('left')
? Icons.logout
: rest.startsWith('sat you out')
? Icons.bedtime
: rest.startsWith('nudged')
? Icons.notifications_active_outlined
: rest.startsWith('changed')
? Icons.swap_horiz
: Icons.info_outline;
return ShellToast(id: t.id, who: avatar(n), text: rest, icon: icon);
: rest.startsWith('lost connection')
? Icons.wifi_off_rounded
: rest.startsWith('is back')
? Icons.wifi_rounded
: rest.startsWith('sat you out')
? Icons.bedtime
: rest.startsWith('nudged')
? Icons.notifications_active_outlined
: rest.startsWith('changed')
? Icons.swap_horiz
: rest.startsWith('added')
? Icons.playlist_add_rounded
: Icons.info_outline;
return ShellToast(id: t.id, who: avatar(n), text: rest, icon: icon, thumb: t.thumb);
}
/// The coordinator's toasts as cards with avatars, bottom-left: each
/// slides in, stays [life], then slides out and collapses its gap. A click
/// on a chat toast opens the chat ([onOpenChat]); the rest let clicks
/// through. Chat toasts follow [AppSettings.chatPopups].
/// The Notifier's toasts as cards with avatars, bottom-left: each slides
/// in, stays while the Notifier keeps it (it drops it after its time),
/// then slides out and collapses its gap. A click on a chat toast opens
/// the chat ([onOpenChat]); the rest let clicks through. What pops up at
/// all (chat pop-ups, sitting out, alone) is the Notifier's policy.
class PartyToasts extends StatefulWidget {
final Duration life;
final VoidCallback? onOpenChat;
const PartyToasts({super.key, this.life = const Duration(seconds: 5), this.onOpenChat});
const PartyToasts({super.key, this.onOpenChat});
@override
State<PartyToasts> createState() => _PartyToastsState();
@@ -72,33 +78,40 @@ class _PartyToastsState extends State<PartyToasts> {
_seen = {for (final t in coord.toasts.peek()) t.id};
_stop = effect(() {
final items = coord.toasts.value;
final ids = {for (final t in items) t.id};
final fresh = [for (final t in items) if (!_seen.contains(t.id)) t];
_seen = {for (final t in items) t.id};
if (fresh.isEmpty) return;
final gone = _seen.difference(ids);
_seen = ids;
if (fresh.isEmpty && gone.isEmpty) return;
// Only new toasts re-run this, not the party they mention.
untracked(() {
final playing = coord.serverState.value?.transport == 'playing';
final popups = AppSettings.instance.chatPopups.value;
for (final t in fresh) {
final toast = shellToastFor(t, (n) => avatarForName(n, coord, t.actorId));
// Chat per Settings → Notifications (it is in the chat either way).
if (toast.chat && !popups.shows(playing: playing)) continue;
_add(toast);
_add(shellToastFor(t, (n) => avatarForName(n, coord, t.actorId)));
}
if (gone.isNotEmpty) _leave(gone);
});
});
}
void _add(ShellToast t) {
// Not from inside the effect's build-time notification.
scheduleMicrotask(() {
if (mounted) setState(() => _shown.add(t));
});
}
void _leave(Set<int> ids) {
scheduleMicrotask(() {
if (!mounted) return;
setState(() => _shown.add(t));
_timers.add(Timer(widget.life, () {
if (mounted) setState(() => t.leaving = true);
}));
_timers.add(Timer(widget.life + easeTime, () {
if (mounted) setState(() => _shown.remove(t));
final leaving = [for (final t in _shown) if (ids.contains(t.id) && !t.leaving) t];
if (leaving.isEmpty) return;
setState(() {
for (final t in leaving) {
t.leaving = true;
}
});
_timers.add(Timer(easeTime, () {
if (mounted) setState(() => _shown.removeWhere(leaving.contains));
}));
});
}
@@ -114,7 +127,7 @@ class _PartyToastsState extends State<PartyToasts> {
void _open(ShellToast t) {
widget.onOpenChat?.call();
setState(() => t.leaving = true);
SessionState.instance.notifier.dismiss(t.id);
}
@override
@@ -206,6 +219,8 @@ class ToastCard extends StatelessWidget {
children: [
if (who != null)
StatusAvatar(info: who, size: 38, badges: false)
else if (toast.thumb.isNotEmpty)
_Thumb(hash: toast.thumb)
else
CircleAvatar(
radius: 19,
@@ -237,6 +252,8 @@ class ToastCard extends StatelessWidget {
],
Text(
toast.text,
maxLines: 3,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: toast.chat ? 15 : 14,
color: toast.chat || who == null ? scheme.onSurface : scheme.onSurfaceVariant,
@@ -245,6 +262,10 @@ class ToastCard extends StatelessWidget {
],
),
),
if (who != null && toast.thumb.isNotEmpty) ...[
const SizedBox(width: 12),
_Thumb(hash: toast.thumb),
],
],
),
),
@@ -252,3 +273,31 @@ class ToastCard extends StatelessWidget {
);
}
}
/// An Up next item's picture (16:9, from the queue's picture cache);
/// a blank until it is fetched.
class _Thumb extends StatelessWidget {
final String hash;
const _Thumb({required this.hash});
@override
Widget build(BuildContext context) {
final thumbs = SessionState.instance.coordinator.queue.thumbs;
return ClipRRect(
key: const ValueKey('toast-thumb'),
borderRadius: BorderRadius.circular(6),
child: SizedBox(
width: 68,
height: 38,
child: ListenableBuilder(
listenable: thumbs,
builder: (context, _) {
final bytes = thumbs.bytesFor(hash);
if (bytes == null) return ColoredBox(color: Theme.of(context).colorScheme.surfaceContainerHighest);
return Image.memory(bytes, fit: BoxFit.cover, gaplessPlayback: true);
},
),
),
);
}
}