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:
@@ -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 600–1023 — 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,
|
||||
|
||||
/// 600–1023dp — 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 (600–1023dp).
|
||||
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
@@ -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: (_) {},
|
||||
),
|
||||
|
||||
Reference in New Issue
Block a user