Clarify ONNX Runtime guidance with direct-open install hints, restore desktop WebRTC VAD visibility, map mouse side buttons through focused PTT capture/runtime paths, and wait for server acks before showing chat sends as successful. Constraint: Linux release UX must stay functional when ONNX Runtime is optional and GNOME portal availability varies Rejected: Keep desktop VAD locked to Silero only | misleads users when ONNX Runtime is skipped Confidence: medium Scope-risk: moderate Directive: Preserve the protocol send-ack wait path for chat so UI success always tracks real server acceptance Tested: flutter analyze lib/main.dart lib/widgets/chat_views.dart lib/widgets/input_dialogs.dart lib/widgets/startup_dependency_screen.dart; flutter test test/widgets/input_dialogs_test.dart test/widgets/chat_views_test.dart test/services/startup_dependency_check_test.dart test/widgets/startup_dependency_screen_test.dart test/widgets/voice_settings_controls_test.dart test/widgets/audio_processing_config_state_test.dart; cargo test -p chanora_protocol --lib; cargo test -p chanora_audio ptt_backends --lib Not-tested: Live manual GNOME portal rebind/global PTT on a real desktop session; observer-bot chat against a live server after the sender-name fallback change
1439 lines
44 KiB
Dart
1439 lines
44 KiB
Dart
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';
|
|
|
|
const double _chatSidebarTileExtent = 92;
|
|
const double _chatSidebarIndicatorExtent = 76;
|
|
const double _chatSidebarIconExtent = 24;
|
|
const double _chatSidebarIconSize = 20;
|
|
const BorderRadius _chatSidebarIndicatorRadius = BorderRadius.all(
|
|
Radius.circular(20),
|
|
);
|
|
|
|
/// 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,
|
|
this.countsTowardUnread = true,
|
|
});
|
|
|
|
/// 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;
|
|
|
|
/// Whether this entry should increment the message unread badge.
|
|
final bool countsTowardUnread;
|
|
|
|
/// 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;
|
|
|
|
/// True for synthetic or protocol-driven server activity rows.
|
|
bool get isServerActivity =>
|
|
target is rust.BridgeMessageTarget_Server && !countsTowardUnread;
|
|
}
|
|
|
|
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);
|
|
}
|
|
|
|
enum _ActivityTone {
|
|
positive,
|
|
transition,
|
|
warning,
|
|
plain,
|
|
username,
|
|
channel,
|
|
adminActor,
|
|
automationActor,
|
|
group,
|
|
}
|
|
|
|
sealed class _ActivitySegment {
|
|
const _ActivitySegment(this.text, this.tone, {this.bold = false});
|
|
|
|
final String text;
|
|
final _ActivityTone tone;
|
|
final bool bold;
|
|
}
|
|
|
|
class _ActivityTextSegment extends _ActivitySegment {
|
|
const _ActivityTextSegment(super.text, super.tone, {super.bold = false});
|
|
}
|
|
|
|
List<_ActivitySegment> _activitySegments(String message) {
|
|
final quotedMatches = RegExp(r'"[^"]+"').allMatches(message).toList();
|
|
if (quotedMatches.isEmpty) {
|
|
return [_ActivityTextSegment(message, _activityActionTone(message))];
|
|
}
|
|
|
|
final segments = <_ActivitySegment>[];
|
|
var cursor = 0;
|
|
for (var i = 0; i < quotedMatches.length; i++) {
|
|
final match = quotedMatches[i];
|
|
if (match.start > cursor) {
|
|
final plain = message.substring(cursor, match.start);
|
|
if (plain.isNotEmpty) {
|
|
segments.add(
|
|
_ActivityTextSegment(plain, _activityActionTone(plain.trim())),
|
|
);
|
|
}
|
|
}
|
|
final quoted = match.group(0)!;
|
|
segments.add(_activityEntitySegment(quoted, message, i));
|
|
cursor = match.end;
|
|
}
|
|
if (cursor < message.length) {
|
|
final tail = message.substring(cursor);
|
|
if (tail.isNotEmpty) {
|
|
segments.add(
|
|
_ActivityTextSegment(tail, _activityActionTone(tail.trim())),
|
|
);
|
|
}
|
|
}
|
|
return segments;
|
|
}
|
|
|
|
_ActivitySegment _activityEntitySegment(
|
|
String quoted,
|
|
String fullMessage,
|
|
int quotedIndex,
|
|
) {
|
|
final lower = fullMessage.toLowerCase();
|
|
final after = fullMessage
|
|
.substring(fullMessage.indexOf(quoted) + quoted.length)
|
|
.toLowerCase();
|
|
final value = quoted.substring(1, quoted.length - 1);
|
|
|
|
if (lower.contains('channel group ')) {
|
|
if (quotedIndex == 0) {
|
|
return _ActivityTextSegment(quoted, _ActivityTone.group);
|
|
}
|
|
return _activityActorSegment(value, quoted);
|
|
}
|
|
if (lower.contains('server group ')) {
|
|
if (quotedIndex == 0) {
|
|
return _ActivityTextSegment(quoted, _ActivityTone.group);
|
|
}
|
|
return _activityActorSegment(value, quoted);
|
|
}
|
|
if (after.startsWith(' connected to channel') ||
|
|
after.startsWith(' switched from channel') ||
|
|
after.startsWith(' disconnected') ||
|
|
after.startsWith(' was moved from channel') ||
|
|
after.startsWith(' is now ')) {
|
|
return _activityActorSegment(value, quoted);
|
|
}
|
|
if (lower.contains('channel ') &&
|
|
(after.startsWith('.') ||
|
|
after.startsWith(' to') ||
|
|
after.startsWith(' from') ||
|
|
after.startsWith(' by') ||
|
|
after.isEmpty)) {
|
|
return _ActivityTextSegment(quoted, _ActivityTone.channel, bold: true);
|
|
}
|
|
if (lower.contains('channel ') &&
|
|
(quotedIndex == 1 || quotedIndex == 2 || quotedIndex == 3)) {
|
|
return _ActivityTextSegment(quoted, _ActivityTone.channel, bold: true);
|
|
}
|
|
return _activityActorSegment(value, quoted);
|
|
}
|
|
|
|
_ActivitySegment _activityActorSegment(String value, String quoted) {
|
|
final lower = value.toLowerCase();
|
|
if (lower.contains('auto') ||
|
|
lower.contains('automation') ||
|
|
lower.contains('bot')) {
|
|
return _ActivityTextSegment(
|
|
quoted,
|
|
_ActivityTone.automationActor,
|
|
bold: true,
|
|
);
|
|
}
|
|
if (lower.contains('server') ||
|
|
lower.contains('admin') ||
|
|
lower.contains('vigorous pro')) {
|
|
return _ActivityTextSegment(quoted, _ActivityTone.adminActor, bold: true);
|
|
}
|
|
return _ActivityTextSegment(quoted, _ActivityTone.username, bold: true);
|
|
}
|
|
|
|
_ActivityTone _activityActionTone(String text) {
|
|
final lower = text.toLowerCase();
|
|
if (lower.contains('connected')) return _ActivityTone.positive;
|
|
if (lower.contains('disconnected') ||
|
|
lower.contains('dropped') ||
|
|
lower.contains('lost') ||
|
|
lower.contains('shutdown')) {
|
|
return _ActivityTone.warning;
|
|
}
|
|
if (lower.contains('switched') ||
|
|
lower.contains('moved') ||
|
|
lower.contains('created') ||
|
|
lower.contains('deleted') ||
|
|
lower.contains('renamed') ||
|
|
lower.contains('assigned') ||
|
|
lower.contains('removed')) {
|
|
return _ActivityTone.transition;
|
|
}
|
|
return _ActivityTone.plain;
|
|
}
|
|
|
|
Color _activityColor(BuildContext context, _ActivityTone tone) {
|
|
return switch (tone) {
|
|
_ActivityTone.positive => const Color(0xFF2F7D57),
|
|
_ActivityTone.transition => const Color(0xFF5A7394),
|
|
_ActivityTone.warning => const Color(0xFFC06A2B),
|
|
_ActivityTone.plain => const Color(0xFF6C7C8F),
|
|
_ActivityTone.username => const Color(0xFFC64A4A),
|
|
_ActivityTone.channel => const Color(0xFF3B6EA8),
|
|
_ActivityTone.adminActor => const Color(0xFF426B9A),
|
|
_ActivityTone.automationActor => const Color(0xFFB64C4C),
|
|
_ActivityTone.group => const Color(0xFF8B7A68),
|
|
};
|
|
}
|
|
|
|
/// Resolve the best chat target to show when opening the chat page.
|
|
///
|
|
/// Returns null to show the chat hub.
|
|
rust.BridgeMessageTarget? resolveInitialChatTarget({
|
|
required List<ChatEntry> messages,
|
|
required BigInt? currentVoiceChannelId,
|
|
}) {
|
|
bool hasServerActivity = false;
|
|
for (final message in messages.reversed) {
|
|
if (message.isPrivate) return message.target;
|
|
if (message.target is rust.BridgeMessageTarget_Server) {
|
|
hasServerActivity = true;
|
|
}
|
|
}
|
|
if (currentVoiceChannelId != null) {
|
|
return const rust.BridgeMessageTarget.channel();
|
|
}
|
|
if (hasServerActivity) {
|
|
return const rust.BridgeMessageTarget.server();
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/// Build server-activity entries from a fresh snapshot diff.
|
|
List<ChatEntry> buildServerActivityEntries({
|
|
required rust.BridgeSnapshot previous,
|
|
required rust.BridgeSnapshot current,
|
|
DateTime? timestamp,
|
|
}) {
|
|
final now = timestamp ?? DateTime.now();
|
|
final previousClients = {
|
|
for (final client in previous.clients) client.id: client,
|
|
};
|
|
final currentClients = {
|
|
for (final client in current.clients) client.id: client,
|
|
};
|
|
final previousChannels = {
|
|
for (final channel in previous.channels) channel.id: channel,
|
|
};
|
|
final currentChannels = {
|
|
for (final channel in current.channels) channel.id: channel,
|
|
};
|
|
final entries = <ChatEntry>[];
|
|
|
|
String clientLabel(rust.BridgeClient client, rust.BridgeSnapshot snapshot) {
|
|
if (client.id == snapshot.ownClientId) return 'You';
|
|
final name = client.name.isNotEmpty ? client.name : 'Unknown';
|
|
return '"$name"';
|
|
}
|
|
|
|
String channelLabel(rust.BridgeSnapshot snapshot, BigInt channelId) {
|
|
final name = snapshotChannelName(snapshot, channelId);
|
|
if (name.isNotEmpty) return 'channel "$name"';
|
|
return 'channel ${channelId.toString()}';
|
|
}
|
|
|
|
void addEntry(String message) {
|
|
entries.add(
|
|
ChatEntry(
|
|
senderId: BigInt.zero,
|
|
senderName: 'Server',
|
|
message: message,
|
|
target: const rust.BridgeMessageTarget.server(),
|
|
timestamp: now,
|
|
countsTowardUnread: false,
|
|
),
|
|
);
|
|
}
|
|
|
|
for (final client in current.clients) {
|
|
if (client.isServerQuery) continue;
|
|
final previousClient = previousClients[client.id];
|
|
if (previousClient == null) {
|
|
addEntry(
|
|
'${clientLabel(client, current)} connected to ${channelLabel(current, client.channel)}',
|
|
);
|
|
continue;
|
|
}
|
|
if (previousClient.channel != client.channel) {
|
|
final fromChannel = channelLabel(previous, previousClient.channel);
|
|
final toChannel = channelLabel(current, client.channel);
|
|
addEntry(
|
|
'${clientLabel(client, current)} switched from $fromChannel to $toChannel',
|
|
);
|
|
continue;
|
|
}
|
|
if (previousClient.name != client.name && client.name.isNotEmpty) {
|
|
final previousName = previousClient.id == current.ownClientId
|
|
? 'You'
|
|
: (previousClient.name.isNotEmpty ? previousClient.name : 'Unknown');
|
|
final currentName = clientLabel(client, current);
|
|
addEntry('$previousName is now $currentName');
|
|
}
|
|
}
|
|
|
|
for (final client in previous.clients) {
|
|
if (client.isServerQuery) continue;
|
|
if (currentClients.containsKey(client.id)) continue;
|
|
addEntry(
|
|
'${clientLabel(client, previous)} disconnected from ${channelLabel(previous, client.channel)}',
|
|
);
|
|
}
|
|
|
|
for (final channel in current.channels) {
|
|
final previousChannel = previousChannels[channel.id];
|
|
if (previousChannel == null) {
|
|
addEntry('Channel created: ${channelLabel(current, channel.id)}');
|
|
continue;
|
|
}
|
|
if (previousChannel.name != channel.name) {
|
|
addEntry('Channel renamed to ${channelLabel(current, channel.id)}');
|
|
continue;
|
|
}
|
|
if (previousChannel.parent != channel.parent ||
|
|
previousChannel.order != channel.order) {
|
|
addEntry('Channel moved: ${channelLabel(current, channel.id)}');
|
|
}
|
|
}
|
|
|
|
for (final channel in previous.channels) {
|
|
if (currentChannels.containsKey(channel.id)) continue;
|
|
addEntry('Channel deleted: ${channelLabel(previous, channel.id)}');
|
|
}
|
|
|
|
return entries;
|
|
}
|
|
|
|
class ChatClientGroups {
|
|
const ChatClientGroups({
|
|
required this.clientsByChannel,
|
|
required this.ungroupedClients,
|
|
});
|
|
|
|
final Map<BigInt, List<rust.BridgeClient>> clientsByChannel;
|
|
final List<rust.BridgeClient> ungroupedClients;
|
|
}
|
|
|
|
class ChatClientPickerGroup {
|
|
const ChatClientPickerGroup({
|
|
required this.channelName,
|
|
required this.clients,
|
|
});
|
|
|
|
final String channelName;
|
|
final List<rust.BridgeClient> 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 = <BigInt, List<rust.BridgeClient>>{};
|
|
final ungrouped = <rust.BridgeClient>[];
|
|
|
|
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<ChatClientPickerGroup> filterChatClientPickerGroups({
|
|
required List<rust.BridgeChannel> channels,
|
|
required Map<BigInt, List<rust.BridgeClient>> clientsByChannel,
|
|
required List<rust.BridgeClient> 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.messagesSource,
|
|
this.snapshotSource,
|
|
this.refreshListenable,
|
|
this.initialTarget,
|
|
this.initialClientName = '',
|
|
this.onTs3ServerLink,
|
|
});
|
|
|
|
/// Backing message list. The detail view appends self-sent messages.
|
|
final List<ChatEntry> messages;
|
|
|
|
/// Latest TeamSpeak snapshot.
|
|
final rust.BridgeSnapshot snapshot;
|
|
|
|
/// Optional live source for messages while this route stays open.
|
|
final List<ChatEntry> Function()? messagesSource;
|
|
|
|
/// Optional live source for snapshots while this route stays open.
|
|
final rust.BridgeSnapshot Function()? snapshotSource;
|
|
|
|
/// Triggers a rebuild when the backing chat state changes.
|
|
final Listenable? refreshListenable;
|
|
|
|
/// 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<ChatPage> createState() => _ChatPageState();
|
|
}
|
|
|
|
class _ChatPageState extends State<ChatPage> {
|
|
late rust.BridgeMessageTarget _selectedTarget;
|
|
String _selectedClientName = '';
|
|
final Set<BigInt> _closedPrivateChats = {};
|
|
|
|
List<ChatEntry> get _messages =>
|
|
widget.messagesSource?.call() ?? widget.messages;
|
|
|
|
rust.BridgeSnapshot get _snapshot =>
|
|
widget.snapshotSource?.call() ?? widget.snapshot;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_selectedTarget =
|
|
widget.initialTarget ??
|
|
resolveInitialChatTarget(
|
|
messages: _messages,
|
|
currentVoiceChannelId: _currentChannelId,
|
|
) ??
|
|
const rust.BridgeMessageTarget.server();
|
|
_selectedClientName = widget.initialClientName;
|
|
}
|
|
|
|
BigInt? get _currentChannelId {
|
|
return ownClientSnapshotState(_snapshot)?.channelId;
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
if (widget.refreshListenable != null) {
|
|
return ListenableBuilder(
|
|
listenable: widget.refreshListenable!,
|
|
builder: (context, _) => _buildScaffold(context),
|
|
);
|
|
}
|
|
return _buildScaffold(context);
|
|
}
|
|
|
|
Widget _buildScaffold(BuildContext context) {
|
|
final snapshot = _snapshot;
|
|
final messages = _messages;
|
|
final currentChannelId = _currentChannelId;
|
|
final channelName = snapshotChannelName(snapshot, currentChannelId);
|
|
return Scaffold(
|
|
appBar: AppBar(
|
|
title: Text('Chat — ${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: snapshot,
|
|
messages: 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 _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 chats.values.toList()..sort((a, b) => a.name.compareTo(b.name));
|
|
}
|
|
|
|
String _privateChatName(BigInt id, String fallback) {
|
|
for (final client in _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) {
|
|
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 _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) {
|
|
final theme = Theme.of(context);
|
|
final dividerColor = theme.colorScheme.outlineVariant.withValues(
|
|
alpha: 0.45,
|
|
);
|
|
return SizedBox(
|
|
width: _chatSidebarTileExtent,
|
|
child: Material(
|
|
color: theme.colorScheme.surfaceContainerLow,
|
|
child: Column(
|
|
children: [
|
|
const SizedBox(height: 8),
|
|
_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()),
|
|
),
|
|
Divider(height: 1, indent: 12, endIndent: 12, color: dividerColor),
|
|
Expanded(
|
|
child: ListView.builder(
|
|
padding: const EdgeInsets.symmetric(vertical: 6),
|
|
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,
|
|
),
|
|
);
|
|
},
|
|
),
|
|
),
|
|
Divider(height: 1, indent: 12, endIndent: 12, color: dividerColor),
|
|
Padding(
|
|
padding: const EdgeInsets.fromLTRB(8, 10, 8, 12),
|
|
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 scheme = theme.colorScheme;
|
|
final indicatorColor = selected
|
|
? scheme.primaryContainer
|
|
: Colors.transparent;
|
|
final contentColor = selected
|
|
? scheme.onPrimaryContainer
|
|
: scheme.onSurfaceVariant;
|
|
final labelStyle = theme.textTheme.labelSmall?.copyWith(
|
|
color: contentColor,
|
|
height: 1.15,
|
|
fontWeight: selected ? FontWeight.w600 : FontWeight.w500,
|
|
);
|
|
|
|
return SizedBox(
|
|
width: _chatSidebarTileExtent,
|
|
height: _chatSidebarTileExtent,
|
|
child: Material(
|
|
color: Colors.transparent,
|
|
child: InkWell(
|
|
onTap: onTap,
|
|
borderRadius: _chatSidebarIndicatorRadius,
|
|
child: Center(
|
|
child: AnimatedContainer(
|
|
duration: const Duration(milliseconds: 180),
|
|
curve: Curves.easeOutCubic,
|
|
width: _chatSidebarIndicatorExtent,
|
|
height: _chatSidebarIndicatorExtent,
|
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8),
|
|
decoration: BoxDecoration(
|
|
color: indicatorColor,
|
|
borderRadius: _chatSidebarIndicatorRadius,
|
|
),
|
|
child: Column(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
children: [
|
|
SizedBox(
|
|
width: _chatSidebarIconExtent,
|
|
height: _chatSidebarIconExtent,
|
|
child: Icon(
|
|
icon,
|
|
size: _chatSidebarIconSize,
|
|
color: contentColor,
|
|
),
|
|
),
|
|
const SizedBox(height: 8),
|
|
SizedBox(
|
|
width: _chatSidebarIndicatorExtent - 16,
|
|
child: Text(
|
|
label,
|
|
maxLines: 2,
|
|
overflow: TextOverflow.ellipsis,
|
|
textAlign: TextAlign.center,
|
|
style: labelStyle,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _ClientPickerDialog extends StatefulWidget {
|
|
const _ClientPickerDialog({
|
|
required this.channels,
|
|
required this.byChannel,
|
|
required this.ungrouped,
|
|
required this.onSelected,
|
|
});
|
|
|
|
final List<rust.BridgeChannel> channels;
|
|
final Map<BigInt, List<rust.BridgeClient>> byChannel;
|
|
final List<rust.BridgeClient> 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<rust.BridgeClient> 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 _ChatDetailView extends StatefulWidget {
|
|
const _ChatDetailView({
|
|
required this.target,
|
|
required this.clientName,
|
|
required this.snapshot,
|
|
required this.messages,
|
|
required this.currentChannelId,
|
|
required this.channelName,
|
|
this.onTs3ServerLink,
|
|
});
|
|
|
|
final rust.BridgeMessageTarget target;
|
|
final String clientName;
|
|
final rust.BridgeSnapshot snapshot;
|
|
final List<ChatEntry> messages;
|
|
final BigInt? currentChannelId;
|
|
final String channelName;
|
|
final Ts3ServerLinkHandler? onTs3ServerLink;
|
|
|
|
@override
|
|
State<_ChatDetailView> createState() => _ChatDetailViewState();
|
|
}
|
|
|
|
class _ChatDetailViewState extends State<_ChatDetailView> {
|
|
final _textCtl = TextEditingController();
|
|
final _scrollCtl = ScrollController();
|
|
int _lastRenderedMessageCount = -1;
|
|
rust.BridgeMessageTarget? _lastRenderedTarget;
|
|
bool _sending = false;
|
|
|
|
Iterable<ChatEntry> 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();
|
|
}
|
|
|
|
Future<void> _send() async {
|
|
final text = _textCtl.text.trim();
|
|
if (text.isEmpty || !_canSend || _sending) return;
|
|
_textCtl.clear();
|
|
setState(() => _sending = true);
|
|
try {
|
|
await rust.sendChatMessage(message: text, target: widget.target);
|
|
if (!mounted) return;
|
|
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();
|
|
} catch (error) {
|
|
if (!mounted) return;
|
|
_textCtl.text = text;
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(
|
|
behavior: SnackBarBehavior.floating,
|
|
content: Text(
|
|
'Could not send message: $error',
|
|
maxLines: 3,
|
|
overflow: TextOverflow.ellipsis,
|
|
),
|
|
),
|
|
);
|
|
} finally {
|
|
if (mounted) {
|
|
setState(() => _sending = false);
|
|
}
|
|
}
|
|
}
|
|
|
|
void _scrollToBottom() {
|
|
if (_scrollCtl.hasClients) {
|
|
_scrollCtl.animateTo(
|
|
_scrollCtl.position.maxScrollExtent,
|
|
duration: const Duration(milliseconds: 100),
|
|
curve: Curves.easeOut,
|
|
);
|
|
}
|
|
}
|
|
|
|
void _scheduleScrollIfNeeded(int messageCount) {
|
|
final targetChanged = _lastRenderedTarget != widget.target;
|
|
final countChanged = _lastRenderedMessageCount != messageCount;
|
|
_lastRenderedTarget = widget.target;
|
|
_lastRenderedMessageCount = messageCount;
|
|
if (!targetChanged && !countChanged) return;
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
if (mounted) _scrollToBottom();
|
|
});
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final theme = Theme.of(context);
|
|
final msgs = _filtered.toList();
|
|
_scheduleScrollIfNeeded(msgs.length);
|
|
final placeholder = chatInputPlaceholder(
|
|
widget.target,
|
|
channelName: widget.channelName,
|
|
clientName: widget.clientName,
|
|
);
|
|
|
|
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),
|
|
),
|
|
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) {
|
|
if (entry.isPoke) {
|
|
return _PokeHistoryRow(entry: entry);
|
|
}
|
|
if (entry.isServerActivity) {
|
|
return _ServerActivityRow(entry: entry);
|
|
}
|
|
final theme = Theme.of(context);
|
|
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 _ServerActivityRow extends StatelessWidget {
|
|
const _ServerActivityRow({required this.entry});
|
|
|
|
final ChatEntry entry;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final theme = Theme.of(context);
|
|
final timestampStyle = theme.textTheme.bodySmall?.copyWith(
|
|
color: const Color(0xFF8A98A8),
|
|
fontStyle: FontStyle.italic,
|
|
fontWeight: FontWeight.w400,
|
|
letterSpacing: 0.15,
|
|
fontFeatures: const [FontFeature.tabularFigures()],
|
|
);
|
|
final baseStyle = theme.textTheme.bodySmall?.copyWith(
|
|
color: const Color(0xFF6C7C8F),
|
|
fontWeight: FontWeight.w400,
|
|
fontSize: 13,
|
|
height: 1.45,
|
|
letterSpacing: 0.05,
|
|
);
|
|
final spans = <InlineSpan>[
|
|
TextSpan(
|
|
text: '<${chatTimeLabel(entry.timestamp)}> ',
|
|
style: timestampStyle,
|
|
),
|
|
];
|
|
for (final segment in _activitySegments(entry.message)) {
|
|
spans.add(
|
|
TextSpan(
|
|
text: segment.text,
|
|
style: baseStyle?.copyWith(
|
|
color: _activityColor(context, segment.tone),
|
|
fontWeight: segment.bold ? FontWeight.w700 : FontWeight.w400,
|
|
),
|
|
),
|
|
);
|
|
}
|
|
return Padding(
|
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 3),
|
|
child: DecoratedBox(
|
|
decoration: BoxDecoration(
|
|
color: const Color(0xFFF7F9FC),
|
|
borderRadius: BorderRadius.circular(10),
|
|
),
|
|
child: Padding(
|
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
|
child: Text.rich(TextSpan(children: spans)),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
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()],
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|