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
@@ -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,
),
),
),
),
),