feat: integrate chat voice and diagnostics client

This commit is contained in:
Edison Jwa
2026-05-23 06:51:55 +09:00
parent 7e28791ec2
commit 7d5d8c2c90
93 changed files with 10273 additions and 3347 deletions
+321 -236
View File
@@ -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',
),
],
),
],
),
),
],
);
}
}