import 'dart:async' show unawaited; import 'package:flutter/material.dart'; import '../l10n/generated/app_localizations.dart'; import '../services/channel_spacer.dart'; import '../services/link_trust_service.dart'; import '../services/snapshot_state_mapper.dart'; import '../services/ts3_server_link.dart'; import '../src/rust/api.dart' as rust; import 'bbcode_text.dart'; /// One chat/activity message shown in the chat hub. class ChatEntry { /// Construct a chat entry. const ChatEntry({ required this.senderId, required this.senderName, required this.message, required this.target, this.isSelf = false, this.timestamp, }); /// Sender client id. final BigInt senderId; /// Sender display name. final String senderName; /// Message body. final String message; /// Chat target. final rust.BridgeMessageTarget target; /// True when the local client sent this message. final bool isSelf; /// Local time when this message was received or sent. final DateTime? timestamp; /// True for direct-message targets. bool get isPrivate => target is rust.BridgeMessageTarget_Client; /// True for poke targets. bool get isPoke => target is rust.BridgeMessageTarget_Poke; } String chatTimeLabel(DateTime? timestamp) { if (timestamp == null) return '--:--:--'; String two(int value) => value.toString().padLeft(2, '0'); return '${two(timestamp.hour)}:${two(timestamp.minute)}:${two(timestamp.second)}'; } String pokeHistoryLine(AppL10n l10n, ChatEntry entry) { final time = chatTimeLabel(entry.timestamp); final peerName = entry.senderName.isNotEmpty ? entry.senderName : 'Unknown'; final message = entry.message.trim(); if (entry.isSelf) { if (message.isEmpty) { return l10n.pokeHistorySelfNoMessage(time, peerName); } return l10n.pokeHistorySelfWithMessage(time, peerName, message); } if (message.isEmpty) { return l10n.pokeHistoryIncomingNoMessage(time, peerName); } return l10n.pokeHistoryIncomingWithMessage(time, peerName, message); } /// Resolve the best chat target to show when opening the chat page. /// /// Returns null to show the chat hub. rust.BridgeMessageTarget? resolveInitialChatTarget({ required List messages, required BigInt? currentVoiceChannelId, }) { for (final message in messages.reversed) { if (message.isPrivate) return message.target; } if (currentVoiceChannelId != null) { return const rust.BridgeMessageTarget.channel(); } return null; } class ChatClientGroups { const ChatClientGroups({ required this.clientsByChannel, required this.ungroupedClients, }); final Map> clientsByChannel; final List ungroupedClients; } class ChatClientPickerGroup { const ChatClientPickerGroup({ required this.channelName, required this.clients, }); final String channelName; final List clients; } ChatClientGroups groupChatClientsForPicker(rust.BridgeSnapshot snapshot) { final ownId = snapshot.ownClientId; final clients = snapshot.clients .where((client) => client.id != ownId && !client.isServerQuery) .toList() ..sort((a, b) => a.name.compareTo(b.name)); final channelIds = {for (final channel in snapshot.channels) channel.id}; final byChannel = >{}; final ungrouped = []; for (final client in clients) { if (channelIds.contains(client.channel)) { byChannel.putIfAbsent(client.channel, () => []).add(client); } else { ungrouped.add(client); } } return ChatClientGroups( clientsByChannel: byChannel, ungroupedClients: ungrouped, ); } List filterChatClientPickerGroups({ required List channels, required Map> clientsByChannel, required List ungroupedClients, required String query, }) { final normalizedQuery = query.trim().toLowerCase(); bool matches(rust.BridgeClient client) { return normalizedQuery.isEmpty || client.name.toLowerCase().contains(normalizedQuery); } final ungrouped = ungroupedClients.where(matches).toList(); return [ for (final channel in channels) if (clientsByChannel.containsKey(channel.id)) ChatClientPickerGroup( channelName: channelSpacerLabel(channel.name), clients: clientsByChannel[channel.id]!.where(matches).toList(), ), if (ungrouped.isNotEmpty) ChatClientPickerGroup(channelName: 'Other', clients: ungrouped), ].where((group) => group.clients.isNotEmpty).toList(); } String chatTargetTitle( rust.BridgeMessageTarget target, { required String channelName, required String clientName, }) { switch (target) { case rust.BridgeMessageTarget_Server(): return 'Server Activity'; case rust.BridgeMessageTarget_Channel(): return channelName.isNotEmpty ? '# $channelName' : 'Channel'; case rust.BridgeMessageTarget_Client(): return clientName.isNotEmpty ? clientName : 'Direct Message'; case rust.BridgeMessageTarget_Poke(): return clientName.isNotEmpty ? 'Poke: $clientName' : 'Poke'; } } String chatEmptyTitle(rust.BridgeMessageTarget target) { switch (target) { case rust.BridgeMessageTarget_Server(): return 'No server activity yet'; case rust.BridgeMessageTarget_Channel(): return 'No channel messages yet'; case rust.BridgeMessageTarget_Client(): return 'No private messages yet'; case rust.BridgeMessageTarget_Poke(): return 'No pokes'; } } String chatEmptyBody( rust.BridgeMessageTarget target, { required String channelName, }) { switch (target) { case rust.BridgeMessageTarget_Server(): return 'Joins, leaves, and disconnects will appear here.'; case rust.BridgeMessageTarget_Channel(): if (channelName.isNotEmpty) { return 'Start the conversation in #$channelName.'; } return 'Join a channel to start chatting.'; case rust.BridgeMessageTarget_Client(): return 'Select a user to start a private chat.'; case rust.BridgeMessageTarget_Poke(): return 'Pokes will appear here when someone needs your attention.'; } } String chatInputPlaceholder( rust.BridgeMessageTarget target, { required String channelName, required String clientName, }) { switch (target) { case rust.BridgeMessageTarget_Server(): return 'Message server...'; case rust.BridgeMessageTarget_Channel(): return 'Message #$channelName...'; case rust.BridgeMessageTarget_Client(): return 'Message $clientName...'; case rust.BridgeMessageTarget_Poke(): return 'Poke message...'; } } bool canSendToChatTarget( rust.BridgeMessageTarget target, BigInt? currentChannelId, ) { switch (target) { case rust.BridgeMessageTarget_Channel(): return currentChannelId != null; default: return true; } } String? chatSendBlockedReason( rust.BridgeMessageTarget target, BigInt? currentChannelId, ) { switch (target) { case rust.BridgeMessageTarget_Channel(): if (currentChannelId == null) { return 'Join a channel to send messages.'; } return null; default: return null; } } /// Chat and activity hub page. class ChatPage extends StatefulWidget { /// Construct a chat page. const ChatPage({ super.key, required this.messages, required this.snapshot, this.initialTarget, this.initialClientName = '', this.onTs3ServerLink, }); /// Backing message list. The detail view appends self-sent messages. final List messages; /// Latest TeamSpeak snapshot. final rust.BridgeSnapshot snapshot; /// Non-null to open a specific target directly; null for hub. final rust.BridgeMessageTarget? initialTarget; /// Initial selected client name for direct-message or poke targets. final String initialClientName; /// Handle TeamSpeak server links embedded in chat messages. final Ts3ServerLinkHandler? onTs3ServerLink; @override State createState() => _ChatPageState(); } class _ChatPageState extends State { rust.BridgeMessageTarget? _detailTarget; String _selectedClientName = ''; @override void initState() { super.initState(); _detailTarget = widget.initialTarget; _selectedClientName = widget.initialClientName; } BigInt? get _currentChannelId { return ownClientSnapshotState(widget.snapshot)?.channelId; } @override Widget build(BuildContext context) { final currentChannelId = _currentChannelId; final channelName = snapshotChannelName(widget.snapshot, currentChannelId); if (_detailTarget != null) { return _ChatDetailView( target: _detailTarget!, clientName: _selectedClientName, snapshot: widget.snapshot, messages: widget.messages, currentChannelId: currentChannelId, channelName: channelName, onBack: () => setState(() => _detailTarget = null), onTs3ServerLink: widget.onTs3ServerLink, ); } return _ChatHub( snapshot: widget.snapshot, messages: widget.messages, currentChannelId: currentChannelId, channelName: channelName, onOpen: (t, {String name = ''}) { setState(() { _detailTarget = t; _selectedClientName = name; }); }, onPickClient: (fn) => _pickClient(fn), ); } void _pickClient(void Function(BigInt id, String name) cb) { final groups = groupChatClientsForPicker(widget.snapshot); showDialog( context: context, builder: (ctx) => _ClientPickerDialog( channels: widget.snapshot.channels, byChannel: groups.clientsByChannel, ungrouped: groups.ungroupedClients, onSelected: (c) { cb(c.id, c.name); Navigator.pop(ctx); }, ), ); } } class _ClientPickerDialog extends StatefulWidget { const _ClientPickerDialog({ required this.channels, required this.byChannel, required this.ungrouped, required this.onSelected, }); final List channels; final Map> byChannel; final List ungrouped; final void Function(rust.BridgeClient c) onSelected; @override State<_ClientPickerDialog> createState() => _ClientPickerDialogState(); } class _ClientPickerDialogState extends State<_ClientPickerDialog> { final _searchCtl = TextEditingController(); String _query = ''; @override void dispose() { _searchCtl.dispose(); super.dispose(); } @override Widget build(BuildContext context) { final groups = filterChatClientPickerGroups( channels: widget.channels, clientsByChannel: widget.byChannel, ungroupedClients: widget.ungrouped, query: _query, ); return Dialog( child: SizedBox( width: 360, height: 520, child: Column( children: [ Padding( padding: const EdgeInsets.fromLTRB(20, 20, 20, 8), child: TextField( controller: _searchCtl, decoration: const InputDecoration( hintText: 'Search clients...', prefixIcon: Icon(Icons.search), border: OutlineInputBorder(), isDense: true, ), onChanged: (v) => setState(() => _query = v), ), ), Expanded( child: ListView( children: [ for (final group in groups) _ChannelGroup( channelName: group.channelName, clients: group.clients, onSelected: widget.onSelected, ), ], ), ), ], ), ), ); } } class _ChannelGroup extends StatelessWidget { const _ChannelGroup({ required this.channelName, required this.clients, required this.onSelected, }); final String channelName; final List clients; final void Function(rust.BridgeClient) onSelected; @override Widget build(BuildContext context) { final theme = Theme.of(context); if (clients.isEmpty) return const SizedBox.shrink(); return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Padding( padding: const EdgeInsets.fromLTRB(16, 12, 16, 4), child: Text( channelName, style: theme.textTheme.labelMedium?.copyWith( color: theme.colorScheme.primary, fontWeight: FontWeight.w600, ), ), ), for (final c in clients) ListTile( leading: CircleAvatar( radius: 12, child: Text( c.name.isNotEmpty ? c.name[0].toUpperCase() : '?', style: const TextStyle(fontSize: 10), ), ), title: Text(c.name), onTap: () => onSelected(c), ), ], ); } } class _ChatHub extends StatelessWidget { const _ChatHub({ required this.snapshot, required this.messages, required this.currentChannelId, required this.channelName, required this.onOpen, required this.onPickClient, }); final rust.BridgeSnapshot snapshot; final List messages; final BigInt? currentChannelId; final String channelName; final void Function(rust.BridgeMessageTarget t, {String name}) onOpen; final void Function(void Function(BigInt id, String name) cb) onPickClient; Iterable _of(rust.BridgeMessageTarget t) => messages.where((m) => m.target == t); @override Widget build(BuildContext context) { final channelMsgs = _of(const rust.BridgeMessageTarget.channel()); final privateMsgs = messages.where((m) => m.isPrivate); final serverMsgs = _of(const rust.BridgeMessageTarget.server()); return Scaffold( appBar: AppBar(title: Text('Chat & Activity — ${snapshot.serverName}')), body: ListView( padding: const EdgeInsets.all(16), children: [ _HubCard( icon: Icons.tag, title: channelName.isNotEmpty ? '# $channelName' : 'Current Channel', subtitle: channelMsgs.isNotEmpty ? '${channelMsgs.length} message${channelMsgs.length == 1 ? '' : 's'}' : 'No messages yet', detail: currentChannelId != null ? 'You can send messages here' : 'Join a channel to send messages', onTap: () => onOpen(const rust.BridgeMessageTarget.channel()), ), const SizedBox(height: 12), _HubCard( icon: Icons.person_outline, title: 'Direct Messages', subtitle: privateMsgs.isNotEmpty ? '${privateMsgs.length} message${privateMsgs.length == 1 ? '' : 's'}' : 'No private messages', detail: 'Select a user to start a private chat.', onTap: () => onPickClient( (id, name) => onOpen(rust.BridgeMessageTarget.client(id), name: name), ), ), const SizedBox(height: 12), _HubCard( icon: Icons.list_alt_outlined, title: 'Server Activity', subtitle: serverMsgs.isNotEmpty ? '${serverMsgs.length} message${serverMsgs.length == 1 ? '' : 's'}' : 'No server activity', detail: serverMsgs.isNotEmpty ? 'Server messages and events appear here.' : 'Server-wide messages will appear here.', onTap: () => onOpen(const rust.BridgeMessageTarget.server()), ), ], ), ); } } class _HubCard extends StatelessWidget { const _HubCard({ required this.icon, required this.title, required this.subtitle, required this.detail, required this.onTap, }); final IconData icon; final String title; final String subtitle; final String detail; final VoidCallback onTap; @override Widget build(BuildContext context) { final theme = Theme.of(context); return Card( clipBehavior: Clip.antiAlias, child: ListTile( leading: Icon(icon, color: theme.colorScheme.primary), title: Text(title, style: theme.textTheme.titleSmall), subtitle: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ const SizedBox(height: 4), Text(subtitle, style: theme.textTheme.bodyMedium), Text( detail, style: theme.textTheme.labelSmall?.copyWith( color: theme.colorScheme.onSurfaceVariant, ), ), ], ), trailing: const Icon(Icons.chevron_right), onTap: onTap, ), ); } } class _ChatDetailView extends StatefulWidget { const _ChatDetailView({ required this.target, required this.clientName, required this.snapshot, required this.messages, required this.currentChannelId, required this.channelName, required this.onBack, this.onTs3ServerLink, }); final rust.BridgeMessageTarget target; final String clientName; final rust.BridgeSnapshot snapshot; final List messages; final BigInt? currentChannelId; final String channelName; final VoidCallback onBack; final Ts3ServerLinkHandler? onTs3ServerLink; @override State<_ChatDetailView> createState() => _ChatDetailViewState(); } class _ChatDetailViewState extends State<_ChatDetailView> { final _textCtl = TextEditingController(); final _scrollCtl = ScrollController(); Iterable get _filtered { if (widget.target is rust.BridgeMessageTarget_Channel) { return widget.messages.where( (m) => m.target == widget.target || m.isPoke, ); } return widget.messages.where((m) => m.target == widget.target); } bool get _canSend => canSendToChatTarget(widget.target, widget.currentChannelId); String? get _blockedReason => chatSendBlockedReason(widget.target, widget.currentChannelId); String get _title => chatTargetTitle( widget.target, channelName: widget.channelName, clientName: widget.clientName, ); @override void dispose() { _textCtl.dispose(); _scrollCtl.dispose(); super.dispose(); } void _send() { final text = _textCtl.text.trim(); if (text.isEmpty || !_canSend) return; _textCtl.clear(); unawaited(rust.sendChatMessage(message: text, target: widget.target)); final ownId = widget.snapshot.ownClientId; setState(() { widget.messages.add( ChatEntry( senderId: ownId, senderName: widget.target is rust.BridgeMessageTarget_Poke ? widget.clientName : 'You', message: text, target: widget.target, isSelf: true, timestamp: DateTime.now(), ), ); if (widget.messages.length > 200) { widget.messages.removeRange(0, widget.messages.length - 200); } }); _scrollToBottom(); } void _scrollToBottom() { if (_scrollCtl.hasClients) { _scrollCtl.animateTo( _scrollCtl.position.maxScrollExtent, duration: const Duration(milliseconds: 100), curve: Curves.easeOut, ); } } @override Widget build(BuildContext context) { final theme = Theme.of(context); final msgs = _filtered.toList(); final placeholder = chatInputPlaceholder( widget.target, channelName: widget.channelName, clientName: widget.clientName, ); return Scaffold( appBar: AppBar( leading: IconButton( icon: const Icon(Icons.arrow_back), onPressed: () => Navigator.of(context).pop(), ), title: Text(_title), actions: [ TextButton(onPressed: widget.onBack, child: const Text('Activity')), ], ), body: Column( children: [ Expanded( child: msgs.isEmpty ? Center( child: Padding( padding: const EdgeInsets.all(32), child: Column( mainAxisSize: MainAxisSize.min, children: [ Icon( Icons.forum_outlined, size: 48, color: theme.colorScheme.onSurfaceVariant, ), const SizedBox(height: 16), Text( chatEmptyTitle(widget.target), style: theme.textTheme.titleMedium, textAlign: TextAlign.center, ), const SizedBox(height: 8), Text( chatEmptyBody( widget.target, channelName: widget.channelName, ), style: theme.textTheme.bodyMedium?.copyWith( color: theme.colorScheme.onSurfaceVariant, ), textAlign: TextAlign.center, ), ], ), ), ) : ListView.builder( controller: _scrollCtl, padding: const EdgeInsets.symmetric(vertical: 8), itemCount: msgs.length, itemBuilder: (_, i) => _MessageBubble( entry: msgs[i], onTs3ServerLink: widget.onTs3ServerLink, ), ), ), if (_blockedReason != null) Container( width: double.infinity, padding: const EdgeInsets.all(12), color: theme.colorScheme.surfaceContainerHighest, child: Text( _blockedReason!, style: theme.textTheme.bodySmall?.copyWith( color: theme.colorScheme.onSurfaceVariant, ), textAlign: TextAlign.center, ), ), if (_canSend && _blockedReason == null) Padding( padding: const EdgeInsets.all(8), child: Row( children: [ Expanded( child: TextField( controller: _textCtl, decoration: InputDecoration( hintText: placeholder, border: OutlineInputBorder( borderRadius: BorderRadius.circular(24), ), contentPadding: const EdgeInsets.symmetric( horizontal: 16, vertical: 10, ), ), textInputAction: TextInputAction.send, onSubmitted: (_) => _send(), ), ), const SizedBox(width: 8), IconButton.filled( icon: const Icon(Icons.send), onPressed: _send, tooltip: 'Send', ), ], ), ), ], ), ); } } class _MessageBubble extends StatelessWidget { const _MessageBubble({required this.entry, this.onTs3ServerLink}); final ChatEntry entry; final Ts3ServerLinkHandler? onTs3ServerLink; @override Widget build(BuildContext context) { final theme = Theme.of(context); if (entry.isPoke) { return _PokeHistoryRow(entry: entry); } return Padding( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ CircleAvatar( radius: 14, child: Text( entry.senderName.isNotEmpty ? entry.senderName[0].toUpperCase() : '?', style: const TextStyle(fontSize: 12), ), ), const SizedBox(width: 8), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( entry.senderName, style: TextStyle( fontWeight: FontWeight.bold, fontSize: 12, color: entry.isSelf ? theme.colorScheme.primary : entry.isPrivate ? theme.colorScheme.tertiary : null, ), ), const SizedBox(height: 2), Container( padding: const EdgeInsets.symmetric( horizontal: 10, vertical: 6, ), decoration: BoxDecoration( color: entry.isSelf ? theme.colorScheme.primaryContainer : theme.colorScheme.surfaceContainerHighest, borderRadius: BorderRadius.circular(12), ), child: DefaultTextStyle.merge( style: theme.textTheme.bodyMedium, child: BbCodeText( entry.message, linkTrust: LinkTrustService.instance, onTs3ServerLink: onTs3ServerLink, ), ), ), ], ), ), ], ), ); } } class _PokeHistoryRow extends StatelessWidget { const _PokeHistoryRow({required this.entry}); final ChatEntry entry; @override Widget build(BuildContext context) { final theme = Theme.of(context); final l10n = AppL10n.of(context); return Padding( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), child: DecoratedBox( decoration: BoxDecoration( color: theme.colorScheme.secondaryContainer.withValues(alpha: 0.45), borderRadius: BorderRadius.circular(8), border: Border.all( color: theme.colorScheme.secondary.withValues(alpha: 0.22), ), ), child: Padding( padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 7), child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ Icon( Icons.notifications_none, size: 16, color: theme.colorScheme.onSecondaryContainer, ), const SizedBox(width: 8), Expanded( child: Text( pokeHistoryLine(l10n, entry), style: theme.textTheme.bodySmall?.copyWith( color: theme.colorScheme.onSecondaryContainer, fontFeatures: const [FontFeature.tabularFigures()], ), ), ), ], ), ), ), ); } }