feat(flutter): surface bound PTT key label in capability badge (SDD-091 follow-up)

After a user saved a PTT binding through _PttBindingCaptureDialog,
the capability badge showed the resolved level + backend (e.g.
'PTT: L2WindowsRawInput (windows-raw-input)') but never told the
user which key they had actually bound. Reported on the Windows
verification round as 'do you think we should tell user what key
they have set and then they will know what to press'.

This commit caches the captured platform-neutral key label in
_BetaHomeState whenever _onConfigurePtt succeeds, and threads it
through _AudioControls to a new boundKeyLabel prop on
PttCapabilityBadge. When the prop is non-empty the badge renders
a second line below the existing row:

  PTT: L2WindowsRawInput (windows-raw-input)    [ⓘ] [Configure]
    Key: Space

The label uses bodySmall + monospace + onSurfaceVariant to stay
visually subordinate to the capability descriptor. Two new l10n
entries (en + zh) cover the 'Key: {key}' string.

Privacy: the displayed label is the same platform-neutral
LogicalKeyboardKey.keyLabel string the dialog already shows
during capture and that already crosses the bridge as
PttBinding.platform_key. No raw OS key code is introduced
(DEC-027 / SDD-077 compliance preserved).

State scope: display-only cache that resets on app restart. The
bridge-side PttController (SDD-088) holds the authoritative
binding; this UI cache is purely for display continuity within
a single process.

