feat: integrate chat voice and diagnostics client
This commit is contained in:
@@ -0,0 +1,665 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.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 with welcome text, channels, and clients.
|
||||
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.onJoinChannel,
|
||||
required this.onJoinChannelWithPassword,
|
||||
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;
|
||||
|
||||
/// Join an unlocked channel.
|
||||
final ValueChanged<rust.BridgeChannel> onJoinChannel;
|
||||
|
||||
/// Join a password-protected channel.
|
||||
final ValueChanged<rust.BridgeChannel> onJoinChannelWithPassword;
|
||||
|
||||
/// 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 = {};
|
||||
bool _welcomeExpanded = true;
|
||||
double _welcomeHeight = 0;
|
||||
final _welcomeKey = GlobalKey();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_scrollController.addListener(_onScroll);
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_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 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 (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 decoration = status.isSpeaking
|
||||
? BoxDecoration(
|
||||
color: theme.colorScheme.primaryContainer.withValues(alpha: 0.45),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(
|
||||
color: theme.colorScheme.primary.withValues(alpha: 0.55),
|
||||
width: 1.2,
|
||||
),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: theme.colorScheme.primary.withValues(alpha: 0.14),
|
||||
blurRadius: 8,
|
||||
spreadRadius: 1,
|
||||
),
|
||||
],
|
||||
)
|
||||
: null;
|
||||
|
||||
return 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: status.icon,
|
||||
title: Text(client.name, style: nameStyle),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
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);
|
||||
});
|
||||
}
|
||||
|
||||
({Widget icon, 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 icon;
|
||||
final Color color;
|
||||
final String tooltip;
|
||||
if (outputMuted) {
|
||||
icon = Icons.volume_off;
|
||||
color = theme.colorScheme.error;
|
||||
tooltip = 'Speaker muted';
|
||||
} else if (inputMuted) {
|
||||
icon = Icons.mic_off;
|
||||
color = theme.colorScheme.error;
|
||||
tooltip = 'Microphone muted';
|
||||
} else if (talkPowerBlocked) {
|
||||
icon = Icons.volume_off;
|
||||
color = theme.colorScheme.error;
|
||||
tooltip =
|
||||
'Insufficient talk power (${client.talkPower} < $neededTalkPower)';
|
||||
} else if (speaking) {
|
||||
icon = isSelf ? Icons.mic : Icons.volume_up;
|
||||
color = theme.colorScheme.primary;
|
||||
tooltip = 'Speaking';
|
||||
} else if (inCurrentChannel) {
|
||||
icon = isSelf ? Icons.mic_none : Icons.volume_up_outlined;
|
||||
color = theme.colorScheme.onSurfaceVariant;
|
||||
tooltip = 'Not speaking';
|
||||
} else {
|
||||
icon = Icons.person_outline;
|
||||
color = theme.colorScheme.onSurfaceVariant;
|
||||
tooltip = 'Outside current channel';
|
||||
}
|
||||
|
||||
return (
|
||||
icon: Tooltip(
|
||||
message: tooltip,
|
||||
child: Icon(icon, color: color),
|
||||
),
|
||||
isSpeaking: speaking,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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(
|
||||
'Server welcome message',
|
||||
style: theme.textTheme.labelMedium?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
AnimatedCrossFade(
|
||||
firstChild: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(8, 0, 8, 8),
|
||||
child: BbCodeText(
|
||||
welcomeMessage,
|
||||
linkTrust: LinkTrustService.instance,
|
||||
onTs3ServerLink: onTs3ServerLink,
|
||||
),
|
||||
),
|
||||
secondChild: const SizedBox(width: double.infinity),
|
||||
crossFadeState: expanded
|
||||
? CrossFadeState.showFirst
|
||||
: CrossFadeState.showSecond,
|
||||
duration: const Duration(milliseconds: 200),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user