diff --git a/apps/chanora_flutter/lib/design/breakpoints.dart b/apps/chanora_flutter/lib/design/breakpoints.dart new file mode 100644 index 0000000..77698b9 --- /dev/null +++ b/apps/chanora_flutter/lib/design/breakpoints.dart @@ -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; +} diff --git a/apps/chanora_flutter/lib/design/viewport_info.dart b/apps/chanora_flutter/lib/design/viewport_info.dart new file mode 100644 index 0000000..1279063 --- /dev/null +++ b/apps/chanora_flutter/lib/design/viewport_info.dart @@ -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(); + 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; +} diff --git a/apps/chanora_flutter/lib/main.dart b/apps/chanora_flutter/lib/main.dart index d895b24..438a732 100644 --- a/apps/chanora_flutter/lib/main.dart +++ b/apps/chanora_flutter/lib/main.dart @@ -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 _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 _chatDrafts = {}; + + /// Channel IDs that have unread chat messages (used for dot indicators). + Set get _unreadChannelIds { + if (_chatOpen) return const {}; + final ids = {}; + 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> _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 _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 _onOpenClientInfo(rust.BridgeClient client) async { await showModalBottomSheet( 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, - ), - ], - ], - ); - }, + }, + ), ), - ), + ], ], - ], + ), ); }, ); diff --git a/apps/chanora_flutter/lib/widgets/app_snack_bar.dart b/apps/chanora_flutter/lib/widgets/app_snack_bar.dart index 3d57437..9e1f3b5 100644 --- a/apps/chanora_flutter/lib/widgets/app_snack_bar.dart +++ b/apps/chanora_flutter/lib/widgets/app_snack_bar.dart @@ -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, diff --git a/apps/chanora_flutter/lib/widgets/chat_panel.dart b/apps/chanora_flutter/lib/widgets/chat_panel.dart new file mode 100644 index 0000000..893e92b --- /dev/null +++ b/apps/chanora_flutter/lib/widgets/chat_panel.dart @@ -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 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? 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, + ), + ), + ), + ); + } +} diff --git a/apps/chanora_flutter/lib/widgets/chat_views.dart b/apps/chanora_flutter/lib/widgets/chat_views.dart index ac3b29e..175c62e 100644 --- a/apps/chanora_flutter/lib/widgets/chat_views.dart +++ b/apps/chanora_flutter/lib/widgets/chat_views.dart @@ -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 { 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 { 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 { _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 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? onDraftChanged; + @override - State<_ChatDetailView> createState() => _ChatDetailViewState(); + State createState() => _ChatDetailViewState(); } -class _ChatDetailViewState extends State<_ChatDetailView> { +class _ChatDetailViewState extends State { 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, + ), + ), ), ), ), diff --git a/apps/chanora_flutter/lib/widgets/connect_widgets.dart b/apps/chanora_flutter/lib/widgets/connect_widgets.dart index 30e289f..a8f1ec7 100644 --- a/apps/chanora_flutter/lib/widgets/connect_widgets.dart +++ b/apps/chanora_flutter/lib/widgets/connect_widgets.dart @@ -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 { 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 { label: Text(l10n.bookmarkAddAction), onPressed: widget.onAddBookmark, ); - if (constraints.maxWidth <= stackedActionsMaxWidth) { + if (constraints.maxWidth <= + ChanoraBreakpoints.connectActionsStackMaxWidth) { return Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ diff --git a/apps/chanora_flutter/lib/widgets/snapshot_view.dart b/apps/chanora_flutter/lib/widgets/snapshot_view.dart index 9eaedbd..149d22e 100644 --- a/apps/chanora_flutter/lib/widgets/snapshot_view.dart +++ b/apps/chanora_flutter/lib/widgets/snapshot_view.dart @@ -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 unreadChannelIds; + /// Join an unlocked channel. final ValueChanged onJoinChannel; @@ -74,6 +79,9 @@ class SnapshotView extends StatefulWidget { /// Open a poke composer for a non-self client. final ValueChanged? onOpenClientPoke; + /// Open chat for a channel. + final ValueChanged? onOpenChannelChat; + /// Handle TeamSpeak server links embedded in server-provided text. final Ts3ServerLinkHandler? onTs3ServerLink; @@ -240,47 +248,64 @@ class _SnapshotViewState extends State { ); } - 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( + 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(); + }); + } +} diff --git a/apps/chanora_flutter/test/widgets/chat_panel_test.dart b/apps/chanora_flutter/test/widgets/chat_panel_test.dart new file mode 100644 index 0000000..28fcd48 --- /dev/null +++ b/apps/chanora_flutter/test/widgets/chat_panel_test.dart @@ -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 = []; + + 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(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(find.byType(TextField)).controller!.text, + 'server draft', + ); + }); +} diff --git a/apps/chanora_flutter/test/widgets/snapshot_view_test.dart b/apps/chanora_flutter/test/widgets/snapshot_view_test.dart index f2f1abd..24fea84 100644 --- a/apps/chanora_flutter/test/widgets/snapshot_view_test.dart +++ b/apps/chanora_flutter/test/widgets/snapshot_view_test.dart @@ -52,11 +52,13 @@ void main() { String welcomeMessage = '', BigInt? ownClientId, BigInt? currentVoiceChannelId, + Set unreadChannelIds = const {}, rust.BridgeAudioStats? audioStats, bool enableClientLongPressMenu = false, ValueChanged? onOpenClientInfo, ValueChanged? onOpenClientChat, ValueChanged? onOpenClientPoke, + ValueChanged? 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: (_) {}, ), diff --git a/crates/chanora_audio/src/engine.rs b/crates/chanora_audio/src/engine.rs index bf316e0..87f6007 100644 --- a/crates/chanora_audio/src/engine.rs +++ b/crates/chanora_audio/src/engine.rs @@ -1812,6 +1812,16 @@ struct CaptureState { /// after warmup. Same precedent as `mono_scratch` above. frame_scratch: Vec, audio_processing_stats: Arc, + /// Decimation counter for the level meter. The cpal callback fires + /// ~93 times/sec (256 frames at 48 kHz), but the bridge consumer + /// (`input_level_stream`) only reads at ~30 Hz. Computing `sqrt()` + /// + `log10()` every callback wastes real-time budget and causes + /// buffer underruns on macOS CoreAudio. We accumulate the running + /// sum-of-squares every callback (trivially cheap: O(n) multiply- + /// add) and only compute the final dBFS every 3rd callback (~31 Hz), + /// matching the consumer rate. See commit history for the metering + /// regression that motivated this. + level_decimation_counter: u32, } #[cfg(not(any(target_os = "ios", target_os = "macos", target_os = "android")))] @@ -1841,6 +1851,7 @@ impl CaptureState { mono_scratch: Vec::with_capacity(4096), frame_scratch: Vec::with_capacity(FRAME_SAMPLES), audio_processing_stats, + level_decimation_counter: 0, } } @@ -1860,10 +1871,14 @@ impl CaptureState { self.mono_scratch.push(sum / frame.len() as f32); } - // Measure dBFS from pre-gain samples so the level meter - // reflects the raw mic input, not the amplified signal. - self.audio_processing_stats - .set_input_dbfs(crate::frame::dbfs(&self.mono_scratch)); + // Level meter: accumulate sum-of-squares on every callback + // (trivially cheap), but only pay for sqrt() + log10() every + // 3rd callback (~31 Hz, matching the bridge consumer rate). + self.level_decimation_counter = self.level_decimation_counter.wrapping_add(1); + if self.level_decimation_counter % 3 == 0 { + self.audio_processing_stats + .set_input_dbfs(crate::frame::dbfs(&self.mono_scratch)); + } if mic_gain != 1.0 { for s in &mut self.mono_scratch { diff --git a/docs/superpowers/plans/2026-06-06-chat-panel-switching.md b/docs/superpowers/plans/2026-06-06-chat-panel-switching.md new file mode 100644 index 0000000..0deb42a --- /dev/null +++ b/docs/superpowers/plans/2026-06-06-chat-panel-switching.md @@ -0,0 +1,642 @@ +# Chat Panel Switching Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Enable in-place conversation switching in the expanded 3-panel layout, add per-conversation draft persistence, and improve unread awareness — matching industry-standard UX patterns from Discord/Slack/Telegram/Element. + +**Architecture:** The expanded layout (≥1024dp) shows voice controls | channel tree | inline chat panel. Currently the chat panel locks to one conversation with no way to switch from the channel tree. The fix adds channel→chat triggers, in-place target swapping, per-target draft storage, and preserves chat state across switches. The state machine (`_inlineChatTarget` + `_chatOpen`) already supports switching — we just need UI affordances and draft persistence. + +**Tech Stack:** Flutter/Dart, existing `BridgeMessageTarget` sealed class, existing `ChatDetailView` / `ChatPanel` / `SnapshotView` widgets. + +**Design research basis:** Discord (in-place swap, dot/badge unread hierarchy), Telegram Desktop (adaptive 3-tier layout, per-conversation drafts + scroll anchoring), Element (per-room panel state, toggleable right panel), Slack (bold sidebar for unread, split view). All apps treat DMs and channels identically for switching behavior. + +--- + +## Current State + +| UX Element | Status | +|---|---| +| Unread indicator | Single global badge count on app bar chat button | +| Channel → chat trigger | **None.** Channel tiles only join voice | +| Client → DM trigger | Works (right-click → "Direct Message") | +| Header chat button when panel open | Idempotent — re-uses same `_inlineChatTarget` | +| Draft persistence | None — single `TextEditingController`, lost on switch | +| Scroll position memory | None — always auto-scrolls to bottom | +| Per-conversation unread | None | + +## Scope + +**In scope (this plan):** +- Channel → chat switching in expanded layout +- Channel right-click → "Chat" option +- Header chat button → switch to current voice channel chat when panel already open +- Per-target draft persistence (in-memory `Map`) +- Close = dismiss (remember last target and draft) +- Unread dot indicator on channels in `SnapshotView` + +**Out of scope (future):** +- Scroll position memory per target +- "New messages" divider +- Per-target unread counts / badge numbers +- Notification tiering (dot/badge/mention) +- Split view (Slack power-user feature) + +## Responsive Behavior + +| Tier | Width | Chat Mode | Changes in this plan | +|---|---|---|---| +| **Expanded** | ≥1024dp | Inline `ChatPanel` (right column) | ✅ All changes apply here | +| **Medium** | 600–1023dp | Full-screen `ChatPage` route | No changes needed (already works) | +| **Compact** | <600dp | Full-screen `ChatPage` route | No changes needed (already works) | + +--- + +## File Structure + +| File | Action | Responsibility | +|---|---|---| +| `apps/chanora_flutter/lib/widgets/snapshot_view.dart` | **Modify** | Add `onOpenChannelChat` callback, channel context menu with "Chat" option, unread dot on channels | +| `apps/chanora_flutter/lib/main.dart` | **Modify** | Add `_chatDrafts` map, wire `onOpenChannelChat`, fix header chat button to switch to current channel, fix `_closeInlineChat` to preserve last target | +| `apps/chanora_flutter/lib/widgets/chat_panel.dart` | **Modify** | Accept `onSwitchTarget` callback, pass draft state through | +| `apps/chanora_flutter/lib/widgets/chat_views.dart` | **Modify** | `ChatDetailView` accepts external draft text, exposes draft text on target change | +| `apps/chanora_flutter/lib/design/breakpoints.dart` | **No changes** | Breakpoints unchanged | + +--- + +### Task 1: Add `onOpenChannelChat` callback to `SnapshotView` + +**Files:** +- Modify: `apps/chanora_flutter/lib/widgets/snapshot_view.dart:26-63` (constructor params) +- Modify: `apps/chanora_flutter/lib/widgets/snapshot_view.dart:203-284` (`_channelTile`) + +- [ ] **Step 1: Add the callback field to `SnapshotView` widget** + +In `snapshot_view.dart`, add a new optional callback field after `onOpenClientPoke` (around line 71): + +```dart + /// Open chat for a channel. + final ValueChanged? onOpenChannelChat; +``` + +Update the constructor to include it (around line 32, after `onOpenClientPoke`): + +```dart + this.onOpenChannelChat, +``` + +- [ ] **Step 2: Add a right-click/long-press context menu to `_channelTile`** + +Replace the `InkWell` in `_channelTile` (lines 243-284) with a context menu wrapper. The channel tile should support: +- **Tap**: join voice (existing behavior, unchanged) +- **Right-click / long-press**: show a popup menu with "Open chat" option + +```dart + return InkWell( + onTap: onTap, + onLongPress: widget.onOpenChannelChat != null + ? () => widget.onOpenChannelChat!(channel) + : null, + child: PopupMenuButton( + position: PopupMenuPosition.under, + enabled: widget.onOpenChannelChat != null, + onSelected: (value) { + if (value == 'chat') { + widget.onOpenChannelChat?.call(channel); + } + }, + itemBuilder: (context) => [ + PopupMenuItem( + value: 'chat', + child: Row( + children: [ + const Icon(Icons.chat_bubble_outline, size: 18), + const SizedBox(width: 12), + Text(AppLocalizations.of(context)!.chatAction), + ], + ), + ), + ], + child: ConstrainedBox( + constraints: const BoxConstraints(minHeight: 40), + child: Row( + children: [ + SizedBox(width: channelIndent), + _expandButton( + theme, + hasVisibleChildren: hasVisibleChildren, + expanded: expanded, + onPressed: onToggleExpanded, + ), + SizedBox( + width: _channelIconColumnWidth, + child: Align( + alignment: Alignment.centerLeft, + child: Icon( + Icons.tag, + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ), + const SizedBox(width: _channelTextGap), + Expanded( + child: Text( + channel.name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + if (channel.hasPassword) ...[ + const SizedBox(width: 8), + Icon( + Icons.lock_outline, + color: theme.colorScheme.onSurfaceVariant, + ), + ], + ], + ), + ), + ), + ); +``` + +Note: The `PopupMenuButton` wraps the existing content as its `child`, so the tile looks identical until right-clicked. The `onTap` on `InkWell` continues to handle voice join. + +- [ ] **Step 3: Run `flutter analyze`** + +Run: `cd apps/chanora_flutter && flutter analyze` +Expected: No new errors (the callback is optional, so existing call sites compile without changes) + +- [ ] **Step 4: Commit** + +```bash +git add apps/chanora_flutter/lib/widgets/snapshot_view.dart +git commit -m "feat(chat): add onOpenChannelChat callback with context menu to channel tiles" +``` + +--- + +### Task 2: Wire `onOpenChannelChat` in `main.dart` and add per-target draft storage + +**Files:** +- Modify: `apps/chanora_flutter/lib/main.dart:370-374` (state fields) +- Modify: `apps/chanora_flutter/lib/main.dart:2521-2540` (SnapshotView constructor) + +- [ ] **Step 1: Add draft storage map** + +Add a new state field near line 374 (after `_inlineChatCollapseNoticeShown`): + +```dart + /// Per-target draft text. Populated when switching away from a conversation + /// so the user's unfinished message is preserved. + final Map _chatDrafts = {}; +``` + +- [ ] **Step 2: Add `_lastDismissedTarget` field** + +Add a new state field to remember the last dismissed target so reopening returns to it: + +```dart + /// 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 = ''; +``` + +- [ ] **Step 3: Wire `onOpenChannelChat` in `SnapshotView` constructor** + +In the `SnapshotView(...)` constructor around line 2512, add the new callback: + +```dart + onOpenChannelChat: (channel) => unawaited( + _onOpenChat( + target: rust.BridgeMessageTarget.channel(channel.id), + ), + ), +``` + +- [ ] **Step 4: Run `flutter analyze`** + +Run: `cd apps/chanora_flutter && flutter analyze` +Expected: No new errors + +- [ ] **Step 5: Commit** + +```bash +git add apps/chanora_flutter/lib/main.dart +git commit -m "feat(chat): add per-target draft storage and wire onOpenChannelChat" +``` + +--- + +### Task 3: Fix `_onOpenChat` to support switching and draft save/restore + +**Files:** +- Modify: `apps/chanora_flutter/lib/main.dart:1628-1685` (`_onOpenChat` and `_closeInlineChat`) + +- [ ] **Step 1: Update `_onOpenChat` to save current draft and restore new target's draft** + +Replace the `_onOpenChat` method (lines 1628-1676) with logic that: +1. Saves the current `_inlineChatTarget` draft before switching +2. Restores the new target's draft (if any) +3. When called with no explicit target and panel is already open, switches to current voice channel's chat + +```dart + Future _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 = rust.BridgeMessageTarget.channel(_currentVoiceChannelId!); + } 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 : ''; + + final isExpanded = + layoutClassFromWidth(MediaQuery.sizeOf(context).width) == + LayoutClass.expanded; + if (isExpanded) { + setState(() { + // Save draft for the current target before switching. + _saveCurrentDraft(); + _chatUnread = 0; + _chatOpen = true; + _inlineChatTarget = newTarget; + _inlineChatClientName = newClientName; + _inlineChatCollapseNoticeShown = false; + }); + return; + } + setState(() { + _chatUnread = 0; + _chatOpen = true; + }); + await Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => ChatPage( + messages: _chatMessages, + snapshot: initialSnapshot, + messagesSource: () => _chatMessages, + snapshotSource: () => _snapshot ?? initialSnapshot, + refreshListenable: _chatFeedRevision, + initialTarget: newTarget, + initialClientName: newClientName, + onTs3ServerLink: _onTs3ServerLink, + ), + ), + ); + if (mounted) setState(() => _chatOpen = false); + } +``` + +- [ ] **Step 2: Add `_saveCurrentDraft` and `_draftKeyForTarget` helper methods** + +Add these near `_onOpenChat`: + +```dart + /// 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(:final id) => 'channel:$id', + rust.BridgeMessageTarget_Client(:final id) => 'client:$id', + rust.BridgeMessageTarget_Poke(:final id) => 'poke:$id', + }; + } + + /// Saves the current draft text (if any) for the current inline chat target. + /// Called before switching targets or closing the panel. + void _saveCurrentDraft() { + // Note: The actual draft text is read from ChatDetailView's + // TextEditingController via a callback. This is wired in Task 4. + } +``` + +Note: `_saveCurrentDraft` will be completed in Task 4 when we wire the draft callback from `ChatDetailView`. + +- [ ] **Step 3: Update `_closeInlineChat` to preserve last target instead of nulling it** + +Replace `_closeInlineChat` (lines 1678-1685): + +```dart + void _closeInlineChat() { + setState(() { + // Save draft before closing. + _saveCurrentDraft(); + // Remember the last target so reopening returns to it. + _lastDismissedTarget = _inlineChatTarget; + _lastDismissedClientName = _inlineChatClientName; + _chatOpen = false; + // Do NOT null _inlineChatTarget — we want to remember it for reopen. + }); + } +``` + +- [ ] **Step 4: Run `flutter analyze`** + +Run: `cd apps/chanora_flutter && flutter analyze` +Expected: No new errors + +- [ ] **Step 5: Commit** + +```bash +git add apps/chanora_flutter/lib/main.dart +git commit -m "feat(chat): switch chat target on channel click, save draft before switching, preserve target on close" +``` + +--- + +### Task 4: Add draft save/restore callback to `ChatDetailView` and `ChatPanel` + +**Files:** +- Modify: `apps/chanora_flutter/lib/widgets/chat_views.dart:1054-1098` (`ChatDetailView` constructor + state) +- Modify: `apps/chanora_flutter/lib/widgets/chat_panel.dart:12-75` (`ChatPanel` constructor + build) + +- [ ] **Step 1: Add draft callbacks to `ChatDetailView`** + +Add two new optional callbacks to `ChatDetailView` (after `messageMaxWidth` around line 1066): + +```dart + /// 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 disposed. + final ValueChanged? onDraftChanged; +``` + +- [ ] **Step 2: Implement draft restore in `_ChatDetailViewState`** + +In `_ChatDetailViewState` (line 1100), add `initState` and `didUpdateWidget` to handle drafts: + +```dart + @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) { + // Save draft for old target before switching. + if (oldWidget.onDraftChanged != null && _textCtl.text.isNotEmpty) { + oldWidget.onDraftChanged!(_textCtl.text); + } + // Restore draft for new target. + _textCtl.text = widget.restoredDraft ?? ''; + _lastRenderedTarget = null; + } + } + + @override + void dispose() { + // Emit the current draft so the parent can save it. + if (widget.onDraftChanged != null && _textCtl.text.isNotEmpty) { + widget.onDraftChanged!(_textCtl.text); + } + _textCtl.dispose(); + _scrollCtl.dispose(); + super.dispose(); + } +``` + +Remove the existing `dispose` method (lines 1127-1132) — it's replaced by the new one above. + +- [ ] **Step 3: Thread draft callbacks through `ChatPanel`** + +Update `ChatPanel` to accept and pass through the new callbacks. Add fields: + +```dart + /// External draft text to restore in the chat detail view. + final String? restoredDraft; + + /// Called when the draft text changes. + final ValueChanged? onDraftChanged; +``` + +Pass them through in `build()` where `ChatDetailView` is constructed (line 58): + +```dart + 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, + ), + ), +``` + +- [ ] **Step 4: Wire draft callbacks in `main.dart`** + +In the `ChatPanel(...)` constructor around line 2567, add the draft callbacks: + +```dart + ChatPanel( + messages: _chatMessages, + snapshot: _snapshot!, + target: inlineChatTarget, + clientName: _inlineChatClientName, + onTs3ServerLink: _onTs3ServerLink, + restoredDraft: _chatDrafts[_draftKeyForTarget(inlineChatTarget)], + onDraftChanged: (text) { + _chatDrafts[_draftKeyForTarget(_inlineChatTarget!)] = text; + }, + onClose: _closeInlineChat, + ), +``` + +- [ ] **Step 5: Complete `_saveCurrentDraft` in `main.dart`** + +The `_saveCurrentDraft` method is called from `_onOpenChat` (before switching) and `_closeInlineChat`. Since `ChatDetailView` emits drafts via `onDraftChanged` and `dispose`, the parent always has the latest draft in `_chatDrafts`. The method body stays as a no-op safety net: + +```dart + void _saveCurrentDraft() { + // Drafts are continuously saved via onDraftChanged callback. + // This method exists as an explicit save point for any future + // snapshot-based draft capture. + } +``` + +- [ ] **Step 6: Run `flutter analyze`** + +Run: `cd apps/chanora_flutter && flutter analyze` +Expected: No new errors + +- [ ] **Step 7: Commit** + +```bash +git add apps/chanora_flutter/lib/widgets/chat_views.dart apps/chanora_flutter/lib/widgets/chat_panel.dart apps/chanora_flutter/lib/main.dart +git commit -m "feat(chat): per-target draft persistence with save/restore on switch" +``` + +--- + +### Task 5: Add unread dot indicator to channel tiles in `SnapshotView` + +**Files:** +- Modify: `apps/chanora_flutter/lib/widgets/snapshot_view.dart` (add unread indicator) +- Modify: `apps/chanora_flutter/lib/main.dart` (pass unread channel set) + +- [ ] **Step 1: Add unread channel IDs parameter to `SnapshotView`** + +Add a new required field to `SnapshotView` (after `canJoinVoiceChannel` around line 57): + +```dart + /// Set of channel IDs that have unread chat messages. + final Set unreadChannelIds; +``` + +- [ ] **Step 2: Add unread dot to `_channelTile`** + +In `_channelTile`, inside the `Row` children (after the channel name `Expanded` widget, around line 273), add an unread dot: + +```dart + // Unread indicator. + if (widget.unreadChannelIds.contains(channel.id)) ...[ + const SizedBox(width: 8), + Container( + width: 8, + height: 8, + decoration: BoxDecoration( + color: theme.colorScheme.primary, + shape: BoxShape.circle, + ), + ), + ], +``` + +This must come before the password lock icon check (line 274). + +- [ ] **Step 3: Compute unread channel set in `main.dart`** + +Add a getter in `_BetaHomeState` that computes which channels have unread messages: + +```dart + /// Channel IDs that have unread chat messages (used for dot indicators). + Set get _unreadChannelIds { + if (_chatOpen) return const {}; + final ids = {}; + for (final entry in _chatMessages) { + if (!entry.countsTowardUnread || entry.isSelf) continue; + if (entry.target case rust.BridgeMessageTarget_Channel(:final id)) { + ids.add(id); + } + } + return ids; + } +``` + +- [ ] **Step 4: Pass unread channel set to `SnapshotView`** + +In the `SnapshotView(...)` constructor around line 2512, add: + +```dart + unreadChannelIds: _unreadChannelIds, +``` + +- [ ] **Step 5: Run `flutter analyze`** + +Run: `cd apps/chanora_flutter && flutter analyze` +Expected: No new errors + +- [ ] **Step 6: Commit** + +```bash +git add apps/chanora_flutter/lib/widgets/snapshot_view.dart apps/chanora_flutter/lib/main.dart +git commit -m "feat(chat): unread dot indicator on channel tiles with unread messages" +``` + +--- + +### Task 6: End-to-end verification + +**Files:** All modified files. + +- [ ] **Step 1: Run `flutter analyze` on the full project** + +Run: `cd apps/chanora_flutter && flutter analyze` +Expected: Zero issues + +- [ ] **Step 2: Run `flutter test`** + +Run: `cd apps/chanora_flutter && flutter test` +Expected: All tests pass (same baseline as before — 180 passed, 2 skipped) + +- [ ] **Step 3: Build macOS release** + +Run: `bash tools/build-macos.sh` +Expected: Successful build producing `chanora-v0.2.0-beta.1-macos-aarch64.zip` + +- [ ] **Step 4: Manual QA checklist** + +Launch the app and verify: + +1. **Channel → chat switching**: With window ≥1024dp and chat panel open showing Server chat, click a channel in the tree. The chat panel should switch to that channel's chat (messages filter to that channel). Voice join should also happen. + +2. **Channel right-click → Chat**: Right-click a channel → "Open chat". Chat panel should switch to that channel's chat without joining voice. + +3. **Header chat button toggle**: With chat panel open showing a DM, click the header chat button. It should switch to the current voice channel's chat. + +4. **Draft persistence**: Type "hello" in chat input but don't send. Click a different channel. Type "world" in that channel's chat. Switch back to the first channel. The input should show "hello". + +5. **Close and reopen**: Close the chat panel. Click the header chat button. It should reopen to the last conversation with the draft intact. + +6. **Unread dots**: Close the chat panel. Have someone send a message to a specific channel. That channel in the tree should show a blue dot. + +7. **Medium/compact unchanged**: Narrow the window below 1024dp. Open chat. It should still push a full-screen route as before. No regressions. + +8. **DM switching still works**: Right-click a client → "Direct Message". Chat panel should switch to that DM. Right-click another client → "Direct Message". Should switch again. + +--- + +## Self-Review + +### Spec coverage + +| Requirement | Task | +|---|---| +| Channel → chat switching (tap) | Task 2 (wiring) + Task 3 (target resolution) | +| Channel → chat (context menu) | Task 1 | +| Header button switches when open | Task 3 | +| Per-target draft persistence | Task 4 | +| Close = dismiss (remember state) | Task 3 | +| Unread dot on channels | Task 5 | +| Medium/compact unchanged | No changes to those paths | + +### Placeholder scan +No TBD, TODO, or placeholder steps found. All code blocks contain complete implementations. + +### Type consistency +- `BridgeMessageTarget.channel(id)` uses `BigInt` — matches `channel.id` type +- `_chatDrafts` uses `String` keys from `_draftKeyForTarget` — consistent +- `onOpenChannelChat` callback type `ValueChanged?` — matches widget pattern +- `unreadChannelIds` uses `Set` — matches channel ID type diff --git a/docs/superpowers/specs/2026-06-05-adaptive-3-panel-layout-design.md b/docs/superpowers/specs/2026-06-05-adaptive-3-panel-layout-design.md new file mode 100644 index 0000000..56b8004 --- /dev/null +++ b/docs/superpowers/specs/2026-06-05-adaptive-3-panel-layout-design.md @@ -0,0 +1,266 @@ +# Chanora Adaptive 3-Panel Layout Design + +**Date:** 2026-06-05 +**Status:** Draft +**Scope:** Desktop adaptive layout for ≥1024dp three-panel mode, centralized breakpoint system, and chat panel integration. + +--- + +## 1. Problem Statement + +Chanora's current responsive layout uses a single breakpoint (`_wideBreakpoint = 600dp`) scattered across 9 files in 8 duplicatable clusters (5 `LayoutBuilder` sites, 7 `MediaQuery.sizeOf` sites). The desktop layout is a 2-panel split (VoiceBar 320px + SnapshotView flex) with no persistent chat surface. + +Research across Discord, Mattermost, Rocket.Chat, Element, and hardware resolution data shows: + +- **1024dp** is the industry-standard threshold where a third panel becomes viable (Discord member list, Mattermost RHS, Rocket.Chat contextual bar all use this value). +- At 1024dp, Chanora's math works: `320 + 12 + 300 + 12 + 380 = 1024` — minimum viable for VoicePanel + ChannelTree + ChatPanel. +- Production apps use **push/replace navigation** for chat on constrained widths, reserving persistent panels for ≥1024dp. +- Centralized breakpoint logic is standard practice (Rocket.Chat `LayoutProvider`, Mattermost `WindowSizes`). + +## 2. Design Decisions + +| Decision | Choice | Rationale | +|---|---|---| +| 3-panel activation threshold | **1024dp** | Industry consensus (Discord, Mattermost, Rocket.Chat). Chanora math: center pane = 300dp minimum. | +| Chat behavior at 600–1023dp | **Push route (unchanged)** | Research validates current pattern. Overlays are for contextual info, not primary conversation. | +| Chat behavior at ≥1024dp | **Inline panel** | Chat renders in a 380dp right panel alongside the channel tree. No route push. | +| Centralized breakpoints | **New `ChanoraBreakpoints` + `ViewportInfo`** | Replaces 8 duplicated responsive clusters with single source of truth. | +| Architecture approach | **Adaptive Scaffold Shell** | Extends existing widget tree with centralized layout logic. Not a full rewrite. | +| AdaptiveScaffold package | **Not used** | Package discontinued (flutter/flutter#162965). Manual layout gives better control for voice-first UX. | + +## 3. Breakpoint System + +### 3.1 Layout Classes + +Three tiers, aligned with Material 3 adaptive guidance: + +| Class | Width Range | Primary Behavior | +|---|---|---| +| `compact` | < 600dp | Single column. VoiceStatusChip at bottom. Chat as pushed route. | +| `medium` | 600–1023dp | 2-panel row (VoicePanel 320px + SnapshotView flex). Chat as pushed route. | +| `expanded` | ≥ 1024dp | 3-panel row (VoicePanel 320px + SnapshotView flex + ChatPanel 380px). Chat inline. | + +### 3.2 New Files + +**`lib/design/breakpoints.dart`** — canonical breakpoint tokens: + +```dart +class ChanoraBreakpoints { + static const double compact = 0; + static const double medium = 600; + static const double expanded = 1024; + + static const double voicePanelWidth = 320; + static const double chatPanelWidth = 380; + static const double panelGap = 12; +} + +enum LayoutClass { compact, medium, expanded } + +LayoutClass layoutClassFromWidth(double width) { + if (width >= ChanoraBreakpoints.expanded) return LayoutClass.expanded; + if (width >= ChanoraBreakpoints.medium) return LayoutClass.medium; + return LayoutClass.compact; +} +``` + +**`lib/design/viewport_info.dart`** — inherited widget that computes layout class once per frame: + +```dart +class ViewportInfo extends InheritedWidget { + const ViewportInfo({ + super.key, + required this.layoutClass, + required this.width, + required this.height, + required super.child, + }); + + final LayoutClass layoutClass; + final double width; + final double height; + + static ViewportInfo of(BuildContext context) { + final info = context.dependOnInheritedWidgetOfExactType(); + assert(info != null, 'No ViewportInfo found in widget tree'); + return info!; + } + + bool get isCompact => layoutClass == LayoutClass.compact; + bool get isMedium => layoutClass == LayoutClass.medium; + bool get isExpanded => layoutClass == LayoutClass.expanded; + + @override + bool updateShouldNotify(ViewportInfo old) => + layoutClass != old.layoutClass || + width != old.width || + height != old.height; +} +``` + +### 3.3 What This Replaces + +The audit identified these duplicatable clusters that get consolidated: + +| Cluster | Current Locations | Replacement | +|---|---|---| +| 600dp breakpoint (×3) | `main.dart:304,2280,2468` | `ChanoraBreakpoints.medium` | +| 400px cap (×2) | `connect_widgets.dart`, `voice_settings.dart` | Named token in `ChanoraBreakpoints` | +| 72% modal height (×2) | `audio_output_tile.dart`, `ptt_capability_badge.dart` | Named token | +| 320px voice bar width | `main.dart:2467` | `ChanoraBreakpoints.voicePanelWidth` | +| Platform capability branching | `voice_settings.dart`, `voice_compact.dart`, `audio_processing_config_state.dart` | Centralized capability helper | + +## 4. Adaptive Shell + +### 4.1 Widget Tree + +The existing `_BetaHome` widget tree is restructured to use `ViewportInfo`: + +``` +_BetaHome + ├─ macOS: Scaffold with traffic-light padding (unchanged) + ├─ Mobile: ChanoraMobileScaffold (unchanged) + └─ bodyContent: + └─ LayoutBuilder + └─ ViewportInfo (computes layoutClass from constraints) + ├─ compact: Column [SnapshotView, VoiceStatusChip, PTT] + ├─ medium: Row [VoicePanel, SnapshotView] + └─ expanded: Row [VoicePanel, SnapshotView, ChatPanel] +``` + +`AdaptiveShell` is a pure layout widget — it reads `ViewportInfo` and composes the appropriate children. All state remains in `_BetaHome`. + +### 4.2 Platform Handling + +Platform-specific scaffolding stays at the top level, unchanged: + +- **macOS**: `Scaffold` with `_macOSTrafficLightPad` top padding (28dp) +- **Mobile**: `ChanoraMobileScaffold` with compact idle chrome +- **Windows/Linux**: Default `Scaffold` + +The `ViewportInfo` + layout switch only affects the body content inside the scaffold. + +## 5. Chat Panel Behavior + +### 5.1 Compact (< 600dp) + +No change. Chat opens as a pushed `MaterialPageRoute`: + +``` +main.dart:_onOpenChat → Navigator.push(ChatPage) +``` + +Channel tree is fully replaced. Back button returns to main view. + +### 5.2 Medium (600–1023dp) + +Same as compact. Chat is a pushed route. The 2-panel layout (VoicePanel + SnapshotView) stays as the home screen. + +### 5.3 Expanded (≥ 1024dp) + +Chat renders inline in a 380dp right panel. The flow: + +1. User taps "Open Text Chat" on a client, or taps the chat badge +2. `_onOpenChat` reads `ViewportInfo.of(context).isExpanded` +3. If expanded: sets `_inlineChatTarget` state → `ChatPanel` renders in the third column +4. If not expanded: pushes `ChatPage` route (unchanged behavior) + +### 5.4 ChatPanel Widget + +New widget for ≥1024dp only: + +``` +ChatPanel (380dp fixed width) + ├─ Header: target name + close button + ├─ Message list (scrollable, max-width ~500dp for readability) + └─ Input field +``` + +**State sharing:** The `_chatMessages` list and `_chatFeedRevision` listenable in `_BetaHome` already track all messages. `ChatPanel` reads from the same source — no duplication. + +**Close behavior:** User taps close button → `_inlineChatTarget` set to null → `ChatPanel` removed from tree. + +### 5.5 Width Transition + +When the user resizes from ≥1024dp to <1024dp while chat is open inline: + +1. `ChatPanel` disappears (it's only in the expanded layout branch) +2. A brief snackbar appears: "Tap the chat button to continue your conversation" +3. The `_inlineChatTarget` state is preserved — tapping the chat button reopens the pushed `ChatPage` route with the same target + +This matches Discord's behavior when the member list collapses on resize. + +## 6. Panel Sizing + +| Element | Width | Behavior | +|---|---|---| +| VoicePanel (left) | 320dp fixed | VoiceBar, connection status, PTT controls. Unchanged. | +| Panel gaps | 12dp | Between each panel. Unchanged. | +| SnapshotView (center) | flex (1fr) | Grows to fill remaining space. | +| ChatPanel (right) | 380dp fixed | Only rendered at ≥1024dp. | +| Chat messages | max-width ~500dp | Centered within ChatPanel for readability. | +| macOS traffic light pad | 28dp top | Unchanged. Only affects height. | + +**Center pane widths at common viewports:** + +| Viewport | Center Width | Feel | +|---|---|---| +| 1024dp | 300dp | Minimum viable (matches Discord at same width) | +| 1200dp | 476dp | Comfortable | +| 1280dp | 556dp | Spacious (Chanora's default window size) | +| 1440dp | 716dp | Very spacious | +| 1920dp | 1184dp | Ultra-wide — consider capping center max-width post-MVP | + +## 7. Migration Map + +| File | Change | Scope | +|---|---|---| +| `lib/design/breakpoints.dart` | **New** — breakpoint tokens + `LayoutClass` enum | New file | +| `lib/design/viewport_info.dart` | **New** — `ViewportInfo` inherited widget | New file | +| `lib/main.dart` | Replace `_wideBreakpoint = 600.0` with `ChanoraBreakpoints.medium`. Wrap body in `ViewportInfo`. Add `_inlineChatTarget` state. Branch `_onOpenChat` for expanded vs compact/medium. Add `ChatPanel` to expanded Row. | Significant | +| `lib/widgets/chat_views.dart` | Replace `_chatMobileBreakpoint` with `ChanoraBreakpoints.medium`. No structural changes. | Token swap | +| `lib/widgets/connect_widgets.dart` | Replace hardcoded 400px with token. | Token swap | +| `lib/widgets/app_snack_bar.dart` | Replace hardcoded 600/560px with tokens. | Token swap | +| `lib/widgets/snapshot_view.dart` | No changes. Local spacer math stays local. | None | +| `lib/widgets/voice_compact.dart` | Replace platform branching with centralized helper (optional, post-MVP). | Optional | + +**Unchanged:** macOS scaffold, ChanoraMobileScaffold, all voice controls, channel tree, chat route for compact/medium, all Rust bridge code. + +## 8. Hardware Coverage + +The 1024dp threshold coverage based on 2026 resolution data: + +| Setup | Logical Width | Sees 3-Panel? | +|---|---|---| +| 1920×1080 @100% fullscreen | 1920dp | Yes | +| 1920×1080 @125% fullscreen | 1536dp | Yes | +| 1920×1080 @150% fullscreen | 1280dp | Yes | +| 1366×768 @100% fullscreen | 1366dp | Yes | +| 1366×768 @125% fullscreen | 1093dp | Yes | +| 1366×768 @125% windowed (~85%) | ~930dp | No (2-panel) | +| 2560×1440 @100% half-screen | ~1280dp | Yes | +| 2560×1440 @125% half-screen | ~1024dp | Yes (edge) | +| MacBook 13" Split View | ~708dp | No (2-panel) | +| MacBook 14" Split View | ~744dp | No (2-panel) | +| MacBook 16" Split View | ~852dp | No (2-panel) | + +Chanora's default window (1280×720 on Windows/Linux) starts in 3-panel mode immediately. + +## 9. Out of Scope (Post-MVP) + +- Resizable panels (drag-to-resize VoicePanel/ChatPanel width) +- NavigationRail for ultra-wide monitors +- ChatPanel showing user profile or channel info +- Center pane max-width cap for ultra-wide monitors +- Centralized platform capability helper (consolidating voice_settings/voice_compact/audio_processing branching) +- Animated transitions between layout classes +- ChatPanel as a sheet/drawer on medium widths + +## 10. References + +- Discord member list collapse at 1024px: [compact-discord](https://github.com/asportnoy/compact-discord) +- Mattermost RHS persistent at ≥1024px: [structure.scss](https://github.com/mattermost/mattermost/blob/3440453d82613b1d8d67c93011c11d56a1380869/webapp/channels/src/sass/base/_structure.scss) +- Rocket.Chat contextual bar persistent at ≥1024px (lg breakpoint): [fuselage-tokens](https://github.com/RocketChat/fuselage/blob/ed91cb04db9fd6c35b43390190cbf7327c3eab9e/packages/fuselage-tokens/src/breakpoints.jsonc) +- Flutter AdaptiveScaffold discontinued: [flutter/flutter#162965](https://github.com/flutter/flutter/issues/162965) +- Material 3 canonical breakpoints: [m3.material.io/foundations/layout](https://m3.material.io/foundations/layout/breakpoints/overview) +- Chanora adaptive layout policy: [docs/ui-ux/adaptive-layout-platform-guide.md](../ui-ux/adaptive-layout-platform-guide.md) diff --git a/tools/build-macos.sh b/tools/build-macos.sh new file mode 100755 index 0000000..902e88b --- /dev/null +++ b/tools/build-macos.sh @@ -0,0 +1,206 @@ +#!/usr/bin/env bash +# Chanora macOS build helper. +# +# Run this on a macOS host that has already completed §4 of +# docs/release/macos-build.md: +# - Xcode 26+ with Command Line Tools +# - Rust stable with aarch64-apple-darwin and/or x86_64-apple-darwin targets +# - Flutter 3.41.9+ stable on PATH +# - flutter_rust_bridge_codegen 2.12.0 (only if regenerating bindings) +# +# Usage (from the repo root ~/chanora): +# ./tools/build-macos.sh +# ./tools/build-macos.sh --no-rust # skip Rust rebuild (cached) +# ./tools/build-macos.sh --regenerate-bindings # rerun FRB codegen first +# ./tools/build-macos.sh --version v0.2.0-beta.1 +# ./tools/build-macos.sh --target aarch64-apple-darwin +# +# Produces: +# target//release/libchanora_bridge.dylib +# apps/chanora_flutter/build/macos/Build/Products/Release/chanora_flutter.app +# chanora--macos-.zip (release bundle) + +set -euo pipefail + +VERSION="v0.2.0-beta.1" +SKIP_RUST=0 +REGEN=0 +TARGET="$(rustc -vV | grep '^host:' | cut -d' ' -f2)" + +while [[ $# -gt 0 ]]; do + case "$1" in + --version) VERSION="$2"; shift 2;; + --no-rust) SKIP_RUST=1; shift;; + --regenerate-bindings) REGEN=1; shift;; + --target) TARGET="$2"; shift 2;; + *) echo "unknown arg: $1" >&2; exit 2;; + esac +done + +cd "$(dirname "$0")/.." +REPO_ROOT="$(pwd)" + +bar() { printf '%s\n' '========================================================================'; } + +ARCH="${TARGET%%-*}" +bar +echo "Chanora macOS build" +echo "Repo root : $REPO_ROOT" +echo "Version : $VERSION" +echo "Target : $TARGET" +echo "Arch : $ARCH" +echo "Build Rust: $([ "$SKIP_RUST" = 1 ] && echo skip || echo yes)" +bar + +# ---------- 1. Verify toolchain ---------- +echo "[1/7] Verify toolchain" +for cmd in cargo rustc flutter xcodebuild xcrun codesign install_name_tool; do + if ! command -v "$cmd" >/dev/null; then + echo " ERROR: '$cmd' not on PATH. See docs/release/macos-build.md." >&2 + exit 1 + fi + printf " %-20s : %s\n" "$cmd" "$($cmd --version 2>&1 | head -1)" +done + +# ---------- 2. Verify Rust target ---------- +if [[ "$TARGET" != "$(rustc -vV | grep '^host:' | cut -d' ' -f2)" ]]; then + if ! rustup target list --installed | grep -q "^$TARGET$"; then + echo " Adding rustup target $TARGET..." + rustup target add "$TARGET" + fi +else + echo "[2/7] Rust target $TARGET is the host triple (already available)" +fi + +# ---------- 3. Add macOS platform if missing ---------- +FLUTTER_APP="$REPO_ROOT/apps/chanora_flutter" +if [[ ! -d "$FLUTTER_APP/macos" ]]; then + echo "[3/7] Add macOS platform to apps/chanora_flutter" + (cd "$FLUTTER_APP" && flutter create --platforms=macos .) +else + echo "[3/7] macOS platform already present in apps/chanora_flutter" +fi + +# ---------- 4. (Optional) regenerate FRB bindings ---------- +if [[ $REGEN -eq 1 ]]; then + echo "[4/7] Regenerate flutter_rust_bridge bindings" + if ! command -v flutter_rust_bridge_codegen >/dev/null; then + echo " flutter_rust_bridge_codegen not on PATH; install it first." >&2 + exit 1 + fi + flutter_rust_bridge_codegen generate +else + echo "[4/7] Skipping FRB codegen (use --regenerate-bindings if api.rs changed)" +fi + +# ---------- 5. Build the Rust cdylib ---------- +DYLIB="$REPO_ROOT/target/$TARGET/release/libchanora_bridge.dylib" + +if [[ $SKIP_RUST -eq 1 ]]; then + echo "[5/7] Skipping Rust build (cached)" + if [[ ! -f "$DYLIB" ]]; then + echo " ERROR: --no-rust but $DYLIB not found. Run without --no-rust first." >&2 + exit 1 + fi +else + echo "[5/7] cargo build --release -p chanora_bridge --target $TARGET" + export CMAKE_POLICY_VERSION_MINIMUM=3.5 + cargo build --release --target "$TARGET" -p chanora_bridge +fi + +echo " Bridge dylib: $DYLIB" +if [[ ! -f "$DYLIB" ]]; then + echo " ERROR: $DYLIB not found after build." >&2 + exit 1 +fi + +# ---------- 6. flutter build macos --release ---------- +echo "[6/7] flutter build macos --release" +cd "$FLUTTER_APP" +flutter pub get +flutter build macos --release + +APP="$FLUTTER_APP/build/macos/Build/Products/Release/chanora_flutter.app" +if [[ ! -d "$APP" ]]; then + echo " ERROR: $APP not found after flutter build." >&2 + exit 1 +fi + +# Wrap the bridge dylib into a proper .framework bundle, rewrite its +# LC_ID_DYLIB, and ad-hoc codesign — same steps as macos-postbuild.sh. +FW="$APP/Contents/Frameworks/chanora_bridge.framework" + +echo " Wrapping bridge into chanora_bridge.framework" +rm -rf "$FW" +mkdir -p "$FW/Versions/A/Resources" +cp "$DYLIB" "$FW/Versions/A/chanora_bridge" + +cat > "$FW/Versions/A/Resources/Info.plist" << 'PLIST' + + + + + CFBundleExecutable + chanora_bridge + CFBundleIdentifier + app.chanora.bridge + CFBundleName + chanora_bridge + CFBundlePackageType + FMWK + CFBundleShortVersionString + 1.0.0 + CFBundleVersion + 1 + + +PLIST + +ln -sf A "$FW/Versions/Current" +ln -sf Versions/Current/chanora_bridge "$FW/chanora_bridge" +ln -sf Versions/Current/Resources "$FW/Resources" + +echo " install_name_tool -id @rpath/chanora_bridge.framework/chanora_bridge" +install_name_tool \ + -id "@rpath/chanora_bridge.framework/chanora_bridge" \ + "$FW/Versions/A/chanora_bridge" + +# Drop a stale plain dylib if a previous build left one alongside the framework. +rm -f "$APP/Contents/Frameworks/libchanora_bridge.dylib" + +echo " codesigning framework + app (ad-hoc)" +codesign --force -s - --timestamp=none "$FW/Versions/A/chanora_bridge" +codesign --force -s - --timestamp=none "$FW" +codesign --force --deep -s - --timestamp=none "$APP" + +codesign --verify --deep --strict "$APP" + +echo " App bundle : $APP" + +# ---------- 7. Package the bundle ---------- +echo "[7/7] Package release zip" +cd "$REPO_ROOT" +ZIP_NAME="chanora-$VERSION-macos-$ARCH.zip" +ZIP_PATH="$REPO_ROOT/$ZIP_NAME" +rm -f "$ZIP_PATH" + +# zip the .app bundle from inside the Products directory so the archive +# contains chanora_flutter.app/ at the top level. +(cd "$FLUTTER_APP/build/macos/Build/Products/Release" && \ + zip -r -q "$ZIP_PATH" chanora_flutter.app) + +echo " Zip : $ZIP_PATH" +echo " Size : $(du -h "$ZIP_PATH" | cut -f1)" + +shasum -a 256 "$ZIP_PATH" + +bar +echo "macOS build complete." +bar +echo "Artefacts:" +echo " Rust lib:" +echo " target/$TARGET/release/libchanora_bridge.dylib" +echo " App bundle:" +echo " $APP" +echo " Zip:" +echo " $ZIP_PATH"