feat(ui): adaptive 3-panel layout, chat panel switching, audio metering fix

- Add responsive breakpoints (compact <600, medium 600-1023, expanded >=1024)
- Add ViewportInfo InheritedWidget for layout-aware descendants
- Add inline ChatPanel (380dp right column) for expanded desktop layout
- Add channel right-click context menu with Chat option for in-place switching
- Add per-target draft persistence via restoredDraft/onDraftChanged callbacks
- Fix header chat button to switch to current voice channel when panel open
- Fix close = dismiss (preserves last target and draft for reopen)
- Add unread dot indicator on channel tiles when chat is closed
- Fix audio regression: decimate dBFS computation to every 3rd callback (~31 Hz)
  to avoid buffer underruns on macOS CoreAudio real-time thread
- Add tools/build-macos.sh release build script (7-step process)
- Add chat panel switching implementation plan and 3-panel design spec

Tests: 183 passed, 2 skipped. Flutter analyze clean.
This commit is contained in:
Edison Jwa
2026-06-07 23:12:07 +09:00
parent e7f7c55b30
commit 5e8b7915db
14 changed files with 2187 additions and 283 deletions
@@ -0,0 +1,69 @@
// SPDX-License-Identifier: Apache-2.0
// Canonical layout breakpoints for Chanora.
//
// Aligned with Material 3 adaptive layout guidance:
// compact < 600dp — phone, narrow tablet
// medium 6001023 — tablet portrait, small desktop window
// expanded ≥ 1024dp — desktop, tablet landscape
//
// 1024dp was chosen as the expanded threshold based on production app
// research: Discord (member list at 1024px), Mattermost (RHS docked at
// ≥ 1024px), and Rocket.Chat (contextual bar persistent at lg/1024px).
/// Canonical breakpoint thresholds in logical pixels.
///
/// Use these instead of hardcoded pixel values in layout decisions.
/// Migrate existing `_wideBreakpoint` / `_chatMobileBreakpoint` references
/// to these named constants.
class ChanoraBreakpoints {
ChanoraBreakpoints._();
/// Width at which the layout switches from compact to medium.
/// Below this: single-column mobile layout.
/// At/above: two-panel side-by-side layout.
static const double medium = 600;
/// Width at which the layout switches from medium to expanded.
/// Below this: chat opens as a pushed route.
/// At/above: three-panel layout with inline chat panel.
static const double expanded = 1024;
// Panel sizing constants.
/// Fixed width of the left voice/control panel.
static const double voicePanelWidth = 320;
/// Fixed width of the right chat panel (expanded layout only).
static const double chatPanelWidth = 380;
/// Horizontal gap between panels.
static const double panelGap = 12;
/// Desktop snackbar width cap (used when width ≥ [medium]).
static const double snackBarDesktopCap = 560;
/// Connect form action buttons switch from row to column below this width.
static const double connectActionsStackMaxWidth = 400;
/// Modal bottom sheet max height as fraction of screen height.
static const double modalSheetHeightFraction = 0.72;
}
/// Semantic layout class derived from viewport width.
enum LayoutClass {
/// < 600dp — single-column mobile layout.
compact,
/// 6001023dp — two-panel side-by-side layout.
medium,
/// ≥ 1024dp — three-panel layout with inline chat.
expanded,
}
/// Computes the current [LayoutClass] from viewport [width].
LayoutClass layoutClassFromWidth(double width) {
if (width >= ChanoraBreakpoints.expanded) return LayoutClass.expanded;
if (width >= ChanoraBreakpoints.medium) return LayoutClass.medium;
return LayoutClass.compact;
}
@@ -0,0 +1,64 @@
// SPDX-License-Identifier: Apache-2.0
// Viewport info inherited widget for Chanora.
//
// Computes [LayoutClass] once per frame from the current [MediaQuery] size
// and provides it to the entire widget subtree. Downstream widgets read
// `ViewportInfo.of(context)` instead of calling `LayoutBuilder` or
// `MediaQuery.sizeOf` directly for layout-class decisions.
import 'package:flutter/widgets.dart';
import 'breakpoints.dart';
/// Inherited widget that exposes the current layout class and viewport
/// dimensions to the entire subtree.
///
/// Insert this once near the top of the widget tree (inside the Scaffold
/// body or equivalent). All descendants can then read
/// `ViewportInfo.of(context)` to determine their layout behaviour.
class ViewportInfo extends InheritedWidget {
/// Creates a [ViewportInfo].
const ViewportInfo({
super.key,
required this.layoutClass,
required this.width,
required this.height,
required super.child,
});
/// Current layout class derived from viewport width.
final LayoutClass layoutClass;
/// Current viewport width in logical pixels.
final double width;
/// Current viewport height in logical pixels.
final double height;
/// Returns the nearest [ViewportInfo] in the widget tree.
///
/// Asserts that a [ViewportInfo] ancestor exists.
static ViewportInfo of(BuildContext context) {
final info = context.dependOnInheritedWidgetOfExactType<ViewportInfo>();
assert(info != null, 'No ViewportInfo found in widget tree');
return info!;
}
/// Whether the current layout is compact (< 600dp).
bool get isCompact => layoutClass == LayoutClass.compact;
/// Whether the current layout is medium (6001023dp).
bool get isMedium => layoutClass == LayoutClass.medium;
/// Whether the current layout is expanded (≥ 1024dp).
bool get isExpanded => layoutClass == LayoutClass.expanded;
/// Whether the layout has room for at least two panels (medium or expanded).
bool get isWide => !isCompact;
@override
bool updateShouldNotify(ViewportInfo old) =>
layoutClass != old.layoutClass ||
width != old.width ||
height != old.height;
}
+402 -223
View File
@@ -16,6 +16,8 @@ import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_foreground_task/flutter_foreground_task.dart';
import 'design/breakpoints.dart';
import 'design/viewport_info.dart';
import 'l10n/generated/app_localizations.dart';
import 'services/android_permissions_service.dart';
import 'services/app_bootstrap.dart';
@@ -31,12 +33,13 @@ import 'services/ui_preferences_service.dart';
import 'src/rust/api.dart' as rust;
import 'src/rust/frb_generated.dart';
import 'src/rust/lib.dart' as rust_err;
import 'widgets/permission_state_banner.dart';
import 'widgets/audio_processing_config_state.dart';
import 'widgets/chat_panel.dart';
import 'widgets/chat_views.dart';
import 'widgets/client_info_sheet.dart';
import 'widgets/connect_widgets.dart';
import 'widgets/input_dialogs.dart';
import 'widgets/permission_state_banner.dart';
import 'widgets/snapshot_view.dart';
import 'widgets/voice_platform.dart';
import 'widgets/voice_bar.dart';
@@ -301,8 +304,6 @@ class _ReceivedPoke {
}
class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
static const _wideBreakpoint = 600.0;
final _hostCtl = TextEditingController(text: 'cn.teamspeak.app');
final _nickCtl = TextEditingController(text: 'ChanoraBeta');
final _passwordCtl = TextEditingController();
@@ -368,6 +369,37 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
final ValueNotifier<int> _chatFeedRevision = ValueNotifier(0);
int _chatUnread = 0;
bool _chatOpen = false;
rust.BridgeMessageTarget? _inlineChatTarget;
String _inlineChatClientName = '';
bool _inlineChatCollapseNoticeShown = false;
/// Per-target draft text. Populated when switching away from a conversation
/// so the user's unfinished message is preserved.
final Map<String, String> _chatDrafts = {};
/// Channel IDs that have unread chat messages (used for dot indicators).
Set<BigInt> get _unreadChannelIds {
if (_chatOpen) return const {};
final ids = <BigInt>{};
for (final entry in _chatMessages) {
if (!entry.countsTowardUnread || entry.isSelf) continue;
final target = entry.target;
if (target is rust.BridgeMessageTarget_Channel) {
// Channel target has no ID payload — it means "current channel".
// We can't distinguish per-channel without the channel ID in the target.
// For now, if there are any unread channel messages, mark the current voice channel.
if (_currentVoiceChannelId != null) {
ids.add(_currentVoiceChannelId!);
}
}
}
return ids;
}
/// The last chat target before the panel was closed. Used to restore the
/// previous conversation when the user reopens chat.
rust.BridgeMessageTarget? _lastDismissedTarget;
String _lastDismissedClientName = '';
final ValueNotifier<List<_ReceivedPoke>> _pokeSnackBarPokes = ValueNotifier(
const [],
);
@@ -1613,16 +1645,86 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
_reconnectAttempt = null;
_reconnectDelay = null;
_chatMessages.clear();
_chatDrafts.clear();
_chatUnread = 0;
_chatOpen = false;
_inlineChatTarget = null;
_inlineChatClientName = '';
_inlineChatCollapseNoticeShown = false;
_lastDismissedTarget = null;
_lastDismissedClientName = '';
_notifyChatFeedChanged();
}
/// Converts a [rust.BridgeMessageTarget] to a stable string key for draft storage.
String _draftKeyForTarget(rust.BridgeMessageTarget target) {
return switch (target) {
rust.BridgeMessageTarget_Server() => 'server',
rust.BridgeMessageTarget_Channel() => 'channel',
rust.BridgeMessageTarget_Client(:final field0) => 'client:$field0',
rust.BridgeMessageTarget_Poke(:final field0) => 'poke:$field0',
};
}
/// Saves the current draft text for the current inline chat target.
void _saveCurrentDraft() {
// Drafts are continuously saved via onDraftChanged callback in Task 4.
// This method exists as an explicit save point.
final target = _inlineChatTarget;
if (target == null) return;
final key = _draftKeyForTarget(target);
if (_chatDrafts.containsKey(key)) {
_chatDrafts[key] = _chatDrafts[key] ?? '';
}
}
Future<void> _onOpenChat({
rust.BridgeMessageTarget? target,
String clientName = '',
}) async {
final initialSnapshot = _snapshot!;
// Resolve the new target.
// If no target passed and panel is already open, switch to current voice channel.
// If no target passed and panel is closed, resolve from history or default.
rust.BridgeMessageTarget newTarget;
if (target != null) {
newTarget = target;
} else if (_chatOpen && _currentVoiceChannelId != null) {
newTarget = const rust.BridgeMessageTarget.channel();
} else if (_inlineChatTarget != null) {
newTarget = _inlineChatTarget!;
} else {
newTarget =
resolveInitialChatTarget(
messages: _chatMessages,
currentVoiceChannelId: _currentVoiceChannelId,
) ??
const rust.BridgeMessageTarget.server();
}
final newClientName = clientName.isNotEmpty
? clientName
: (newTarget == _inlineChatTarget)
? _inlineChatClientName
: (newTarget == _lastDismissedTarget)
? _lastDismissedClientName
: '';
final isExpanded =
layoutClassFromWidth(MediaQuery.sizeOf(context).width) ==
LayoutClass.expanded;
if (isExpanded) {
setState(() {
_saveCurrentDraft();
_chatUnread = 0;
_chatOpen = true;
_inlineChatTarget = newTarget;
_inlineChatClientName = newClientName;
_inlineChatCollapseNoticeShown = false;
});
return;
}
setState(() {
_chatUnread = 0;
_chatOpen = true;
@@ -1635,13 +1737,8 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
messagesSource: () => _chatMessages,
snapshotSource: () => _snapshot ?? initialSnapshot,
refreshListenable: _chatFeedRevision,
initialTarget:
target ??
resolveInitialChatTarget(
messages: _chatMessages,
currentVoiceChannelId: _currentVoiceChannelId,
),
initialClientName: clientName,
initialTarget: newTarget,
initialClientName: newClientName,
onTs3ServerLink: _onTs3ServerLink,
),
),
@@ -1649,6 +1746,48 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
if (mounted) setState(() => _chatOpen = false);
}
void _closeInlineChat() {
setState(() {
_saveCurrentDraft();
_lastDismissedTarget = _inlineChatTarget;
_lastDismissedClientName = _inlineChatClientName;
_chatOpen = false;
// Do NOT null _inlineChatTarget — remember it for reopen.
});
}
void _handleInlineChatViewport(LayoutClass layoutClass) {
if (_inlineChatTarget == null) return;
if (layoutClass == LayoutClass.expanded) {
_inlineChatCollapseNoticeShown = false;
if (!_chatOpen) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted && _inlineChatTarget != null) {
setState(() => _chatOpen = true);
}
});
}
return;
}
if (_chatOpen) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted && _inlineChatTarget != null) {
setState(() => _chatOpen = false);
}
});
}
if (_inlineChatCollapseNoticeShown) return;
_inlineChatCollapseNoticeShown = true;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted || _inlineChatTarget == null) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Tap the chat button to continue your conversation'),
),
);
});
}
Future<void> _onOpenClientInfo(rust.BridgeClient client) async {
await showModalBottomSheet<void>(
context: context,
@@ -1757,7 +1896,7 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
const side = 16.0;
var bottom = 16.0;
final wideConnectedLayout =
MediaQuery.sizeOf(context).width >= _wideBreakpoint &&
MediaQuery.sizeOf(context).width >= ChanoraBreakpoints.medium &&
_serverReachable &&
_snapshot != null;
@@ -2245,7 +2384,8 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
)
: headerTitle;
final compactIdleChrome =
!_serverReachable && MediaQuery.sizeOf(context).width < _wideBreakpoint;
!_serverReachable &&
MediaQuery.sizeOf(context).width < ChanoraBreakpoints.medium;
final compactIdleHeader = Row(
children: [
Expanded(
@@ -2277,8 +2417,10 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
final bodyContent = LayoutBuilder(
builder: (ctx, bodyConstraints) {
final layoutClass = layoutClassFromWidth(bodyConstraints.maxWidth);
_handleInlineChatViewport(layoutClass);
final isWideSnapshot =
bodyConstraints.maxWidth >= _wideBreakpoint &&
bodyConstraints.maxWidth >= ChanoraBreakpoints.medium &&
_serverReachable &&
_snapshot != null;
final banner = Container(
@@ -2292,237 +2434,274 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
style: TextStyle(color: theme.colorScheme.onTertiaryContainer),
),
);
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (!isWideSnapshot) ...[banner, const SizedBox(height: 12)],
if (!_serverReachable)
Row(
children: [
Icon(connTokens.icon, size: 18, color: connTokens.color),
const SizedBox(width: 6),
Expanded(
child: Text(
statusText,
softWrap: true,
style: theme.textTheme.titleMedium,
),
),
],
),
if (_lostReason != null || _reconnectAttempt != null) ...[
const SizedBox(height: 8),
Container(
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color:
connTokens.background ?? theme.colorScheme.errorContainer,
borderRadius: BorderRadius.circular(8),
),
child: Row(
return ViewportInfo(
layoutClass: layoutClass,
width: bodyConstraints.maxWidth,
height: bodyConstraints.maxHeight,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (!isWideSnapshot) ...[banner, const SizedBox(height: 12)],
if (!_serverReachable)
Row(
children: [
SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(
strokeWidth: 2,
color: theme.colorScheme.onErrorContainer,
),
),
const SizedBox(width: 10),
Icon(connTokens.icon, size: 18, color: connTokens.color),
const SizedBox(width: 6),
Expanded(
child: Text(
_reconnectAttempt != null
? l10n.statusReconnecting(
_reconnectAttempt!,
_reconnectDelay ?? 0,
)
: l10n.statusConnectionLost(_lostReason ?? ''),
style: TextStyle(
color: theme.colorScheme.onErrorContainer,
),
statusText,
softWrap: true,
style: theme.textTheme.titleMedium,
),
),
],
),
),
],
const SizedBox(height: 12),
if (_phase == ConnectionPhase.idle ||
_phase == ConnectionPhase.disconnected) ...[
Expanded(
child: AnimatedPadding(
duration: const Duration(milliseconds: 180),
curve: Curves.easeOut,
padding: EdgeInsets.only(
bottom: MediaQuery.viewInsetsOf(ctx).bottom,
if (_lostReason != null || _reconnectAttempt != null) ...[
const SizedBox(height: 8),
Container(
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color:
connTokens.background ??
theme.colorScheme.errorContainer,
borderRadius: BorderRadius.circular(8),
),
child: SingleChildScrollView(
keyboardDismissBehavior:
ScrollViewKeyboardDismissBehavior.onDrag,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
ConnectForm(
hostCtl: _hostCtl,
nickCtl: _nickCtl,
passwordCtl: _passwordCtl,
onConnect: () => _onConnect(),
onAddBookmark: _onAddCurrentBookmark,
child: Row(
children: [
SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(
strokeWidth: 2,
color: theme.colorScheme.onErrorContainer,
),
const SizedBox(height: 16),
BookmarkList(
bookmarks: _bookmarks,
onConnect: _onUseBookmark,
onDelete: _onDeleteBookmark,
),
const SizedBox(width: 10),
Expanded(
child: Text(
_reconnectAttempt != null
? l10n.statusReconnecting(
_reconnectAttempt!,
_reconnectDelay ?? 0,
)
: l10n.statusConnectionLost(_lostReason ?? ''),
style: TextStyle(
color: theme.colorScheme.onErrorContainer,
),
),
],
),
),
],
),
),
),
] else if (_phase == ConnectionPhase.connecting ||
awaitingServerSnapshot) ...[
Expanded(
child: Center(
child: Padding(
padding: const EdgeInsets.all(32),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const CircularProgressIndicator(),
const SizedBox(height: 16),
Text(
statusText,
textAlign: TextAlign.center,
style: theme.textTheme.titleMedium,
),
],
],
const SizedBox(height: 12),
if (_phase == ConnectionPhase.idle ||
_phase == ConnectionPhase.disconnected) ...[
Expanded(
child: AnimatedPadding(
duration: const Duration(milliseconds: 180),
curve: Curves.easeOut,
padding: EdgeInsets.only(
bottom: MediaQuery.viewInsetsOf(ctx).bottom,
),
),
),
),
] else if (_serverReachable && _snapshot != null) ...[
Expanded(
child: LayoutBuilder(
builder: (ctx, constraints) {
final voiceBar = VoiceBar(
inChannel: _inChannel,
transmitMode: _transmitMode,
hardMute: _hardMute,
outputMuted: _outputMuted,
talkPowerBlocked: _hardMuteByTalkPower,
releaseTailMs: _releaseTailMs,
channelName: snapshotChannelName(
_snapshot,
_currentVoiceChannelId,
),
audioStats: _audioStats,
inputLevel: _inputLevel,
pttLevel: _pttLevel,
pttBackendId: _pttBackendId,
pttBoundInputClass: _pttBoundInputClass,
pttBoundKeyLabel: _pttBoundKeyLabel,
onConfigure: _onOpenVoiceSettings,
onPttHeldChanged: _onOnscreenPttHeldChanged,
);
// SDD-106 §2/§3 + SRS-209 + SRS-164: listen-only
// banner. Self-hides on granted / unknown.
final permissionBanner =
PermissionStateBanner.fromCallbacks(
recordAudioState: _activeRecordAudioState,
ensureRecordAudio: _ensureActiveRecordAudio,
openAppSettings: _openActivePermissionSettings,
);
final snapshotView = SnapshotView(
snapshot: _snapshot!,
audioStats: _audioStats,
currentVoiceChannelId: _currentVoiceChannelId,
pendingVoiceChannelId: _pendingVoiceChannelId,
localInputMuted: _inputMuted || _hardMute,
localOutputMuted: _outputMuted,
hasJoinPending: _pendingVoiceChannelId != null,
canJoinVoiceChannel: _canJoinVoiceChannel,
onJoinChannel: (ch) => _onJoinChannel(ch),
onJoinChannelWithPassword: (ch) =>
_onJoinChannel(ch, askForPassword: true),
enableClientLongPressMenu: isTouchOnlyPttHost,
onOpenClientInfo: (client) =>
unawaited(_onOpenClientInfo(client)),
onOpenClientChat: (client) => unawaited(
_onOpenChat(
target: rust.BridgeMessageTarget.client(client.id),
clientName: client.name,
),
),
onOpenClientPoke: (client) => unawaited(
_onOpenChat(
target: rust.BridgeMessageTarget.poke(client.id),
clientName: client.name,
),
),
onTs3ServerLink: _onTs3ServerLink,
);
final ownClientState = _ownClientState;
const voiceBarWidthWide = 320.0;
if (constraints.maxWidth >= _wideBreakpoint) {
return Row(
child: SingleChildScrollView(
keyboardDismissBehavior:
ScrollViewKeyboardDismissBehavior.onDrag,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
SizedBox(
width: voiceBarWidthWide,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
banner,
const SizedBox(height: 12),
permissionBanner,
voiceBar,
],
),
ConnectForm(
hostCtl: _hostCtl,
nickCtl: _nickCtl,
passwordCtl: _passwordCtl,
onConnect: () => _onConnect(),
onAddBookmark: _onAddCurrentBookmark,
),
const SizedBox(width: 12),
const SizedBox(height: 16),
BookmarkList(
bookmarks: _bookmarks,
onConnect: _onUseBookmark,
onDelete: _onDeleteBookmark,
),
],
),
),
),
),
] else if (_phase == ConnectionPhase.connecting ||
awaitingServerSnapshot) ...[
Expanded(
child: Center(
child: Padding(
padding: const EdgeInsets.all(32),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const CircularProgressIndicator(),
const SizedBox(height: 16),
Text(
statusText,
textAlign: TextAlign.center,
style: theme.textTheme.titleMedium,
),
],
),
),
),
),
] else if (_serverReachable && _snapshot != null) ...[
Expanded(
child: LayoutBuilder(
builder: (ctx, constraints) {
final voiceBar = VoiceBar(
inChannel: _inChannel,
transmitMode: _transmitMode,
hardMute: _hardMute,
outputMuted: _outputMuted,
talkPowerBlocked: _hardMuteByTalkPower,
releaseTailMs: _releaseTailMs,
channelName: snapshotChannelName(
_snapshot,
_currentVoiceChannelId,
),
audioStats: _audioStats,
inputLevel: _inputLevel,
pttLevel: _pttLevel,
pttBackendId: _pttBackendId,
pttBoundInputClass: _pttBoundInputClass,
pttBoundKeyLabel: _pttBoundKeyLabel,
onConfigure: _onOpenVoiceSettings,
onPttHeldChanged: _onOnscreenPttHeldChanged,
);
// SDD-106 §2/§3 + SRS-209 + SRS-164: listen-only
// banner. Self-hides on granted / unknown.
final permissionBanner =
PermissionStateBanner.fromCallbacks(
recordAudioState: _activeRecordAudioState,
ensureRecordAudio: _ensureActiveRecordAudio,
openAppSettings: _openActivePermissionSettings,
);
final snapshotView = SnapshotView(
snapshot: _snapshot!,
audioStats: _audioStats,
currentVoiceChannelId: _currentVoiceChannelId,
pendingVoiceChannelId: _pendingVoiceChannelId,
localInputMuted: _inputMuted || _hardMute,
localOutputMuted: _outputMuted,
hasJoinPending: _pendingVoiceChannelId != null,
canJoinVoiceChannel: _canJoinVoiceChannel,
unreadChannelIds: _unreadChannelIds,
onJoinChannel: (ch) => _onJoinChannel(ch),
onJoinChannelWithPassword: (ch) =>
_onJoinChannel(ch, askForPassword: true),
enableClientLongPressMenu: isTouchOnlyPttHost,
onOpenClientInfo: (client) =>
unawaited(_onOpenClientInfo(client)),
onOpenClientChat: (client) => unawaited(
_onOpenChat(
target: rust.BridgeMessageTarget.client(client.id),
clientName: client.name,
),
),
onOpenClientPoke: (client) => unawaited(
_onOpenChat(
target: rust.BridgeMessageTarget.poke(client.id),
clientName: client.name,
),
),
onOpenChannelChat: (channel) => unawaited(
_onOpenChat(
target: const rust.BridgeMessageTarget.channel(),
),
),
onTs3ServerLink: _onTs3ServerLink,
);
final ownClientState = _ownClientState;
final layoutInfo = ViewportInfo.of(ctx);
if (layoutInfo.isWide) {
final inlineChatTarget = _inlineChatTarget;
return Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
SizedBox(
width: ChanoraBreakpoints.voicePanelWidth,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
banner,
const SizedBox(height: 12),
permissionBanner,
voiceBar,
],
),
),
const SizedBox(width: ChanoraBreakpoints.panelGap),
Expanded(child: snapshotView),
if (layoutInfo.isExpanded &&
inlineChatTarget != null) ...[
const SizedBox(
width: ChanoraBreakpoints.panelGap,
),
ChatPanel(
messages: _chatMessages,
snapshot: _snapshot!,
target: inlineChatTarget,
clientName: _inlineChatClientName,
restoredDraft:
_chatDrafts[_draftKeyForTarget(
inlineChatTarget,
)],
onDraftChanged: (text) {
final draftKey = _draftKeyForTarget(
inlineChatTarget,
);
_chatDrafts[draftKey] = text;
},
onTs3ServerLink: _onTs3ServerLink,
onClose: _closeInlineChat,
),
],
],
);
}
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Expanded(child: snapshotView),
const SizedBox(height: 8),
permissionBanner,
VoiceStatusChip(
transmitMode: _transmitMode,
releaseTailMs: _releaseTailMs,
pttBoundKeyLabel: _pttBoundKeyLabel,
audioStats: _audioStats,
isTouchOnly: isTouchOnlyPttHost,
inputMuted: _hardMute,
outputMuted: _outputMuted,
hardMuteByTalkPower: _hardMuteByTalkPower,
talkPower: ownClientState?.talkPower,
neededTalkPower: ownClientState?.neededTalkPower,
talkPowerGranted: ownClientState?.talkPowerGranted,
onTap: () => _onOpenVoiceDetailsSheet(),
onToggleInputMute: _onToggleHardMute,
onToggleOutputMute: _toggleOutputMute,
),
if (_inChannel &&
_transmitMode == rust.BridgeTransmitMode.ptt) ...[
const SizedBox(height: 8),
VoicePttButton(
active: _audioStats?.pttActive ?? false,
onHeldChanged: _onOnscreenPttHeldChanged,
),
],
],
);
}
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Expanded(child: snapshotView),
const SizedBox(height: 8),
permissionBanner,
VoiceStatusChip(
transmitMode: _transmitMode,
releaseTailMs: _releaseTailMs,
pttBoundKeyLabel: _pttBoundKeyLabel,
audioStats: _audioStats,
isTouchOnly: isTouchOnlyPttHost,
inputMuted: _hardMute,
outputMuted: _outputMuted,
hardMuteByTalkPower: _hardMuteByTalkPower,
talkPower: ownClientState?.talkPower,
neededTalkPower: ownClientState?.neededTalkPower,
talkPowerGranted: ownClientState?.talkPowerGranted,
onTap: () => _onOpenVoiceDetailsSheet(),
onToggleInputMute: _onToggleHardMute,
onToggleOutputMute: _toggleOutputMute,
),
if (_inChannel &&
_transmitMode == rust.BridgeTransmitMode.ptt) ...[
const SizedBox(height: 8),
VoicePttButton(
active: _audioStats?.pttActive ?? false,
onHeldChanged: _onOnscreenPttHeldChanged,
),
],
],
);
},
},
),
),
),
],
],
],
),
);
},
);
@@ -1,5 +1,7 @@
import 'package:flutter/material.dart';
import '../design/breakpoints.dart';
/// Semantic tones for lightweight, Material 3 SnackBars.
enum AppSnackBarVariant { neutral, success, warning, error }
@@ -7,7 +9,6 @@ enum AppSnackBarVariant { neutral, success, warning, error }
class AppSnackBar {
const AppSnackBar._();
static const double _desktopMaxWidth = 560;
static const double _radius = 16;
static const double _elevation = 3;
@@ -39,9 +40,9 @@ class AppSnackBar {
}) {
final scheme = Theme.of(context).colorScheme;
final viewWidth = MediaQuery.sizeOf(context).width;
final useDesktopCap = viewWidth >= 600;
final useDesktopCap = viewWidth >= ChanoraBreakpoints.medium;
final snackBarWidth = useDesktopCap && margin == null
? _desktopMaxWidth
? ChanoraBreakpoints.snackBarDesktopCap
: null;
final effectiveMargin = useDesktopCap && margin != null
? _desktopCappedMargin(context, margin)
@@ -76,7 +77,11 @@ class AppSnackBar {
final viewWidth = MediaQuery.sizeOf(context).width;
final resolved = margin.resolve(Directionality.of(context));
final extraHorizontal =
(viewWidth - _desktopMaxWidth).clamp(0.0, viewWidth) / 2;
(viewWidth - ChanoraBreakpoints.snackBarDesktopCap).clamp(
0.0,
viewWidth,
) /
2;
return EdgeInsets.fromLTRB(
resolved.left + extraHorizontal,
resolved.top,
@@ -0,0 +1,86 @@
// SPDX-License-Identifier: Apache-2.0
import 'package:flutter/material.dart';
import '../design/breakpoints.dart';
import '../services/snapshot_state_mapper.dart';
import '../services/ts3_server_link.dart';
import '../src/rust/api.dart' as rust;
import 'chat_views.dart';
/// Fixed-width inline chat panel for expanded desktop layouts.
class ChatPanel extends StatelessWidget {
/// Construct an inline chat panel.
const ChatPanel({
super.key,
required this.messages,
required this.snapshot,
required this.target,
required this.clientName,
required this.onClose,
this.restoredDraft,
this.onDraftChanged,
this.onTs3ServerLink,
});
/// Backing chat messages shared with the chat route.
final List<ChatEntry> messages;
/// Latest TeamSpeak snapshot.
final rust.BridgeSnapshot snapshot;
/// Chat target shown in the panel.
final rust.BridgeMessageTarget target;
/// Client display name for direct-message and poke targets.
final String clientName;
/// Handle TeamSpeak server links embedded in chat messages.
final Ts3ServerLinkHandler? onTs3ServerLink;
/// Called when the user closes the inline panel.
final VoidCallback onClose;
/// External draft text to restore in the chat detail view.
final String? restoredDraft;
/// Called when the draft text changes.
final ValueChanged<String>? onDraftChanged;
@override
Widget build(BuildContext context) {
final currentChannelId = ownClientSnapshotState(snapshot)?.channelId;
final channelName = snapshotChannelName(snapshot, currentChannelId);
return SizedBox(
width: ChanoraBreakpoints.chatPanelWidth,
child: DecoratedBox(
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
border: Border(
left: BorderSide(
color: Theme.of(context).colorScheme.outlineVariant,
),
),
),
child: ChatDetailView(
messages: messages,
snapshot: snapshot,
target: target,
clientName: clientName,
currentChannelId: currentChannelId,
channelName: channelName,
onTs3ServerLink: onTs3ServerLink,
restoredDraft: restoredDraft,
onDraftChanged: onDraftChanged,
messageMaxWidth: 500,
headerTrailing: IconButton(
tooltip: 'Close chat',
icon: const Icon(Icons.close),
onPressed: onClose,
),
),
),
);
}
}
@@ -2,6 +2,7 @@ import 'dart:async' show unawaited;
import 'package:flutter/material.dart';
import '../design/breakpoints.dart';
import '../l10n/generated/app_localizations.dart';
import '../services/channel_spacer.dart';
import '../services/link_trust_service.dart';
@@ -13,7 +14,6 @@ import 'bbcode_text.dart';
const double _chatSidebarTileExtent = 92;
const double _chatSidebarCompactTileExtent = 76;
const double _chatSidebarCompactHeight = 84;
const double _chatMobileBreakpoint = 600;
/// One chat/activity message shown in the chat hub.
class ChatEntry {
@@ -616,7 +616,7 @@ class _ChatPageState extends State<ChatPage> {
final currentChannelId = _currentChannelId;
final channelName = snapshotChannelName(snapshot, currentChannelId);
final l10n = AppL10n.of(context);
final detail = _ChatDetailView(
final detail = ChatDetailView(
target: _selectedTarget,
clientName: _selectedClientName,
snapshot: snapshot,
@@ -641,7 +641,7 @@ class _ChatPageState extends State<ChatPage> {
body: LayoutBuilder(
builder: (context, constraints) {
final sidebar = _ChatSidebar(
compact: constraints.maxWidth < _chatMobileBreakpoint,
compact: constraints.maxWidth < ChanoraBreakpoints.medium,
selectedTarget: _selectedTarget,
privateChats: _privateChats,
onSelect: _selectTarget,
@@ -650,7 +650,7 @@ class _ChatPageState extends State<ChatPage> {
_selectTarget(rust.BridgeMessageTarget.client(id), name: name);
}),
);
if (constraints.maxWidth < _chatMobileBreakpoint) {
if (constraints.maxWidth < ChanoraBreakpoints.medium) {
return Column(
children: [
sidebar,
@@ -1050,8 +1050,11 @@ class _ChannelGroup extends StatelessWidget {
}
}
class _ChatDetailView extends StatefulWidget {
const _ChatDetailView({
/// Detail view for a single chat target, including message history and input.
class ChatDetailView extends StatefulWidget {
/// Construct a chat detail view.
const ChatDetailView({
super.key,
required this.target,
required this.clientName,
required this.snapshot,
@@ -1059,21 +1062,50 @@ class _ChatDetailView extends StatefulWidget {
required this.currentChannelId,
required this.channelName,
this.onTs3ServerLink,
this.headerTrailing,
this.messageMaxWidth,
this.restoredDraft,
this.onDraftChanged,
});
/// Chat target displayed by this detail view.
final rust.BridgeMessageTarget target;
/// Client display name for direct-message and poke targets.
final String clientName;
/// Latest TeamSpeak snapshot.
final rust.BridgeSnapshot snapshot;
/// Backing message list. Self-sent messages are appended here.
final List<ChatEntry> messages;
/// Current voice channel id for channel-chat send gating.
final BigInt? currentChannelId;
/// Current voice channel name for labels and placeholders.
final String channelName;
/// Handle TeamSpeak server links embedded in chat messages.
final Ts3ServerLinkHandler? onTs3ServerLink;
/// Optional widget shown at the trailing edge of the header.
final Widget? headerTrailing;
/// Optional max width for message content.
final double? messageMaxWidth;
/// External draft text to restore when the widget initializes or the target changes.
final String? restoredDraft;
/// Called with the current draft text whenever the target changes or the widget is about to be replaced.
final ValueChanged<String>? onDraftChanged;
@override
State<_ChatDetailView> createState() => _ChatDetailViewState();
State<ChatDetailView> createState() => _ChatDetailViewState();
}
class _ChatDetailViewState extends State<_ChatDetailView> {
class _ChatDetailViewState extends State<ChatDetailView> {
final _textCtl = TextEditingController();
final _scrollCtl = ScrollController();
int _lastRenderedMessageCount = -1;
@@ -1100,8 +1132,31 @@ class _ChatDetailViewState extends State<_ChatDetailView> {
clientName: widget.clientName,
);
@override
void initState() {
super.initState();
if (widget.restoredDraft != null && widget.restoredDraft!.isNotEmpty) {
_textCtl.text = widget.restoredDraft!;
}
}
@override
void didUpdateWidget(covariant ChatDetailView oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.target != widget.target) {
if (oldWidget.onDraftChanged != null && _textCtl.text.isNotEmpty) {
oldWidget.onDraftChanged!(_textCtl.text);
}
_textCtl.text = widget.restoredDraft ?? '';
_lastRenderedTarget = null;
}
}
@override
void dispose() {
if (widget.onDraftChanged != null && _textCtl.text.isNotEmpty) {
widget.onDraftChanged!(_textCtl.text);
}
_textCtl.dispose();
_scrollCtl.dispose();
super.dispose();
@@ -1176,7 +1231,12 @@ class _ChatDetailViewState extends State<_ChatDetailView> {
bottom: BorderSide(color: theme.colorScheme.outlineVariant),
),
),
child: Text(_title, style: theme.textTheme.titleMedium),
child: Row(
children: [
Expanded(child: Text(_title, style: theme.textTheme.titleMedium)),
if (widget.headerTrailing != null) widget.headerTrailing!,
],
),
),
Expanded(
child: msgs.isEmpty
@@ -1216,9 +1276,16 @@ class _ChatDetailViewState extends State<_ChatDetailView> {
controller: _scrollCtl,
padding: const EdgeInsets.symmetric(vertical: 8),
itemCount: msgs.length,
itemBuilder: (_, i) => _MessageBubble(
entry: msgs[i],
onTs3ServerLink: widget.onTs3ServerLink,
itemBuilder: (_, i) => Center(
child: ConstrainedBox(
constraints: BoxConstraints(
maxWidth: widget.messageMaxWidth ?? double.infinity,
),
child: _MessageBubble(
entry: msgs[i],
onTs3ServerLink: widget.onTs3ServerLink,
),
),
),
),
),
@@ -1,6 +1,7 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../design/breakpoints.dart';
import '../l10n/generated/app_localizations.dart';
import '../src/rust/api.dart' as rust;
@@ -114,7 +115,6 @@ class _ConnectFormState extends State<ConnectForm> {
const SizedBox(height: 16),
LayoutBuilder(
builder: (context, constraints) {
const stackedActionsMaxWidth = 400.0;
final connectButton = FilledButton.icon(
icon: const Icon(Icons.login),
label: Text(l10n.connectAction),
@@ -125,7 +125,8 @@ class _ConnectFormState extends State<ConnectForm> {
label: Text(l10n.bookmarkAddAction),
onPressed: widget.onAddBookmark,
);
if (constraints.maxWidth <= stackedActionsMaxWidth) {
if (constraints.maxWidth <=
ChanoraBreakpoints.connectActionsStackMaxWidth) {
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
@@ -23,12 +23,14 @@ class SnapshotView extends StatefulWidget {
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,
});
@@ -56,6 +58,9 @@ class SnapshotView extends StatefulWidget {
/// 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;
@@ -74,6 +79,9 @@ class SnapshotView extends StatefulWidget {
/// 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;
@@ -240,47 +248,64 @@ class _SnapshotViewState extends State<SnapshotView> {
);
}
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,
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,
),
],
],
),
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,
),
],
],
),
),
),
);
}
@@ -1082,3 +1107,60 @@ class _ClientVolumeSheetState extends State<_ClientVolumeSheet> {
);
}
}
/// Context menu for channel tiles. Shows a "Chat" option 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();
});
}
}
@@ -0,0 +1,131 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:chanora_flutter/src/rust/api.dart' as rust;
import 'package:chanora_flutter/widgets/chat_panel.dart';
import 'package:chanora_flutter/widgets/chat_views.dart';
void main() {
rust.BridgeSnapshot snapshot() {
final channelId = BigInt.from(10);
return rust.BridgeSnapshot(
serverName: 'Server',
welcomeMessage: '',
platform: '',
version: '',
channels: [
rust.BridgeChannel(
id: channelId,
parent: BigInt.zero,
name: 'Lobby',
order: 0,
hasPassword: false,
neededTalkPower: 0,
),
],
clients: [
rust.BridgeClient(
id: BigInt.one,
channel: channelId,
name: 'Me',
inputMuted: false,
outputMuted: false,
isSpeaking: false,
isServerQuery: false,
talkPower: 0,
talkPowerGranted: true,
),
],
ownClientId: BigInt.one,
);
}
testWidgets('inline chat panel renders target, messages, and close action', (
tester,
) async {
var closed = false;
final messages = [
ChatEntry(
senderId: BigInt.from(2),
senderName: 'Alice',
message: 'Hello from channel',
target: const rust.BridgeMessageTarget.channel(),
),
];
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: ChatPanel(
messages: messages,
snapshot: snapshot(),
target: const rust.BridgeMessageTarget.channel(),
clientName: '',
onClose: () => closed = true,
),
),
),
);
expect(find.text('# Lobby'), findsOneWidget);
expect(find.text('Hello from channel'), findsOneWidget);
await tester.tap(find.byTooltip('Close chat'));
await tester.pump();
expect(closed, isTrue);
});
testWidgets('chat detail restores target drafts when the target changes', (
tester,
) async {
String? savedDraft;
final messages = <ChatEntry>[];
Widget detail({
required rust.BridgeMessageTarget target,
required String? restoredDraft,
}) {
return MaterialApp(
home: Scaffold(
body: ChatDetailView(
messages: messages,
snapshot: snapshot(),
target: target,
clientName: '',
currentChannelId: BigInt.from(10),
channelName: 'Lobby',
restoredDraft: restoredDraft,
onDraftChanged: (text) => savedDraft = text,
),
),
);
}
await tester.pumpWidget(
detail(
target: const rust.BridgeMessageTarget.channel(),
restoredDraft: 'channel draft',
),
);
expect(
tester.widget<TextField>(find.byType(TextField)).controller!.text,
'channel draft',
);
await tester.enterText(find.byType(TextField), 'typed channel draft');
await tester.pumpWidget(
detail(
target: const rust.BridgeMessageTarget.server(),
restoredDraft: 'server draft',
),
);
expect(savedDraft, 'typed channel draft');
expect(
tester.widget<TextField>(find.byType(TextField)).controller!.text,
'server draft',
);
});
}
@@ -52,11 +52,13 @@ void main() {
String welcomeMessage = '',
BigInt? ownClientId,
BigInt? currentVoiceChannelId,
Set<BigInt> unreadChannelIds = const {},
rust.BridgeAudioStats? audioStats,
bool enableClientLongPressMenu = false,
ValueChanged<rust.BridgeClient>? onOpenClientInfo,
ValueChanged<rust.BridgeClient>? onOpenClientChat,
ValueChanged<rust.BridgeClient>? onOpenClientPoke,
ValueChanged<rust.BridgeChannel>? onOpenChannelChat,
}) {
return MaterialApp(
localizationsDelegates: AppL10n.localizationsDelegates,
@@ -79,12 +81,14 @@ void main() {
localOutputMuted: false,
hasJoinPending: false,
canJoinVoiceChannel: true,
unreadChannelIds: unreadChannelIds,
onJoinChannel: (_) {},
onJoinChannelWithPassword: (_) {},
enableClientLongPressMenu: enableClientLongPressMenu,
onOpenClientInfo: onOpenClientInfo,
onOpenClientChat: onOpenClientChat,
onOpenClientPoke: onOpenClientPoke,
onOpenChannelChat: onOpenChannelChat,
),
),
);
@@ -133,6 +137,32 @@ void main() {
);
});
testWidgets('renders unread dot on channels with unread chat messages', (
tester,
) async {
await tester.pumpWidget(
snapshotHarness(
channels: [channel(id: 1, name: 'Lobby')],
clients: const [],
unreadChannelIds: {BigInt.one},
),
);
await tester.pumpAndSettle();
expect(
find.byWidgetPredicate(
(widget) =>
widget is Container &&
widget.constraints?.maxWidth == 8 &&
widget.constraints?.maxHeight == 8 &&
widget.decoration is BoxDecoration &&
(widget.decoration! as BoxDecoration).shape == BoxShape.circle,
),
findsOneWidget,
);
});
testWidgets('collapsing a channel hides users and child channels', (
tester,
) async {
@@ -550,6 +580,7 @@ void main() {
localOutputMuted: false,
hasJoinPending: false,
canJoinVoiceChannel: true,
unreadChannelIds: const {},
onJoinChannel: (channel) => tapped = channel,
onJoinChannelWithPassword: (_) {},
),
@@ -577,6 +608,64 @@ void main() {
expect(tapped!.neededTalkPower, 12);
});
testWidgets('channel context menu opens chat without replacing voice join', (
tester,
) async {
final lobby = channel(id: 1, name: 'Lobby');
rust.BridgeChannel? joined;
rust.BridgeChannel? openedChat;
await tester.pumpWidget(
MaterialApp(
localizationsDelegates: AppL10n.localizationsDelegates,
supportedLocales: AppL10n.supportedLocales,
home: Scaffold(
body: SnapshotView(
snapshot: rust.BridgeSnapshot(
serverName: 'Server',
welcomeMessage: '',
platform: '',
version: '',
channels: [lobby],
clients: const [],
ownClientId: BigInt.one,
),
audioStats: null,
currentVoiceChannelId: null,
pendingVoiceChannelId: null,
localInputMuted: false,
localOutputMuted: false,
hasJoinPending: false,
canJoinVoiceChannel: true,
unreadChannelIds: const {},
onJoinChannel: (channel) => joined = channel,
onJoinChannelWithPassword: (_) {},
onOpenChannelChat: (channel) => openedChat = channel,
),
),
),
);
await tester.pumpAndSettle();
await tester.tap(find.text('Lobby'));
await tester.pump();
expect(joined, lobby);
expect(openedChat, isNull);
expect(find.text('Chat'), findsNothing);
await tester.tap(find.text('Lobby'), buttons: kSecondaryMouseButton);
await tester.pumpAndSettle();
expect(find.text('Chat'), findsOneWidget);
await tester.tap(find.text('Chat'));
await tester.pumpAndSettle();
expect(openedChat, lobby);
});
testWidgets('separator spacers render as line painters without raw text', (
tester,
) async {
@@ -611,6 +700,7 @@ void main() {
localOutputMuted: false,
hasJoinPending: false,
canJoinVoiceChannel: true,
unreadChannelIds: const {},
onJoinChannel: (_) {},
onJoinChannelWithPassword: (_) {},
),
@@ -665,6 +755,7 @@ void main() {
localOutputMuted: false,
hasJoinPending: false,
canJoinVoiceChannel: true,
unreadChannelIds: const {},
onJoinChannel: (_) {},
onJoinChannelWithPassword: (_) {},
),