Add dart doc comments to poke_notification_service, poke_preferences_service, link_trust_service, voice_settings, snapshot_view covering public API.
1215 lines
36 KiB
Dart
1215 lines
36 KiB
Dart
import 'package:flutter/material.dart';
|
|
import 'package:flutter/services.dart';
|
|
import 'package:shared_preferences/shared_preferences.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';
|
|
import 'talk_power_warning.dart';
|
|
|
|
/// Connected-server snapshot displaying welcome text, channel tree, and clients.
|
|
///
|
|
/// Renders the full channel hierarchy from a [BridgeSnapshot] with expandable
|
|
/// channel nodes, client voice-status indicators, unread-message badges, and
|
|
/// context menus for client actions (info, chat, poke, volume).
|
|
///
|
|
/// Channel join is triggered by tapping an unlocked channel row; password-
|
|
/// protected channels invoke [onJoinChannelWithPassword] instead.
|
|
class SnapshotView extends StatefulWidget {
|
|
/// Construct a snapshot view.
|
|
const SnapshotView({
|
|
super.key,
|
|
required this.snapshot,
|
|
required this.audioStats,
|
|
required this.currentVoiceChannelId,
|
|
required this.pendingVoiceChannelId,
|
|
required this.localInputMuted,
|
|
required this.localOutputMuted,
|
|
required this.hasJoinPending,
|
|
required this.canJoinVoiceChannel,
|
|
required this.unreadChannelIds,
|
|
required this.onJoinChannel,
|
|
required this.onJoinChannelWithPassword,
|
|
this.enableClientLongPressMenu = false,
|
|
this.onOpenClientInfo,
|
|
this.onOpenClientChat,
|
|
this.onOpenClientPoke,
|
|
this.onOpenChannelChat,
|
|
this.onTs3ServerLink,
|
|
});
|
|
|
|
/// Current bridge snapshot.
|
|
final rust.BridgeSnapshot snapshot;
|
|
|
|
/// Latest audio stats, used for local speaking state.
|
|
final rust.BridgeAudioStats? audioStats;
|
|
|
|
/// Current voice channel id.
|
|
final BigInt? currentVoiceChannelId;
|
|
|
|
/// Pending join target, if any.
|
|
final BigInt? pendingVoiceChannelId;
|
|
|
|
/// Local input mute state.
|
|
final bool localInputMuted;
|
|
|
|
/// Local output mute state.
|
|
final bool localOutputMuted;
|
|
|
|
/// True while a channel join is in flight.
|
|
final bool hasJoinPending;
|
|
|
|
/// True when the local client may join voice channels.
|
|
final bool canJoinVoiceChannel;
|
|
|
|
/// Set of channel IDs that have unread chat messages.
|
|
final Set<BigInt> unreadChannelIds;
|
|
|
|
/// Join an unlocked channel.
|
|
final ValueChanged<rust.BridgeChannel> onJoinChannel;
|
|
|
|
/// Join a password-protected channel.
|
|
final ValueChanged<rust.BridgeChannel> onJoinChannelWithPassword;
|
|
|
|
/// True on touch-only mobile hosts where long-press opens client actions.
|
|
final bool enableClientLongPressMenu;
|
|
|
|
/// Open richer profile details for a non-self client.
|
|
final ValueChanged<rust.BridgeClient>? onOpenClientInfo;
|
|
|
|
/// Open a direct chat with a non-self client.
|
|
final ValueChanged<rust.BridgeClient>? onOpenClientChat;
|
|
|
|
/// Open a poke composer for a non-self client.
|
|
final ValueChanged<rust.BridgeClient>? onOpenClientPoke;
|
|
|
|
/// Open chat for a channel.
|
|
final ValueChanged<rust.BridgeChannel>? onOpenChannelChat;
|
|
|
|
/// Handle TeamSpeak server links embedded in server-provided text.
|
|
final Ts3ServerLinkHandler? onTs3ServerLink;
|
|
|
|
@override
|
|
State<SnapshotView> createState() => _SnapshotViewState();
|
|
}
|
|
|
|
class _SnapshotViewState extends State<SnapshotView> {
|
|
static const _indentPerLevel = 12.0;
|
|
static const _expandColumnWidth = 28.0;
|
|
static const _channelIconColumnWidth = 24.0;
|
|
static const _channelTextGap = 8.0;
|
|
static const _userRowStartIndent = 32.0;
|
|
|
|
final _scrollController = ScrollController();
|
|
final Map<BigInt, bool> _channelExpandedById = {};
|
|
final _clientVolumePrefs = _ClientVolumePreferences.instance;
|
|
bool _welcomeExpanded = false;
|
|
double _welcomeHeight = 0;
|
|
final _welcomeKey = GlobalKey();
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_scrollController.addListener(_onScroll);
|
|
_clientVolumePrefs.addListener(_onClientVolumePrefsChanged);
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
final ctx = _welcomeKey.currentContext;
|
|
if (ctx != null) {
|
|
final box = ctx.findRenderObject() as RenderBox?;
|
|
if (box != null && mounted) {
|
|
setState(() => _welcomeHeight = box.size.height);
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
void _onScroll() {
|
|
if (_welcomeExpanded && _scrollController.offset > _welcomeHeight) {
|
|
setState(() => _welcomeExpanded = false);
|
|
}
|
|
}
|
|
|
|
void _onClientVolumePrefsChanged() {
|
|
if (mounted) setState(() {});
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_clientVolumePrefs.removeListener(_onClientVolumePrefsChanged);
|
|
_scrollController.removeListener(_onScroll);
|
|
_scrollController.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final l10n = AppL10n.of(context);
|
|
final theme = Theme.of(context);
|
|
final tree = _buildChannelTree(widget.snapshot.channels);
|
|
final clientsByChannel = <BigInt, List<rust.BridgeClient>>{};
|
|
for (final c in widget.snapshot.clients) {
|
|
if (!c.isServerQuery) {
|
|
clientsByChannel.putIfAbsent(c.channel, () => []).add(c);
|
|
}
|
|
}
|
|
|
|
return ListView(
|
|
controller: _scrollController,
|
|
children: [
|
|
Text(
|
|
l10n.countChannelsAndClients(
|
|
widget.snapshot.channels.length,
|
|
widget.snapshot.clients.length,
|
|
),
|
|
style: theme.textTheme.bodyMedium,
|
|
),
|
|
if (widget.snapshot.welcomeMessage.isNotEmpty) ...[
|
|
const SizedBox(height: 8),
|
|
_WelcomeMessageTile(
|
|
key: _welcomeKey,
|
|
welcomeMessage: widget.snapshot.welcomeMessage,
|
|
expanded: _welcomeExpanded,
|
|
onToggle: () =>
|
|
setState(() => _welcomeExpanded = !_welcomeExpanded),
|
|
onTs3ServerLink: widget.onTs3ServerLink,
|
|
),
|
|
],
|
|
const Divider(height: 24),
|
|
for (final node in tree.roots)
|
|
..._channelTreeRows(theme, node, clientsByChannel, 0),
|
|
],
|
|
);
|
|
}
|
|
|
|
List<Widget> _channelTreeRows(
|
|
ThemeData theme,
|
|
_ChannelTreeNode node,
|
|
Map<BigInt, List<rust.BridgeClient>> clientsByChannel,
|
|
int depth,
|
|
) {
|
|
final channel = node.channel;
|
|
final clients = clientsByChannel[channel.id] ?? const <rust.BridgeClient>[];
|
|
final hasVisibleChildren = clients.isNotEmpty || node.children.isNotEmpty;
|
|
final expanded = _isChannelExpanded(channel.id);
|
|
final channelIndent = (depth.clamp(0, 8)) * _indentPerLevel;
|
|
|
|
return [
|
|
_channelTile(
|
|
theme,
|
|
channel,
|
|
channelIndent: channelIndent,
|
|
hasVisibleChildren: hasVisibleChildren,
|
|
expanded: expanded,
|
|
onToggleExpanded: hasVisibleChildren
|
|
? () => _toggleChannelExpanded(channel.id)
|
|
: null,
|
|
),
|
|
if (expanded) ...[
|
|
for (final client in clients) _clientTile(theme, client, channelIndent),
|
|
for (final child in node.children)
|
|
..._channelTreeRows(theme, child, clientsByChannel, depth + 1),
|
|
],
|
|
];
|
|
}
|
|
|
|
Widget _channelTile(
|
|
ThemeData theme,
|
|
rust.BridgeChannel channel, {
|
|
required double channelIndent,
|
|
required bool hasVisibleChildren,
|
|
required bool expanded,
|
|
required VoidCallback? onToggleExpanded,
|
|
}) {
|
|
final spacer = parseSpacerChannelName(channel.name);
|
|
final onTap =
|
|
widget.hasJoinPending ||
|
|
!widget.canJoinVoiceChannel ||
|
|
channel.id == widget.currentVoiceChannelId
|
|
? null
|
|
: () => channel.hasPassword
|
|
? widget.onJoinChannelWithPassword(channel)
|
|
: widget.onJoinChannel(channel);
|
|
|
|
if (spacer.isSpacer) {
|
|
return InkWell(
|
|
onTap: onTap,
|
|
child: ConstrainedBox(
|
|
constraints: const BoxConstraints(minHeight: 40),
|
|
child: Row(
|
|
children: [
|
|
SizedBox(width: channelIndent),
|
|
_expandButton(
|
|
theme,
|
|
hasVisibleChildren: hasVisibleChildren,
|
|
expanded: expanded,
|
|
onPressed: onToggleExpanded,
|
|
),
|
|
const SizedBox(width: _channelTextGap),
|
|
Expanded(child: _SpacerChannelContent(spacer: spacer)),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
return _ChannelContextMenu(
|
|
channel: channel,
|
|
onChat: widget.onOpenChannelChat != null
|
|
? () => widget.onOpenChannelChat!(channel)
|
|
: null,
|
|
child: InkWell(
|
|
onTap: onTap,
|
|
child: ConstrainedBox(
|
|
constraints: const BoxConstraints(minHeight: 40),
|
|
child: Row(
|
|
children: [
|
|
SizedBox(width: channelIndent),
|
|
_expandButton(
|
|
theme,
|
|
hasVisibleChildren: hasVisibleChildren,
|
|
expanded: expanded,
|
|
onPressed: onToggleExpanded,
|
|
),
|
|
SizedBox(
|
|
width: _channelIconColumnWidth,
|
|
child: Align(
|
|
alignment: Alignment.centerLeft,
|
|
child: Icon(
|
|
Icons.tag,
|
|
color: theme.colorScheme.onSurfaceVariant,
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(width: _channelTextGap),
|
|
Expanded(
|
|
child: Text(
|
|
channel.name,
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
),
|
|
),
|
|
if (widget.unreadChannelIds.contains(channel.id)) ...[
|
|
const SizedBox(width: 8),
|
|
Container(
|
|
width: 8,
|
|
height: 8,
|
|
decoration: BoxDecoration(
|
|
color: theme.colorScheme.primary,
|
|
shape: BoxShape.circle,
|
|
),
|
|
),
|
|
],
|
|
if (channel.hasPassword) ...[
|
|
const SizedBox(width: 8),
|
|
Icon(
|
|
Icons.lock_outline,
|
|
color: theme.colorScheme.onSurfaceVariant,
|
|
),
|
|
],
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _clientTile(
|
|
ThemeData theme,
|
|
rust.BridgeClient client,
|
|
double channelIndent,
|
|
) {
|
|
final status = _clientVoiceStatusIcon(theme, client);
|
|
final nameStyle = client.isServerQuery
|
|
? TextStyle(color: theme.colorScheme.onSurfaceVariant)
|
|
: status.isSpeaking
|
|
? TextStyle(
|
|
color: theme.colorScheme.primary,
|
|
fontWeight: FontWeight.w600,
|
|
)
|
|
: null;
|
|
final isSelf = client.id == widget.snapshot.ownClientId;
|
|
final volumePref = isSelf
|
|
? const _ClientVolumePreference()
|
|
: _clientVolumePrefs.preferenceFor(client.id);
|
|
final canOpenPeerActions =
|
|
!isSelf &&
|
|
(widget.onOpenClientChat != null || widget.onOpenClientPoke != null);
|
|
final canOpenClientMenu =
|
|
widget.onOpenClientInfo != null || canOpenPeerActions || !isSelf;
|
|
|
|
final decoration = status.isSpeaking
|
|
? BoxDecoration(
|
|
color: theme.colorScheme.surfaceContainerHighest.withValues(
|
|
alpha: 0.62,
|
|
),
|
|
borderRadius: BorderRadius.circular(8),
|
|
)
|
|
: null;
|
|
|
|
final tile = Padding(
|
|
padding: EdgeInsets.only(
|
|
left: channelIndent + _userRowStartIndent,
|
|
right: 8,
|
|
),
|
|
child: AnimatedContainer(
|
|
duration: const Duration(milliseconds: 120),
|
|
curve: Curves.easeOut,
|
|
decoration: decoration,
|
|
child: ListTile(
|
|
dense: true,
|
|
visualDensity: VisualDensity.compact,
|
|
leading: _ClientAvatarVoiceIndicator(
|
|
name: client.name,
|
|
speaking: status.isSpeaking,
|
|
badgeIcon: status.badgeIcon,
|
|
badgeColor: status.badgeColor,
|
|
badgeTooltip: status.badgeTooltip,
|
|
),
|
|
title: Text(client.name, style: nameStyle),
|
|
trailing: volumePref.isModified
|
|
? _ClientVolumeIndicator(preference: volumePref)
|
|
: null,
|
|
),
|
|
),
|
|
);
|
|
if (!canOpenClientMenu) return tile;
|
|
|
|
return GestureDetector(
|
|
behavior: HitTestBehavior.opaque,
|
|
onSecondaryTapDown: (details) =>
|
|
_showClientMenu(client, details.globalPosition),
|
|
onLongPressStart: widget.enableClientLongPressMenu
|
|
? (details) => _showClientMenu(client, details.globalPosition)
|
|
: null,
|
|
child: tile,
|
|
);
|
|
}
|
|
|
|
Future<void> _showClientMenu(
|
|
rust.BridgeClient client,
|
|
Offset globalPosition,
|
|
) async {
|
|
final overlay = Overlay.of(context).context.findRenderObject();
|
|
if (overlay is! RenderBox) return;
|
|
final isSelf = client.id == widget.snapshot.ownClientId;
|
|
|
|
final selected = await showMenu<_ClientMenuAction>(
|
|
context: context,
|
|
position: RelativeRect.fromRect(
|
|
Rect.fromLTWH(globalPosition.dx, globalPosition.dy, 0, 0),
|
|
Offset.zero & overlay.size,
|
|
),
|
|
items: [
|
|
if (widget.onOpenClientInfo != null)
|
|
PopupMenuItem(
|
|
value: _ClientMenuAction.info,
|
|
child: ListTile(
|
|
dense: true,
|
|
leading: const Icon(Icons.info_outline),
|
|
title: Text(AppL10n.of(context).clientInfoAction),
|
|
),
|
|
),
|
|
if (!isSelf)
|
|
PopupMenuItem(
|
|
value: _ClientMenuAction.volume,
|
|
child: ListTile(
|
|
dense: true,
|
|
leading: const Icon(Icons.volume_up),
|
|
title: Text(AppL10n.of(context).clientVolumeAction),
|
|
),
|
|
),
|
|
if (!isSelf && widget.onOpenClientChat != null)
|
|
PopupMenuItem(
|
|
value: _ClientMenuAction.directMessage,
|
|
child: ListTile(
|
|
dense: true,
|
|
leading: const Icon(Icons.chat_bubble_outline),
|
|
title: Text(AppL10n.of(context).chatDirectMessageAction),
|
|
),
|
|
),
|
|
if (!isSelf && widget.onOpenClientPoke != null)
|
|
PopupMenuItem(
|
|
value: _ClientMenuAction.poke,
|
|
child: ListTile(
|
|
dense: true,
|
|
leading: const Icon(Icons.notifications_active_outlined),
|
|
title: Text(AppL10n.of(context).chatPokeAction),
|
|
),
|
|
),
|
|
],
|
|
);
|
|
if (!mounted || selected == null) return;
|
|
switch (selected) {
|
|
case _ClientMenuAction.info:
|
|
widget.onOpenClientInfo?.call(client);
|
|
case _ClientMenuAction.directMessage:
|
|
widget.onOpenClientChat?.call(client);
|
|
case _ClientMenuAction.poke:
|
|
widget.onOpenClientPoke?.call(client);
|
|
case _ClientMenuAction.volume:
|
|
await _showVolumeSheet(client);
|
|
}
|
|
}
|
|
|
|
Future<void> _showVolumeSheet(rust.BridgeClient client) async {
|
|
await showModalBottomSheet<void>(
|
|
context: context,
|
|
isScrollControlled: true,
|
|
useSafeArea: true,
|
|
builder: (sheetContext) => _ClientVolumeSheet(client: client),
|
|
);
|
|
}
|
|
|
|
Widget _expandButton(
|
|
ThemeData theme, {
|
|
required bool hasVisibleChildren,
|
|
required bool expanded,
|
|
required VoidCallback? onPressed,
|
|
}) {
|
|
if (!hasVisibleChildren) {
|
|
return const SizedBox(
|
|
width: _expandColumnWidth,
|
|
height: _expandColumnWidth,
|
|
);
|
|
}
|
|
return Semantics(
|
|
button: true,
|
|
child: GestureDetector(
|
|
behavior: HitTestBehavior.opaque,
|
|
onTap: onPressed,
|
|
child: SizedBox(
|
|
width: _expandColumnWidth,
|
|
height: _expandColumnWidth,
|
|
child: Icon(
|
|
expanded ? Icons.expand_more : Icons.chevron_right,
|
|
color: theme.colorScheme.onSurfaceVariant,
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
bool _isChannelExpanded(BigInt channelId) {
|
|
return _channelExpandedById[channelId] ?? true;
|
|
}
|
|
|
|
void _toggleChannelExpanded(BigInt channelId) {
|
|
setState(() {
|
|
_channelExpandedById[channelId] = !_isChannelExpanded(channelId);
|
|
});
|
|
}
|
|
|
|
({
|
|
IconData? badgeIcon,
|
|
Color? badgeColor,
|
|
String? badgeTooltip,
|
|
bool isSpeaking,
|
|
})
|
|
_clientVoiceStatusIcon(ThemeData theme, rust.BridgeClient client) {
|
|
final isSelf = client.id == widget.snapshot.ownClientId;
|
|
final inCurrentChannel = client.channel == widget.currentVoiceChannelId;
|
|
final outputMuted = isSelf ? widget.localOutputMuted : client.outputMuted;
|
|
final inputMuted = isSelf ? widget.localInputMuted : client.inputMuted;
|
|
final neededTalkPower = snapshotNeededTalkPower(
|
|
widget.snapshot,
|
|
client.channel,
|
|
);
|
|
final talkPowerBlocked =
|
|
isSelf &&
|
|
inCurrentChannel &&
|
|
isTalkPowerBlocked(
|
|
talkPower: client.talkPower,
|
|
neededTalkPower: neededTalkPower,
|
|
talkPowerGranted: client.talkPowerGranted,
|
|
);
|
|
final rawSpeaking = isSelf
|
|
? (widget.audioStats?.pttActive ?? false)
|
|
: client.isSpeaking;
|
|
final transmitAllowed =
|
|
!outputMuted &&
|
|
!inputMuted &&
|
|
(!isSelf || (inCurrentChannel && !talkPowerBlocked));
|
|
final speaking = rawSpeaking && transmitAllowed;
|
|
|
|
final IconData? badgeIcon;
|
|
final Color? badgeColor;
|
|
final String? badgeTooltip;
|
|
if (outputMuted) {
|
|
badgeIcon = Icons.volume_off;
|
|
badgeColor = theme.colorScheme.error;
|
|
badgeTooltip = 'Speaker muted';
|
|
} else if (inputMuted) {
|
|
badgeIcon = Icons.mic_off;
|
|
badgeColor = theme.colorScheme.error;
|
|
badgeTooltip = 'Microphone muted';
|
|
} else if (talkPowerBlocked) {
|
|
badgeIcon = Icons.volume_off;
|
|
badgeColor = theme.colorScheme.error;
|
|
badgeTooltip =
|
|
'Insufficient talk power (${client.talkPower} < $neededTalkPower)';
|
|
} else {
|
|
badgeIcon = null;
|
|
badgeColor = null;
|
|
badgeTooltip = null;
|
|
}
|
|
|
|
return (
|
|
badgeIcon: badgeIcon,
|
|
badgeColor: badgeColor,
|
|
badgeTooltip: badgeTooltip,
|
|
isSpeaking: speaking,
|
|
);
|
|
}
|
|
}
|
|
|
|
enum _ClientMenuAction { info, directMessage, poke, volume }
|
|
|
|
class _ClientVolumePreference {
|
|
const _ClientVolumePreference({this.volume = 1.0, this.muted = false});
|
|
|
|
final double volume;
|
|
final bool muted;
|
|
|
|
bool get isModified => muted || (volume - 1.0).abs() > 0.001;
|
|
bool get isEffectivelyMuted => muted || volume <= 0.001;
|
|
}
|
|
|
|
class _ClientVolumePreferences extends ChangeNotifier {
|
|
_ClientVolumePreferences._() {
|
|
_load();
|
|
}
|
|
|
|
static final instance = _ClientVolumePreferences._();
|
|
|
|
static const _prefsKey = 'client_volume_prefs';
|
|
|
|
final Map<BigInt, _ClientVolumePreference> _byClientId = {};
|
|
bool _loaded = false;
|
|
|
|
Future<void> _load() async {
|
|
if (_loaded) return;
|
|
_loaded = true;
|
|
try {
|
|
final prefs = await SharedPreferences.getInstance();
|
|
final raw = prefs.getStringList(_prefsKey) ?? const [];
|
|
for (final entry in raw) {
|
|
final parts = entry.split(':');
|
|
if (parts.length == 3) {
|
|
final id = BigInt.tryParse(parts[0]);
|
|
final volume = double.tryParse(parts[1]);
|
|
final muted = parts[2] == '1';
|
|
if (id != null && volume != null) {
|
|
_byClientId[id] = _ClientVolumePreference(
|
|
volume: volume,
|
|
muted: muted,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
notifyListeners();
|
|
} catch (_) {}
|
|
}
|
|
|
|
_ClientVolumePreference preferenceFor(BigInt clientId) {
|
|
return _byClientId[clientId] ?? const _ClientVolumePreference();
|
|
}
|
|
|
|
void setPreference(BigInt clientId, _ClientVolumePreference preference) {
|
|
if (preference.isModified) {
|
|
_byClientId[clientId] = preference;
|
|
} else {
|
|
_byClientId.remove(clientId);
|
|
}
|
|
notifyListeners();
|
|
_save();
|
|
}
|
|
|
|
Future<void> _save() async {
|
|
try {
|
|
final prefs = await SharedPreferences.getInstance();
|
|
final raw = _byClientId.entries
|
|
.map((e) => '${e.key}:${e.value.volume}:${e.value.muted ? 1 : 0}')
|
|
.toList();
|
|
await prefs.setStringList(_prefsKey, raw);
|
|
} catch (_) {}
|
|
}
|
|
}
|
|
|
|
class _ClientAvatarVoiceIndicator extends StatelessWidget {
|
|
const _ClientAvatarVoiceIndicator({
|
|
required this.name,
|
|
required this.speaking,
|
|
required this.badgeIcon,
|
|
required this.badgeColor,
|
|
required this.badgeTooltip,
|
|
});
|
|
|
|
final String name;
|
|
final bool speaking;
|
|
final IconData? badgeIcon;
|
|
final Color? badgeColor;
|
|
final String? badgeTooltip;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final theme = Theme.of(context);
|
|
final initial = name.trim().isEmpty ? '?' : name.trim()[0].toUpperCase();
|
|
|
|
return SizedBox(
|
|
width: 40,
|
|
height: 40,
|
|
child: Stack(
|
|
clipBehavior: Clip.none,
|
|
children: [
|
|
Align(
|
|
alignment: Alignment.center,
|
|
child: AnimatedContainer(
|
|
duration: const Duration(milliseconds: 120),
|
|
curve: Curves.easeOut,
|
|
width: 34,
|
|
height: 34,
|
|
decoration: BoxDecoration(
|
|
shape: BoxShape.circle,
|
|
color: speaking
|
|
? theme.colorScheme.primaryContainer
|
|
: theme.colorScheme.surfaceContainerHighest,
|
|
border: speaking
|
|
? Border.all(color: theme.colorScheme.primary, width: 2)
|
|
: null,
|
|
boxShadow: speaking
|
|
? [
|
|
BoxShadow(
|
|
color: theme.colorScheme.primary.withValues(
|
|
alpha: 0.20,
|
|
),
|
|
blurRadius: 8,
|
|
spreadRadius: 1,
|
|
),
|
|
]
|
|
: null,
|
|
),
|
|
child: Center(
|
|
child: Text(
|
|
initial,
|
|
style: theme.textTheme.labelLarge?.copyWith(
|
|
color: speaking
|
|
? theme.colorScheme.onPrimaryContainer
|
|
: theme.colorScheme.onSurfaceVariant,
|
|
fontWeight: FontWeight.w700,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
if (badgeIcon != null && badgeColor != null && badgeTooltip != null)
|
|
Positioned(
|
|
right: 0,
|
|
bottom: 0,
|
|
child: Tooltip(
|
|
message: badgeTooltip!,
|
|
child: Container(
|
|
width: 18,
|
|
height: 18,
|
|
decoration: BoxDecoration(
|
|
shape: BoxShape.circle,
|
|
color: theme.colorScheme.surface,
|
|
border: Border.all(color: theme.colorScheme.outlineVariant),
|
|
),
|
|
child: Icon(badgeIcon, size: 12, color: badgeColor),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _ClientVolumeIndicator extends StatelessWidget {
|
|
const _ClientVolumeIndicator({required this.preference});
|
|
|
|
final _ClientVolumePreference preference;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final theme = Theme.of(context);
|
|
final percent = (preference.volume * 100).round();
|
|
return Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
if (preference.isEffectivelyMuted)
|
|
Tooltip(
|
|
message: 'Locally muted',
|
|
child: Icon(
|
|
Icons.volume_off,
|
|
size: 18,
|
|
color: theme.colorScheme.error,
|
|
),
|
|
),
|
|
if ((preference.volume - 1.0).abs() > 0.001) ...[
|
|
if (preference.isEffectivelyMuted) const SizedBox(width: 6),
|
|
Container(
|
|
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
|
decoration: BoxDecoration(
|
|
color: theme.colorScheme.surfaceContainerHighest,
|
|
borderRadius: BorderRadius.circular(999),
|
|
),
|
|
child: Text(
|
|
'$percent%',
|
|
style: theme.textTheme.labelSmall?.copyWith(
|
|
color: preference.isEffectivelyMuted
|
|
? theme.colorScheme.error
|
|
: theme.colorScheme.onSurfaceVariant,
|
|
fontWeight: FontWeight.w600,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
class _SpacerChannelContent extends StatelessWidget {
|
|
const _SpacerChannelContent({required this.spacer});
|
|
|
|
final SpacerChannelNameParseResult spacer;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final theme = Theme.of(context);
|
|
final color = theme.colorScheme.onSurfaceVariant;
|
|
|
|
if (spacer.isBlankSpacer) {
|
|
return const SizedBox(height: 20);
|
|
}
|
|
|
|
if (spacer.specialType != null) {
|
|
return SizedBox(
|
|
height: 22,
|
|
child: CustomPaint(
|
|
painter: _SpacerLinePainter(
|
|
color: color.withValues(alpha: 0.72),
|
|
type: spacer.specialType!,
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
if (spacer.isRepeating) {
|
|
return LayoutBuilder(
|
|
builder: (context, constraints) {
|
|
final pattern = spacer.text.isEmpty ? ' ' : spacer.text;
|
|
final estimatedColumns = (constraints.maxWidth / 8).ceil().clamp(
|
|
1,
|
|
256,
|
|
);
|
|
return Text(
|
|
channelSpacerLabel(
|
|
formatSpacerChannelName(
|
|
SpacerChannelNameFormatOptions(
|
|
alignment: spacer.alignment,
|
|
isRepeating: true,
|
|
uniqueSuffix: spacer.uniqueSuffix,
|
|
text: pattern,
|
|
),
|
|
),
|
|
repeatColumns: estimatedColumns,
|
|
),
|
|
maxLines: 1,
|
|
overflow: TextOverflow.clip,
|
|
softWrap: false,
|
|
style: theme.textTheme.bodyMedium?.copyWith(color: color),
|
|
);
|
|
},
|
|
);
|
|
}
|
|
|
|
return Text(
|
|
spacer.text,
|
|
textAlign: switch (spacer.alignment) {
|
|
SpacerAlignment.left => TextAlign.left,
|
|
SpacerAlignment.right => TextAlign.right,
|
|
SpacerAlignment.center => TextAlign.center,
|
|
null => TextAlign.center,
|
|
},
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: theme.textTheme.bodyMedium?.copyWith(
|
|
color: color,
|
|
fontWeight: FontWeight.w600,
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _SpacerLinePainter extends CustomPainter {
|
|
const _SpacerLinePainter({required this.color, required this.type});
|
|
|
|
final Color color;
|
|
final SpacerSpecialType type;
|
|
|
|
@override
|
|
void paint(Canvas canvas, Size size) {
|
|
final y = size.height / 2;
|
|
final paint = Paint()
|
|
..color = color
|
|
..strokeCap = StrokeCap.square
|
|
..strokeWidth = 1.4;
|
|
|
|
switch (type) {
|
|
case SpacerSpecialType.solidLine:
|
|
canvas.drawLine(Offset(0, y), Offset(size.width, y), paint);
|
|
case SpacerSpecialType.dashLine:
|
|
_drawPattern(canvas, size.width, y, paint, const [8, 5]);
|
|
case SpacerSpecialType.dotLine:
|
|
final dotPaint = Paint()..color = color;
|
|
for (var x = 1.5; x < size.width; x += 7) {
|
|
canvas.drawCircle(Offset(x, y), 1.5, dotPaint);
|
|
}
|
|
case SpacerSpecialType.dashDotLine:
|
|
_drawPattern(canvas, size.width, y, paint, const [10, 4, 2, 4]);
|
|
case SpacerSpecialType.dashDotDotLine:
|
|
_drawPattern(canvas, size.width, y, paint, const [10, 4, 2, 4, 2, 4]);
|
|
}
|
|
}
|
|
|
|
void _drawPattern(
|
|
Canvas canvas,
|
|
double width,
|
|
double y,
|
|
Paint paint,
|
|
List<double> pattern,
|
|
) {
|
|
var x = 0.0;
|
|
var index = 0;
|
|
while (x < width) {
|
|
final length = pattern[index % pattern.length];
|
|
if (index.isEven) {
|
|
final end = x + length > width ? width : x + length;
|
|
canvas.drawLine(Offset(x, y), Offset(end, y), paint);
|
|
}
|
|
x += length;
|
|
index += 1;
|
|
}
|
|
}
|
|
|
|
@override
|
|
bool shouldRepaint(covariant _SpacerLinePainter oldDelegate) {
|
|
return oldDelegate.color != color || oldDelegate.type != type;
|
|
}
|
|
}
|
|
|
|
class _ChannelTree {
|
|
const _ChannelTree({required this.roots});
|
|
|
|
final List<_ChannelTreeNode> roots;
|
|
}
|
|
|
|
class _ChannelTreeNode {
|
|
_ChannelTreeNode(this.channel);
|
|
|
|
final rust.BridgeChannel channel;
|
|
final List<_ChannelTreeNode> children = [];
|
|
}
|
|
|
|
_ChannelTree _buildChannelTree(List<rust.BridgeChannel> channels) {
|
|
final byParent = <BigInt, List<rust.BridgeChannel>>{};
|
|
final knownIds = {for (final channel in channels) channel.id};
|
|
|
|
for (final channel in channels) {
|
|
final parent = knownIds.contains(channel.parent)
|
|
? channel.parent
|
|
: BigInt.zero;
|
|
byParent.putIfAbsent(parent, () => []).add(channel);
|
|
}
|
|
|
|
_ChannelTreeNode buildNode(rust.BridgeChannel channel) {
|
|
final node = _ChannelTreeNode(channel);
|
|
for (final child in byParent[channel.id] ?? const <rust.BridgeChannel>[]) {
|
|
node.children.add(buildNode(child));
|
|
}
|
|
return node;
|
|
}
|
|
|
|
return _ChannelTree(
|
|
roots: [
|
|
for (final channel
|
|
in byParent[BigInt.zero] ?? const <rust.BridgeChannel>[])
|
|
buildNode(channel),
|
|
],
|
|
);
|
|
}
|
|
|
|
class _WelcomeMessageTile extends StatelessWidget {
|
|
const _WelcomeMessageTile({
|
|
super.key,
|
|
required this.welcomeMessage,
|
|
required this.expanded,
|
|
required this.onToggle,
|
|
this.onTs3ServerLink,
|
|
});
|
|
|
|
final String welcomeMessage;
|
|
final bool expanded;
|
|
final VoidCallback onToggle;
|
|
final Ts3ServerLinkHandler? onTs3ServerLink;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final theme = Theme.of(context);
|
|
return Container(
|
|
decoration: BoxDecoration(
|
|
color: theme.colorScheme.surfaceContainerHighest,
|
|
borderRadius: BorderRadius.circular(6),
|
|
),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
InkWell(
|
|
onTap: () {
|
|
HapticFeedback.selectionClick();
|
|
onToggle();
|
|
},
|
|
borderRadius: const BorderRadius.vertical(top: Radius.circular(6)),
|
|
child: Padding(
|
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
|
|
child: Row(
|
|
children: [
|
|
Icon(
|
|
expanded ? Icons.expand_less : Icons.expand_more,
|
|
size: 18,
|
|
color: theme.colorScheme.onSurfaceVariant,
|
|
),
|
|
const SizedBox(width: 4),
|
|
Text(
|
|
AppL10n.of(context).serverWelcomeHeading,
|
|
style: theme.textTheme.labelMedium?.copyWith(
|
|
color: theme.colorScheme.onSurfaceVariant,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
AnimatedSwitcher(
|
|
duration: const Duration(milliseconds: 200),
|
|
child: expanded
|
|
? Padding(
|
|
key: const ValueKey('welcome-expanded'),
|
|
padding: const EdgeInsets.fromLTRB(8, 0, 8, 8),
|
|
child: BbCodeText(
|
|
welcomeMessage,
|
|
linkTrust: LinkTrustService.instance,
|
|
onTs3ServerLink: onTs3ServerLink,
|
|
),
|
|
)
|
|
: const SizedBox(
|
|
key: ValueKey('welcome-collapsed'),
|
|
width: double.infinity,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _ClientVolumeSheet extends StatefulWidget {
|
|
const _ClientVolumeSheet({required this.client});
|
|
|
|
final rust.BridgeClient client;
|
|
|
|
@override
|
|
State<_ClientVolumeSheet> createState() => _ClientVolumeSheetState();
|
|
}
|
|
|
|
class _ClientVolumeSheetState extends State<_ClientVolumeSheet> {
|
|
static const _maxVolume = 1.5;
|
|
final _prefs = _ClientVolumePreferences.instance;
|
|
double _volume = 1.0;
|
|
bool _muted = false;
|
|
double _volumeBeforeMute = 1.0;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
final preference = _prefs.preferenceFor(widget.client.id);
|
|
_volume = preference.volume.clamp(0.0, _maxVolume);
|
|
_muted = preference.muted;
|
|
_volumeBeforeMute = _volume <= 0.001 ? 1.0 : _volume;
|
|
}
|
|
|
|
void _applyVolume() {
|
|
_prefs.setPreference(
|
|
widget.client.id,
|
|
_ClientVolumePreference(volume: _volume, muted: _muted),
|
|
);
|
|
rust.setClientVolume(
|
|
clientId: widget.client.id,
|
|
volume: _muted ? 0.0 : _volume,
|
|
);
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final l10n = AppL10n.of(context);
|
|
final theme = Theme.of(context);
|
|
final percent = (_volume * 100).round();
|
|
final isModified = _volume != 1.0 || _muted;
|
|
|
|
return Padding(
|
|
padding: EdgeInsets.fromLTRB(
|
|
16,
|
|
16,
|
|
16,
|
|
16 + MediaQuery.of(context).viewInsets.bottom,
|
|
),
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Row(
|
|
children: [
|
|
Expanded(
|
|
child: Text(
|
|
l10n.clientVolumeTitle(widget.client.name),
|
|
style: theme.textTheme.titleMedium,
|
|
),
|
|
),
|
|
IconButton(
|
|
icon: Icon(
|
|
_muted ? Icons.volume_off : Icons.volume_up,
|
|
color: _muted ? theme.colorScheme.error : null,
|
|
),
|
|
tooltip: _muted
|
|
? l10n.clientVolumeUnmuteAction
|
|
: l10n.clientVolumeMuteAction,
|
|
onPressed: () {
|
|
setState(() {
|
|
if (!_muted) {
|
|
_volumeBeforeMute = _volume;
|
|
_muted = true;
|
|
} else {
|
|
_muted = false;
|
|
_volume = _volumeBeforeMute;
|
|
}
|
|
});
|
|
_applyVolume();
|
|
},
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 8),
|
|
Row(
|
|
children: [
|
|
Expanded(
|
|
child: Slider(
|
|
value: _volume,
|
|
min: 0.0,
|
|
max: _maxVolume,
|
|
divisions: 30,
|
|
label: l10n.clientVolumeLabel(percent),
|
|
onChanged: _muted
|
|
? null
|
|
: (v) {
|
|
setState(() => _volume = v);
|
|
_applyVolume();
|
|
},
|
|
),
|
|
),
|
|
SizedBox(
|
|
width: 52,
|
|
child: Text(
|
|
l10n.clientVolumeLabel(percent),
|
|
style: theme.textTheme.bodyMedium?.copyWith(
|
|
color: _muted ? theme.colorScheme.error : null,
|
|
),
|
|
textAlign: TextAlign.end,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
if (isModified)
|
|
Align(
|
|
alignment: Alignment.centerLeft,
|
|
child: TextButton.icon(
|
|
icon: const Icon(Icons.restart_alt, size: 18),
|
|
label: Text(l10n.clientVolumeResetAction),
|
|
onPressed: () {
|
|
setState(() {
|
|
_volume = 1.0;
|
|
_muted = false;
|
|
_volumeBeforeMute = 1.0;
|
|
});
|
|
_applyVolume();
|
|
},
|
|
),
|
|
),
|
|
const SizedBox(height: 8),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Context menu for channel tiles offering "Chat" on right-click or
|
|
/// long-press. Primary tap passes through to the child for voice join.
|
|
class _ChannelContextMenu extends StatelessWidget {
|
|
const _ChannelContextMenu({
|
|
required this.channel,
|
|
this.onChat,
|
|
required this.child,
|
|
});
|
|
|
|
final rust.BridgeChannel channel;
|
|
final VoidCallback? onChat;
|
|
final Widget child;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
if (onChat == null) return child;
|
|
|
|
return GestureDetector(
|
|
behavior: HitTestBehavior.opaque,
|
|
onSecondaryTapDown: (details) =>
|
|
_show(context, details.globalPosition),
|
|
onLongPressStart: (details) =>
|
|
_show(context, details.globalPosition),
|
|
child: child,
|
|
);
|
|
}
|
|
|
|
void _show(BuildContext context, Offset globalPosition) {
|
|
final overlay =
|
|
Overlay.of(context).context.findRenderObject() as RenderBox;
|
|
final position = RelativeRect.fromLTRB(
|
|
globalPosition.dx,
|
|
globalPosition.dy,
|
|
overlay.size.width - globalPosition.dx,
|
|
overlay.size.height - globalPosition.dy,
|
|
);
|
|
showMenu<String>(
|
|
context: context,
|
|
position: position,
|
|
items: [
|
|
PopupMenuItem(
|
|
value: 'chat',
|
|
child: Row(
|
|
children: [
|
|
const Icon(Icons.chat_bubble_outline, size: 18),
|
|
const SizedBox(width: 12),
|
|
Text(AppL10n.of(context).chatAction),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
).then((value) {
|
|
if (value == 'chat') onChat?.call();
|
|
});
|
|
}
|
|
}
|