From 278df25fd7f28112e0e5fae249a415ffa31b69b6 Mon Sep 17 00:00:00 2001 From: EdisonJwa Date: Sat, 16 May 2026 15:15:38 +0800 Subject: [PATCH] feat(ui,ptt): on-screen touch-and-hold PTT button for iOS / iPadOS / Android MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit iOS / iPadOS / Android have no hardware keyboard for the user to bind a PTT key on. Up to now the VoiceBar showed only a 'Push to talk: bound key —' hint that didn't lead anywhere usable. Add a touch-and-hold on-screen PTT button rendered only on touch-only platforms (Platform.isIOS || Platform.isAndroid; web hosts and desktop continue to use the hardware-key path unchanged). apps/chanora_flutter/lib/widgets/voice_bar.dart: * New module-private `_isTouchOnlyPttHost` predicate. * VoiceBar gains an `onPttHeldChanged: ValueChanged` constructor param. Desktop callers wire it but never invoke it because the button is not rendered there. * Row 3 (the PTT-only secondary content) now branches: - on touch-only hosts -> renders the new `_PttHoldButton` plus a small release-tail hint underneath - on hardware-keyboard hosts -> renders the same bound-key + release-tail one-liner as before, unchanged. * New `_PttHoldButton` StatefulWidget. Uses a single GestureDetector covering onTapDown / onTapUp / onTapCancel / onPanDown / onPanEnd / onPanCancel so the held edges fire on finger-down and the released edge fires when the user lifts OR drags off OR another gesture in the arena wins. Visual feedback mirrors the level-meter active flag. apps/chanora_flutter/lib/main.dart: * New `_onOnscreenPttHeldChanged(bool held)` method that calls `rust.setPtt(active: held)`. The bridge's set_ptt routes the edge through the same release-tail timer + transmit-mode selector that desktop hardware keys use (SDD-096 / SAD-083), so behaviour parity is preserved. flutter analyze: clean (6 pre-existing Radio.groupValue infos). flutter build ios --release --no-codesign: clean (Runner.app 29.9 MB). DEC-025: iPhone + iPad + Android in scope; this commit makes PTT mode actually usable on those platforms. The 'Focused' capability badge wording in ios-p0-acceptance.md / ipad-p0-acceptance.md already documents the on-screen button as the only PTT input; this commit makes that documentation true. --- apps/chanora_flutter/lib/main.dart | 26 +++ .../lib/widgets/voice_bar.dart | 155 +++++++++++++++++- 2 files changed, 177 insertions(+), 4 deletions(-) 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, + ), + ), + ], + ), + ), + ), + ); + } +}