Verified on Linux: flutter analyze clean, cargo test --workspace
55/0/3.
This commit is contained in:
EdisonJwa
2026-05-15 20:35:42 +08:00
parent 9831624079
commit feceacfdad
6 changed files with 101 additions and 25 deletions
+6
View File
@@ -40,6 +40,12 @@
"backend": { "type": "String" } "backend": { "type": "String" }
} }
}, },
"pttCapabilityBoundKey": "Key: {key}",
"@pttCapabilityBoundKey": {
"placeholders": {
"key": { "type": "String" }
}
},
"pttCapabilityExplainTitle": "Push-to-Talk capability", "pttCapabilityExplainTitle": "Push-to-Talk capability",
"pttCapabilityExplainFocusedHeading": "Focused PTT", "pttCapabilityExplainFocusedHeading": "Focused PTT",
"pttCapabilityExplainFocusedBody": "Chanora is currently using Focused Push-to-Talk: the binding only fires while the Chanora window is focused. This is the universal fallback used on every platform when a global capture path is not available.", "pttCapabilityExplainFocusedBody": "Chanora is currently using Focused Push-to-Talk: the binding only fires while the Chanora window is focused. This is the universal fallback used on every platform when a global capture path is not available.",
+1
View File
@@ -30,6 +30,7 @@
"pttTransmitting": "正在发送…", "pttTransmitting": "正在发送…",
"pttHoldToTalkSemanticsHint": "按住进行语音发送,松开停止。", "pttHoldToTalkSemanticsHint": "按住进行语音发送,松开停止。",
"pttCapabilityBadge": "对讲能力:{level}{backend}", "pttCapabilityBadge": "对讲能力:{level}{backend}",
"pttCapabilityBoundKey": "按键:{key}",
"pttCapabilityExplainTitle": "对讲能力说明", "pttCapabilityExplainTitle": "对讲能力说明",
"pttCapabilityExplainFocusedHeading": "聚焦对讲", "pttCapabilityExplainFocusedHeading": "聚焦对讲",
"pttCapabilityExplainFocusedBody": "Chanora 当前使用聚焦对讲:按键只在 Chanora 窗口处于聚焦时生效。这是所有平台在无法启用全局采集时的通用回退方案。", "pttCapabilityExplainFocusedBody": "Chanora 当前使用聚焦对讲:按键只在 Chanora 窗口处于聚焦时生效。这是所有平台在无法启用全局采集时的通用回退方案。",
@@ -253,6 +253,12 @@ abstract class AppL10n {
/// **'PTT: {level} ({backend})'** /// **'PTT: {level} ({backend})'**
String pttCapabilityBadge(String level, String backend); String pttCapabilityBadge(String level, String backend);
/// No description provided for @pttCapabilityBoundKey.
///
/// In en, this message translates to:
/// **'Key: {key}'**
String pttCapabilityBoundKey(String key);
/// No description provided for @pttCapabilityExplainTitle. /// No description provided for @pttCapabilityExplainTitle.
/// ///
/// In en, this message translates to: /// In en, this message translates to:
@@ -96,6 +96,11 @@ class AppL10nEn extends AppL10n {
return 'PTT: $level ($backend)'; return 'PTT: $level ($backend)';
} }
@override
String pttCapabilityBoundKey(String key) {
return 'Key: $key';
}
@override @override
String get pttCapabilityExplainTitle => 'Push-to-Talk capability'; String get pttCapabilityExplainTitle => 'Push-to-Talk capability';
@@ -93,6 +93,11 @@ class AppL10nZh extends AppL10n {
return '对讲能力:$level$backend'; return '对讲能力:$level$backend';
} }
@override
String pttCapabilityBoundKey(String key) {
return '按键:$key';
}
@override @override
String get pttCapabilityExplainTitle => '对讲能力说明'; String get pttCapabilityExplainTitle => '对讲能力说明';
+78 -25
View File
@@ -115,6 +115,16 @@ class _BetaHomeState extends State<_BetaHome> {
String _pttLevel = 'L0Focused'; String _pttLevel = 'L0Focused';
String _pttBackendId = 'focused'; String _pttBackendId = 'focused';
String _pttBoundInputClass = 'keyboard'; String _pttBoundInputClass = 'keyboard';
// Last platform-neutral key label the user saved in the
// `_PttBindingCaptureDialog` (e.g. "Space", "F10",
// "mouse-side-button:8"). Surfaced next to the capability
// badge so the user can remember which key drives PTT. The
// bridge-side `PttController` (SDD-088) holds the authoritative
// binding; this is a display-only cache that resets on app
// restart. Per DEC-027 the raw OS key code never lives here —
// only the platform-neutral label that already crossed into
// the Rust side.
String _pttBoundKeyLabel = '';
List<rust.BridgeBookmark> _bookmarks = const []; List<rust.BridgeBookmark> _bookmarks = const [];
@@ -435,6 +445,15 @@ class _BetaHomeState extends State<_BetaHome> {
inputClass: binding.inputClass, inputClass: binding.inputClass,
platformKey: binding.platformKey, platformKey: binding.platformKey,
); );
// Cache the captured label so the badge can surface "Key:
// Space" / "Key: Mouse4" next to the capability descriptor.
// The bridge holds the authoritative binding; this is only
// for display continuity until the next app restart.
if (mounted) {
setState(() {
_pttBoundKeyLabel = binding.platformKey;
});
}
} catch (e) { } catch (e) {
if (!mounted) return; if (!mounted) return;
// Surface the failure as a snackbar so the user sees that // Surface the failure as a snackbar so the user sees that
@@ -723,6 +742,7 @@ class _BetaHomeState extends State<_BetaHome> {
pttLevel: _pttLevel, pttLevel: _pttLevel,
pttBackendId: _pttBackendId, pttBackendId: _pttBackendId,
pttBoundInputClass: _pttBoundInputClass, pttBoundInputClass: _pttBoundInputClass,
pttBoundKeyLabel: _pttBoundKeyLabel,
onPttDown: () => _setPtt(true), onPttDown: () => _setPtt(true),
onPttUp: () => _setPtt(false), onPttUp: () => _setPtt(false),
onToggleInputMute: _toggleInputMute, onToggleInputMute: _toggleInputMute,
@@ -881,6 +901,7 @@ class _AudioControls extends StatefulWidget {
required this.pttLevel, required this.pttLevel,
required this.pttBackendId, required this.pttBackendId,
required this.pttBoundInputClass, required this.pttBoundInputClass,
required this.pttBoundKeyLabel,
required this.onPttDown, required this.onPttDown,
required this.onPttUp, required this.onPttUp,
required this.onToggleInputMute, required this.onToggleInputMute,
@@ -896,6 +917,7 @@ class _AudioControls extends StatefulWidget {
final String pttLevel; final String pttLevel;
final String pttBackendId; final String pttBackendId;
final String pttBoundInputClass; final String pttBoundInputClass;
final String pttBoundKeyLabel;
final VoidCallback onPttDown; final VoidCallback onPttDown;
final VoidCallback onPttUp; final VoidCallback onPttUp;
final VoidCallback onToggleInputMute; final VoidCallback onToggleInputMute;
@@ -935,6 +957,7 @@ class _AudioControlsState extends State<_AudioControls> {
level: widget.pttLevel, level: widget.pttLevel,
backendId: widget.pttBackendId, backendId: widget.pttBackendId,
boundInputClass: widget.pttBoundInputClass, boundInputClass: widget.pttBoundInputClass,
boundKeyLabel: widget.pttBoundKeyLabel,
onConfigure: widget.onConfigurePtt, onConfigure: widget.onConfigurePtt,
), ),
// Accessibility (SysRS-262 + SysRS-282 + SysRS-263): // Accessibility (SysRS-262 + SysRS-282 + SysRS-263):
@@ -1069,6 +1092,7 @@ class PttCapabilityBadge extends StatelessWidget {
required this.level, required this.level,
required this.backendId, required this.backendId,
required this.boundInputClass, required this.boundInputClass,
required this.boundKeyLabel,
required this.onConfigure, required this.onConfigure,
}); });
@@ -1084,6 +1108,14 @@ class PttCapabilityBadge extends StatelessWidget {
/// or empty when no binding is set). /// or empty when no binding is set).
final String boundInputClass; final String boundInputClass;
/// Platform-neutral key label captured by the binding dialog
/// (e.g. `"Space"`, `"F10"`, `"mouse-side-button:8"`). Empty
/// when the user has not saved a binding in the current
/// process. Display-only; the bridge holds the authoritative
/// binding. Per DEC-027 this string is the same one already
/// crossed into the Rust side — no new privacy surface.
final String boundKeyLabel;
/// Open the configure-binding dialog. Wired by the caller. /// Open the configure-binding dialog. Wired by the caller.
final VoidCallback onConfigure; final VoidCallback onConfigure;
@@ -1165,38 +1197,59 @@ class PttCapabilityBadge extends StatelessWidget {
: '$badgeLabel\n($boundInputClass)'; : '$badgeLabel\n($boundInputClass)';
return Padding( return Padding(
padding: const EdgeInsets.only(bottom: 6), padding: const EdgeInsets.only(bottom: 6),
child: Tooltip( child: Column(
message: tooltipMessage, crossAxisAlignment: CrossAxisAlignment.start,
child: Row( children: [
children: [ Tooltip(
Icon( message: tooltipMessage,
_isFocused ? Icons.crop_free : Icons.public, child: Row(
size: 14, children: [
color: theme.colorScheme.onSurfaceVariant, Icon(
_isFocused ? Icons.crop_free : Icons.public,
size: 14,
color: theme.colorScheme.onSurfaceVariant,
),
const SizedBox(width: 4),
Expanded(
child: Text(
badgeLabel,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
),
if (_isFocused)
IconButton(
icon: const Icon(Icons.info_outline, size: 16),
tooltip: l10n.pttCapabilityExplainTitle,
visualDensity: VisualDensity.compact,
onPressed: () => _openExplanationSheet(context),
),
TextButton.icon(
icon: const Icon(Icons.tune, size: 14),
label: Text(l10n.pttConfigureAction),
onPressed: onConfigure,
),
],
), ),
const SizedBox(width: 4), ),
Expanded( // Second line: show which key the user just bound, so
// they can remember what to press. Only rendered when a
// binding has been saved in the current process (the
// bridge holds the authoritative binding across restarts;
// this label is display-only).
if (boundKeyLabel.isNotEmpty)
Padding(
padding: const EdgeInsets.only(left: 18, top: 2),
child: Text( child: Text(
badgeLabel, l10n.pttCapabilityBoundKey(boundKeyLabel),
style: theme.textTheme.bodySmall?.copyWith( style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant, color: theme.colorScheme.onSurfaceVariant,
fontFamily: 'monospace',
), ),
), ),
), ),
if (_isFocused) ],
IconButton(
icon: const Icon(Icons.info_outline, size: 16),
tooltip: l10n.pttCapabilityExplainTitle,
visualDensity: VisualDensity.compact,
onPressed: () => _openExplanationSheet(context),
),
TextButton.icon(
icon: const Icon(Icons.tune, size: 14),
label: Text(l10n.pttConfigureAction),
onPressed: onConfigure,
),
],
),
), ),
); );
} }