feat(ui,ptt): on-screen touch-and-hold PTT button for iOS / iPadOS / Android

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<bool>`
    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.
This commit is contained in:
EdisonJwa
2026-05-16 15:15:38 +08:00
parent f5d3810f5f
commit 278df25fd7
2 changed files with 177 additions and 4 deletions
+26
View File
@@ -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<void> _onOnscreenPttHeldChanged(bool held) async {
try {
await rust.setPtt(active: held);
} catch (e) {
if (held) {
if (!mounted) return;
setState(() => _error = e.toString());
}
}
}
Future<void> _onOpenVoiceSettings() async {
final result = await showDialog<VoiceSettingsResult>(
context: context,
@@ -943,6 +968,7 @@ class _BetaHomeState extends State<_BetaHome> {
onToggleMute: _onToggleHardMute,
onToggleOutputMute: _toggleOutputMute,
onConfigure: _onOpenVoiceSettings,
onPttHeldChanged: _onOnscreenPttHeldChanged,
);
final snapshotView = _SnapshotView(
snapshot: _snapshot!,
+151 -4
View File
@@ -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<bool> 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<bool> 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,
),
),
],
),
),
),
);
}
}