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:
@@ -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!,
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user