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 {
|
Future<void> _onOpenVoiceSettings() async {
|
||||||
final result = await showDialog<VoiceSettingsResult>(
|
final result = await showDialog<VoiceSettingsResult>(
|
||||||
context: context,
|
context: context,
|
||||||
@@ -943,6 +968,7 @@ class _BetaHomeState extends State<_BetaHome> {
|
|||||||
onToggleMute: _onToggleHardMute,
|
onToggleMute: _onToggleHardMute,
|
||||||
onToggleOutputMute: _toggleOutputMute,
|
onToggleOutputMute: _toggleOutputMute,
|
||||||
onConfigure: _onOpenVoiceSettings,
|
onConfigure: _onOpenVoiceSettings,
|
||||||
|
onPttHeldChanged: _onOnscreenPttHeldChanged,
|
||||||
);
|
);
|
||||||
final snapshotView = _SnapshotView(
|
final snapshotView = _SnapshotView(
|
||||||
snapshot: _snapshot!,
|
snapshot: _snapshot!,
|
||||||
|
|||||||
@@ -3,12 +3,24 @@
|
|||||||
// `BridgeEvent::VoiceState` stream the bridge publishes from the
|
// `BridgeEvent::VoiceState` stream the bridge publishes from the
|
||||||
// core's transmit-mode selector + release-tail timer.
|
// 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 'package:flutter/material.dart';
|
||||||
|
|
||||||
import '../l10n/generated/app_localizations.dart';
|
import '../l10n/generated/app_localizations.dart';
|
||||||
import '../main.dart' show PttCapabilityBadge;
|
import '../main.dart' show PttCapabilityBadge;
|
||||||
import '../src/rust/api.dart' as rust;
|
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
|
/// Voice bar — surfaces the live voice state, mode badge, hard-mute
|
||||||
/// toggle, level meter, and a leave-channel affordance.
|
/// toggle, level meter, and a leave-channel affordance.
|
||||||
class VoiceBar extends StatelessWidget {
|
class VoiceBar extends StatelessWidget {
|
||||||
@@ -29,6 +41,7 @@ class VoiceBar extends StatelessWidget {
|
|||||||
required this.onToggleMute,
|
required this.onToggleMute,
|
||||||
required this.onToggleOutputMute,
|
required this.onToggleOutputMute,
|
||||||
required this.onConfigure,
|
required this.onConfigure,
|
||||||
|
required this.onPttHeldChanged,
|
||||||
});
|
});
|
||||||
|
|
||||||
/// True when the session is currently joined to a voice channel.
|
/// 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.
|
/// and intentionally does NOT have its own configure affordance.
|
||||||
final VoidCallback onConfigure;
|
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) {
|
String _modeLabel(AppL10n l10n) {
|
||||||
switch (transmitMode) {
|
switch (transmitMode) {
|
||||||
case rust.BridgeTransmitMode.ptt:
|
case rust.BridgeTransmitMode.ptt:
|
||||||
@@ -204,10 +226,35 @@ class VoiceBar extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
// Row 3: PTT-only secondary line — bound key + release
|
// Row 3: PTT-only secondary content.
|
||||||
// tail. Hidden entirely for Continuous / Voice Activity
|
//
|
||||||
// so the bar stays focused on what's actually in use.
|
// On hardware-keyboard hosts (Windows / macOS / Linux /
|
||||||
if (isPtt)
|
// 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(
|
||||||
padding: const EdgeInsets.only(left: 22, top: 2),
|
padding: const EdgeInsets.only(left: 22, top: 2),
|
||||||
child: Text(
|
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