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
@@ -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();
});
}
}