diff --git a/apps/chanora_flutter/lib/main.dart b/apps/chanora_flutter/lib/main.dart index b1a51b4..89a382f 100644 --- a/apps/chanora_flutter/lib/main.dart +++ b/apps/chanora_flutter/lib/main.dart @@ -461,6 +461,31 @@ class _BetaHomeState extends State<_BetaHome> { } } + /// Touch-only PTT (iOS / iPadOS / Android). The on-screen + /// `_PttHoldButton` calls this with `true` on finger-down and + /// `false` on finger-up (or cancel). The bridge's + /// `setPtt(active:)` routes the press edge through the same + /// release-tail timer + transmit-mode selector the desktop + /// hardware-key paths use (SDD-096 / SAD-083), so the user- + /// visible behaviour is identical across platforms — only the + /// input device changes. + /// + /// Errors are swallowed silently in the held=false branch + /// because the timer's `key_up` is idempotent; a failed send + /// would still let the tail expire naturally. Errors on + /// held=true surface in the UI banner so the user knows the + /// mic didn't open. + Future _onOnscreenPttHeldChanged(bool held) async { + try { + await rust.setPtt(active: held); + } catch (e) { + if (held) { + if (!mounted) return; + setState(() => _error = e.toString()); + } + } + } + Future _onOpenVoiceSettings() async { final result = await showDialog( context: context, @@ -943,6 +968,7 @@ class _BetaHomeState extends State<_BetaHome> { onToggleMute: _onToggleHardMute, onToggleOutputMute: _toggleOutputMute, onConfigure: _onOpenVoiceSettings, + onPttHeldChanged: _onOnscreenPttHeldChanged, ); final snapshotView = _SnapshotView( snapshot: _snapshot!, diff --git a/apps/chanora_flutter/lib/widgets/voice_bar.dart b/apps/chanora_flutter/lib/widgets/voice_bar.dart index 78f764a..ed5c366 100644 --- a/apps/chanora_flutter/lib/widgets/voice_bar.dart +++ b/apps/chanora_flutter/lib/widgets/voice_bar.dart @@ -3,12 +3,24 @@ // `BridgeEvent::VoiceState` stream the bridge publishes from the // core's transmit-mode selector + release-tail timer. +import 'dart:io' show Platform; + +import 'package:flutter/foundation.dart' show kIsWeb; import 'package:flutter/material.dart'; import '../l10n/generated/app_localizations.dart'; import '../main.dart' show PttCapabilityBadge; import '../src/rust/api.dart' as rust; +/// True when the host is a mobile platform without a hardware +/// keyboard the user would bind a PTT key on. iOS / iPadOS / +/// Android fall here. macOS / Linux / Windows / Web fall on the +/// hardware-key path. +bool get _isTouchOnlyPttHost { + if (kIsWeb) return false; + return Platform.isIOS || Platform.isAndroid; +} + /// Voice bar — surfaces the live voice state, mode badge, hard-mute /// toggle, level meter, and a leave-channel affordance. class VoiceBar extends StatelessWidget { @@ -29,6 +41,7 @@ class VoiceBar extends StatelessWidget { required this.onToggleMute, required this.onToggleOutputMute, required this.onConfigure, + required this.onPttHeldChanged, }); /// True when the session is currently joined to a voice channel. @@ -84,6 +97,15 @@ class VoiceBar extends StatelessWidget { /// and intentionally does NOT have its own configure affordance. final VoidCallback onConfigure; + /// Drive the press/release edges of the on-screen PTT button on + /// touch-only mobile platforms (iOS / iPadOS / Android). On + /// desktop platforms this callback is wired but never invoked + /// because the on-screen button is only rendered on mobile. + /// The callee should map `true` to `setPtt(active: true)` and + /// `false` to `setPtt(active: false)`; the Rust release-tail + /// timer handles the trailing tail (SDD-096). + final ValueChanged onPttHeldChanged; + String _modeLabel(AppL10n l10n) { switch (transmitMode) { case rust.BridgeTransmitMode.ptt: @@ -204,10 +226,35 @@ class VoiceBar extends StatelessWidget { ), ], ), - // Row 3: PTT-only secondary line — bound key + release - // tail. Hidden entirely for Continuous / Voice Activity - // so the bar stays focused on what's actually in use. - if (isPtt) + // Row 3: PTT-only secondary content. + // + // On hardware-keyboard hosts (Windows / macOS / Linux / + // Web) this is a one-line bound-key + release-tail + // hint. + // + // On touch-only hosts (iOS / iPadOS / Android) there is + // no hardware key to bind, so we replace the hint with + // a touch-and-hold on-screen PTT button driven by + // `_PttHoldButton`. The release-tail still applies; the + // small print below the button shows it for parity + // with the desktop hint line. + if (isPtt && _isTouchOnlyPttHost) ...[ + const SizedBox(height: 8), + _PttHoldButton( + active: levelActive, + onHeldChanged: onPttHeldChanged, + ), + const SizedBox(height: 4), + Padding( + padding: const EdgeInsets.only(left: 4), + child: Text( + '${l10n.voiceReleaseTailLabel}: $releaseTailMs${l10n.voiceReleaseTailHint}', + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ), + ] else if (isPtt) Padding( padding: const EdgeInsets.only(left: 22, top: 2), child: Text( @@ -289,3 +336,103 @@ class _LevelMeter extends StatelessWidget { ); } } + +/// On-screen push-to-talk button for touch-only mobile platforms +/// (iOS / iPadOS / Android). Hardware-keyboard hosts hide this in +/// favour of a bound key. +/// +/// Behaviour: +/// * `onPanDown` (finger touches the button) → fires +/// `onHeldChanged(true)`. The Rust release-tail timer treats +/// this as `key_down`. +/// * `onPanEnd` / `onPanCancel` (finger lifts or drags off) → +/// fires `onHeldChanged(false)` → `key_up` → tail expires → +/// mic closes. +/// +/// Using `GestureDetector` rather than `Listener` because we want +/// gesture-arena semantics: if the user starts dragging the +/// channel-tree underneath, the PTT should release. `onPanCancel` +/// fires in that case. +/// +/// The button visually mirrors the `_LevelMeter` state via the +/// `active` flag so the user gets feedback that holding actually +/// engaged the mic. +class _PttHoldButton extends StatefulWidget { + const _PttHoldButton({required this.active, required this.onHeldChanged}); + + final bool active; + final ValueChanged onHeldChanged; + + @override + State<_PttHoldButton> createState() => _PttHoldButtonState(); +} + +class _PttHoldButtonState extends State<_PttHoldButton> { + bool _pressed = false; + + void _setHeld(bool held) { + if (_pressed == held) return; + setState(() => _pressed = held); + widget.onHeldChanged(held); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final activeNow = _pressed || widget.active; + final l10n = AppL10n.of(context); + + return GestureDetector( + behavior: HitTestBehavior.opaque, + onTapDown: (_) => _setHeld(true), + onTapUp: (_) => _setHeld(false), + onTapCancel: () => _setHeld(false), + onPanDown: (_) => _setHeld(true), + onPanEnd: (_) => _setHeld(false), + onPanCancel: () => _setHeld(false), + child: AnimatedContainer( + duration: const Duration(milliseconds: 80), + height: 64, + decoration: BoxDecoration( + color: activeNow + ? theme.colorScheme.primary + : theme.colorScheme.primaryContainer, + borderRadius: BorderRadius.circular(12), + boxShadow: activeNow + ? [ + BoxShadow( + color: theme.colorScheme.primary.withAlpha(100), + blurRadius: 12, + offset: const Offset(0, 2), + ), + ] + : null, + ), + child: Center( + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + activeNow ? Icons.mic : Icons.mic_none, + color: activeNow + ? theme.colorScheme.onPrimary + : theme.colorScheme.onPrimaryContainer, + size: 24, + ), + const SizedBox(width: 10), + Text( + activeNow ? l10n.voiceMicOn : l10n.voiceModePtt, + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w600, + color: activeNow + ? theme.colorScheme.onPrimary + : theme.colorScheme.onPrimaryContainer, + ), + ), + ], + ), + ), + ), + ); + } +}