feat: integrate chat voice and diagnostics client
This commit is contained in:
@@ -280,13 +280,20 @@ class ChatPage extends StatefulWidget {
|
||||
}
|
||||
|
||||
class _ChatPageState extends State<ChatPage> {
|
||||
rust.BridgeMessageTarget? _detailTarget;
|
||||
late rust.BridgeMessageTarget _selectedTarget;
|
||||
String _selectedClientName = '';
|
||||
final Set<BigInt> _closedPrivateChats = {};
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_detailTarget = widget.initialTarget;
|
||||
_selectedTarget =
|
||||
widget.initialTarget ??
|
||||
resolveInitialChatTarget(
|
||||
messages: widget.messages,
|
||||
currentVoiceChannelId: _currentChannelId,
|
||||
) ??
|
||||
const rust.BridgeMessageTarget.server();
|
||||
_selectedClientName = widget.initialClientName;
|
||||
}
|
||||
|
||||
@@ -298,31 +305,102 @@ class _ChatPageState extends State<ChatPage> {
|
||||
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 Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text('Chat — ${widget.snapshot.serverName}'),
|
||||
actions: [
|
||||
IconButton(
|
||||
tooltip: 'Close chat',
|
||||
icon: const Icon(Icons.close),
|
||||
onPressed: _selectedPrivateClientId == null
|
||||
? null
|
||||
: _closeSelectedPrivateChat,
|
||||
),
|
||||
],
|
||||
),
|
||||
body: Row(
|
||||
children: [
|
||||
_ChatSidebar(
|
||||
selectedTarget: _selectedTarget,
|
||||
privateChats: _privateChats,
|
||||
onSelect: _selectTarget,
|
||||
onNewPrivateChat: () => _pickClient((id, name) {
|
||||
_closedPrivateChats.remove(id);
|
||||
_selectTarget(rust.BridgeMessageTarget.client(id), name: name);
|
||||
}),
|
||||
),
|
||||
const VerticalDivider(width: 1),
|
||||
Expanded(
|
||||
child: _ChatDetailView(
|
||||
target: _selectedTarget,
|
||||
clientName: _selectedClientName,
|
||||
snapshot: widget.snapshot,
|
||||
messages: widget.messages,
|
||||
currentChannelId: currentChannelId,
|
||||
channelName: channelName,
|
||||
onTs3ServerLink: widget.onTs3ServerLink,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
BigInt? get _selectedPrivateClientId {
|
||||
final target = _selectedTarget;
|
||||
return target is rust.BridgeMessageTarget_Client ? target.field0 : null;
|
||||
}
|
||||
|
||||
List<_PrivateChatItem> get _privateChats {
|
||||
final chats = <BigInt, _PrivateChatItem>{};
|
||||
for (final message in widget.messages) {
|
||||
final target = message.target;
|
||||
if (target is! rust.BridgeMessageTarget_Client) continue;
|
||||
final id = target.field0;
|
||||
if (_closedPrivateChats.contains(id)) continue;
|
||||
final existing = chats[id];
|
||||
final name = existing?.name.isNotEmpty == true
|
||||
? existing!.name
|
||||
: _privateChatName(id, message.senderName);
|
||||
chats[id] = _PrivateChatItem(id: id, name: name);
|
||||
}
|
||||
final selectedId = _selectedPrivateClientId;
|
||||
if (selectedId != null && !_closedPrivateChats.contains(selectedId)) {
|
||||
chats.putIfAbsent(
|
||||
selectedId,
|
||||
() => _PrivateChatItem(
|
||||
id: selectedId,
|
||||
name: _selectedClientName.isNotEmpty ? _selectedClientName : 'Direct',
|
||||
),
|
||||
);
|
||||
}
|
||||
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),
|
||||
);
|
||||
return chats.values.toList()..sort((a, b) => a.name.compareTo(b.name));
|
||||
}
|
||||
|
||||
String _privateChatName(BigInt id, String fallback) {
|
||||
for (final client in widget.snapshot.clients) {
|
||||
if (client.id == id && client.name.isNotEmpty) return client.name;
|
||||
}
|
||||
return fallback.isNotEmpty && fallback != 'You' ? fallback : 'Direct';
|
||||
}
|
||||
|
||||
void _selectTarget(rust.BridgeMessageTarget target, {String name = ''}) {
|
||||
setState(() {
|
||||
_selectedTarget = target;
|
||||
_selectedClientName = name;
|
||||
});
|
||||
}
|
||||
|
||||
void _closeSelectedPrivateChat() {
|
||||
final id = _selectedPrivateClientId;
|
||||
if (id == null) return;
|
||||
setState(() {
|
||||
_closedPrivateChats.add(id);
|
||||
_selectedTarget = _currentChannelId != null
|
||||
? const rust.BridgeMessageTarget.channel()
|
||||
: const rust.BridgeMessageTarget.server();
|
||||
_selectedClientName = '';
|
||||
});
|
||||
}
|
||||
|
||||
void _pickClient(void Function(BigInt id, String name) cb) {
|
||||
@@ -342,6 +420,134 @@ class _ChatPageState extends State<ChatPage> {
|
||||
}
|
||||
}
|
||||
|
||||
class _PrivateChatItem {
|
||||
const _PrivateChatItem({required this.id, required this.name});
|
||||
|
||||
final BigInt id;
|
||||
final String name;
|
||||
}
|
||||
|
||||
class _ChatSidebar extends StatelessWidget {
|
||||
const _ChatSidebar({
|
||||
required this.selectedTarget,
|
||||
required this.privateChats,
|
||||
required this.onSelect,
|
||||
required this.onNewPrivateChat,
|
||||
});
|
||||
|
||||
final rust.BridgeMessageTarget selectedTarget;
|
||||
final List<_PrivateChatItem> privateChats;
|
||||
final void Function(rust.BridgeMessageTarget target, {String name}) onSelect;
|
||||
final VoidCallback onNewPrivateChat;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SizedBox(
|
||||
width: 148,
|
||||
child: Column(
|
||||
children: [
|
||||
_ChatSidebarItem(
|
||||
icon: Icons.dns_outlined,
|
||||
label: 'Server',
|
||||
selected: selectedTarget is rust.BridgeMessageTarget_Server,
|
||||
onTap: () => onSelect(const rust.BridgeMessageTarget.server()),
|
||||
),
|
||||
_ChatSidebarItem(
|
||||
icon: Icons.tag,
|
||||
label: 'Channel',
|
||||
selected: selectedTarget is rust.BridgeMessageTarget_Channel,
|
||||
onTap: () => onSelect(const rust.BridgeMessageTarget.channel()),
|
||||
),
|
||||
const Divider(height: 1),
|
||||
Expanded(
|
||||
child: ListView.builder(
|
||||
padding: EdgeInsets.zero,
|
||||
itemCount: privateChats.length,
|
||||
itemBuilder: (context, index) {
|
||||
final chat = privateChats[index];
|
||||
final selected = switch (selectedTarget) {
|
||||
rust.BridgeMessageTarget_Client(:final field0) =>
|
||||
field0 == chat.id,
|
||||
_ => false,
|
||||
};
|
||||
return _ChatSidebarItem(
|
||||
icon: Icons.person_outline,
|
||||
label: chat.name.isNotEmpty ? chat.name : 'Direct',
|
||||
selected: selected,
|
||||
onTap: () => onSelect(
|
||||
rust.BridgeMessageTarget.client(chat.id),
|
||||
name: chat.name,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
const Divider(height: 1),
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(8),
|
||||
child: IconButton.filledTonal(
|
||||
tooltip: 'New private chat',
|
||||
icon: const Icon(Icons.add),
|
||||
onPressed: onNewPrivateChat,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ChatSidebarItem extends StatelessWidget {
|
||||
const _ChatSidebarItem({
|
||||
required this.icon,
|
||||
required this.label,
|
||||
required this.selected,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
final IconData icon;
|
||||
final String label;
|
||||
final bool selected;
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final bg = selected
|
||||
? theme.colorScheme.primaryContainer
|
||||
: Colors.transparent;
|
||||
final fg = selected
|
||||
? theme.colorScheme.onPrimaryContainer
|
||||
: theme.colorScheme.onSurface;
|
||||
return Material(
|
||||
color: bg,
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
child: SizedBox(
|
||||
height: 44,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(icon, size: 18, color: fg),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
label,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: theme.textTheme.bodyMedium?.copyWith(color: fg),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ClientPickerDialog extends StatefulWidget {
|
||||
const _ClientPickerDialog({
|
||||
required this.channels,
|
||||
@@ -461,124 +667,6 @@ class _ChannelGroup extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
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<ChatEntry> 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<ChatEntry> _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,
|
||||
@@ -587,7 +675,6 @@ class _ChatDetailView extends StatefulWidget {
|
||||
required this.messages,
|
||||
required this.currentChannelId,
|
||||
required this.channelName,
|
||||
required this.onBack,
|
||||
this.onTs3ServerLink,
|
||||
});
|
||||
|
||||
@@ -597,7 +684,6 @@ class _ChatDetailView extends StatefulWidget {
|
||||
final List<ChatEntry> messages;
|
||||
final BigInt? currentChannelId;
|
||||
final String channelName;
|
||||
final VoidCallback onBack;
|
||||
final Ts3ServerLinkHandler? onTs3ServerLink;
|
||||
|
||||
@override
|
||||
@@ -682,109 +768,108 @@ class _ChatDetailViewState extends State<_ChatDetailView> {
|
||||
clientName: widget.clientName,
|
||||
);
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.arrow_back),
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
return Column(
|
||||
children: [
|
||||
Container(
|
||||
height: 48,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
alignment: Alignment.centerLeft,
|
||||
decoration: BoxDecoration(
|
||||
border: Border(
|
||||
bottom: BorderSide(color: theme.colorScheme.outlineVariant),
|
||||
),
|
||||
),
|
||||
child: Text(_title, style: theme.textTheme.titleMedium),
|
||||
),
|
||||
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,
|
||||
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,
|
||||
),
|
||||
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,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
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,
|
||||
)
|
||||
: ListView.builder(
|
||||
controller: _scrollCtl,
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
itemCount: msgs.length,
|
||||
itemBuilder: (_, i) => _MessageBubble(
|
||||
entry: msgs[i],
|
||||
onTs3ServerLink: widget.onTs3ServerLink,
|
||||
),
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
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,
|
||||
),
|
||||
),
|
||||
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(),
|
||||
),
|
||||
textInputAction: TextInputAction.send,
|
||||
onSubmitted: (_) => _send(),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
IconButton.filled(
|
||||
icon: const Icon(Icons.send),
|
||||
onPressed: _send,
|
||||
tooltip: 'Send',
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
IconButton.filled(
|
||||
icon: const Icon(Icons.send),
|
||||
onPressed: _send,
|
||||
tooltip: 'Send',
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user