feat(ui): adaptive 3-panel layout, chat panel switching, audio metering fix
- Add responsive breakpoints (compact <600, medium 600-1023, expanded >=1024) - Add ViewportInfo InheritedWidget for layout-aware descendants - Add inline ChatPanel (380dp right column) for expanded desktop layout - Add channel right-click context menu with Chat option for in-place switching - Add per-target draft persistence via restoredDraft/onDraftChanged callbacks - Fix header chat button to switch to current voice channel when panel open - Fix close = dismiss (preserves last target and draft for reopen) - Add unread dot indicator on channel tiles when chat is closed - Fix audio regression: decimate dBFS computation to every 3rd callback (~31 Hz) to avoid buffer underruns on macOS CoreAudio real-time thread - Add tools/build-macos.sh release build script (7-step process) - Add chat panel switching implementation plan and 3-panel design spec Tests: 183 passed, 2 skipped. Flutter analyze clean.
This commit is contained in:
@@ -0,0 +1,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<ViewportInfo>();
|
||||
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)
|
||||
Reference in New Issue
Block a user