fix(ui,core): auto-VoiceState on connect + StatefulWidget dialogs (no PTT after join, save-bookmark crash)
Two user-reported issues addressed:
1. 'when user join the server aka default channel, but there are
no ptt button also can not talk'
Root cause: TS3 servers auto-place a newly-connected client into
the server's default channel. We did not detect that. The UI
gated all voice controls (PTT button, mic/headset AppBar icons,
voice modal entry point) on _inChannel which was only flipped
true by an explicit voice_join() call from the user. So after
connect the user saw themselves in the default channel via the
channel tree but had no way to talk.
Fix: in chanora_core::Session::connect(), after the initial
snapshot resolves, call find_own_in(&snap) to determine whether
the server placed us in a channel. If yes, voice_selector
.set_in_channel(true) + emit SessionEvent::VoiceState
{ in_channel: true } + ensure_audio_running. This treats the
server-side default channel placement identically to a user-
driven voice_join: the UI receives a VoiceState(true) event and
renders all voice controls.
Tolerates audio engine startup failure the same way voice_join
does \u2014 server-side we are in the channel regardless; if mic
permission / device init fails, the UI gains the controls and
emits SessionEvent::AudioStopped so the user can resolve the
underlying issue.
Drops the inner lock before calling the public helpers because
set_in_channel + emit_voice_state + ensure_audio_running all
re-lock self.inner.
2. 'save bookmark cause a crash framework.dart line 6268
_dependents.isEmpty is not true'
Root cause: the showDialog-with-inline-TextEditingController-
dispose anti-pattern. _onAddCurrentBookmark and
_askChannelPassword both constructed a TextEditingController in
the surrounding async function, passed it to a dialog's
TextField via the dialog builder, then called ctl.dispose()
synchronously after returned.
On iOS the dialog route pop animation is still mid-flight when
showDialog's Future resolves. The inline dispose tore the
controller out from under EditableText while EditableText still
held InheritedWidget dependencies on the dialog route (theme,
localizations, default text style). When the dialog route's
InheritedElement then deactivated as part of the pop animation,
the framework's debug-only assertion _dependents.isEmpty tripped
because the disposed dialog tree had not finished detaching its
dependents yet.
Fix: hoist both dialogs into dedicated StatefulWidgets
(_BookmarkNameDialog and _ChannelPasswordDialog) that own their
own TextEditingController. The State.dispose() runs as part of
the dialog's normal unmount lifecycle, AFTER the pop animation
completes and all InheritedWidget dependencies have been cleared.
No race possible.
Bonus: dialog builders now use the dialog's own ctx for
AppL10n.of(...), Theme.of(...), and Navigator.of(...) calls
uniformly, rather than capturing the outer _HomePageState
context's l10n in a closure. That avoids a secondary leak where
the dialog widget tree held references back to the outer
route's InheritedElements through closure capture.
Added onSubmitted: -> pop(_ctl.text) on both fields so iOS
hardware-keyboard 'return' submits the dialog (small UX win
discovered while restructuring).
iOS build: flutter build ios --release --no-codesign 20.3 s,
Runner.app 30.4 MB. flutter analyze: 6 pre-existing Radio
deprecation infos (unchanged). cargo test -p chanora_core --release
--lib: 13 passed.
This commit is contained in:
@@ -547,6 +547,12 @@ abstract class AppL10n {
|
||||
/// **'Bind PTT key'**
|
||||
String get voiceBindKeyAction;
|
||||
|
||||
/// No description provided for @voiceBoundKeyLabel.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Bound key'**
|
||||
String get voiceBoundKeyLabel;
|
||||
|
||||
/// No description provided for @voicePttHoldHint.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
@@ -565,6 +571,60 @@ abstract class AppL10n {
|
||||
/// **'off'**
|
||||
String get voiceMicOff;
|
||||
|
||||
/// No description provided for @voiceSheetTitle.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Voice'**
|
||||
String get voiceSheetTitle;
|
||||
|
||||
/// No description provided for @audioOutputLabel.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Audio output'**
|
||||
String get audioOutputLabel;
|
||||
|
||||
/// No description provided for @audioRouteSpeaker.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Speaker'**
|
||||
String get audioRouteSpeaker;
|
||||
|
||||
/// No description provided for @audioRouteReceiver.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'iPhone receiver'**
|
||||
String get audioRouteReceiver;
|
||||
|
||||
/// No description provided for @audioRouteBluetooth.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Bluetooth'**
|
||||
String get audioRouteBluetooth;
|
||||
|
||||
/// No description provided for @audioRouteWiredHeadset.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Wired headset'**
|
||||
String get audioRouteWiredHeadset;
|
||||
|
||||
/// No description provided for @audioRouteCarAudio.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Car audio'**
|
||||
String get audioRouteCarAudio;
|
||||
|
||||
/// No description provided for @audioRouteAirplay.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'AirPlay'**
|
||||
String get audioRouteAirplay;
|
||||
|
||||
/// No description provided for @audioRouteUnknown.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Unknown'**
|
||||
String get audioRouteUnknown;
|
||||
|
||||
/// No description provided for @channelJoinFailedPermission.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
|
||||
@@ -266,6 +266,9 @@ class AppL10nEn extends AppL10n {
|
||||
@override
|
||||
String get voiceBindKeyAction => 'Bind PTT key';
|
||||
|
||||
@override
|
||||
String get voiceBoundKeyLabel => 'Bound key';
|
||||
|
||||
@override
|
||||
String get voicePttHoldHint => 'Hold the button';
|
||||
|
||||
@@ -275,6 +278,33 @@ class AppL10nEn extends AppL10n {
|
||||
@override
|
||||
String get voiceMicOff => 'off';
|
||||
|
||||
@override
|
||||
String get voiceSheetTitle => 'Voice';
|
||||
|
||||
@override
|
||||
String get audioOutputLabel => 'Audio output';
|
||||
|
||||
@override
|
||||
String get audioRouteSpeaker => 'Speaker';
|
||||
|
||||
@override
|
||||
String get audioRouteReceiver => 'iPhone receiver';
|
||||
|
||||
@override
|
||||
String get audioRouteBluetooth => 'Bluetooth';
|
||||
|
||||
@override
|
||||
String get audioRouteWiredHeadset => 'Wired headset';
|
||||
|
||||
@override
|
||||
String get audioRouteCarAudio => 'Car audio';
|
||||
|
||||
@override
|
||||
String get audioRouteAirplay => 'AirPlay';
|
||||
|
||||
@override
|
||||
String get audioRouteUnknown => 'Unknown';
|
||||
|
||||
@override
|
||||
String get channelJoinFailedPermission =>
|
||||
'Insufficient permission to join this channel.';
|
||||
|
||||
@@ -260,6 +260,9 @@ class AppL10nZh extends AppL10n {
|
||||
@override
|
||||
String get voiceBindKeyAction => '绑定 PTT 按键';
|
||||
|
||||
@override
|
||||
String get voiceBoundKeyLabel => '已绑定按键';
|
||||
|
||||
@override
|
||||
String get voicePttHoldHint => '按住按钮';
|
||||
|
||||
@@ -269,6 +272,33 @@ class AppL10nZh extends AppL10n {
|
||||
@override
|
||||
String get voiceMicOff => '关闭';
|
||||
|
||||
@override
|
||||
String get voiceSheetTitle => '语音';
|
||||
|
||||
@override
|
||||
String get audioOutputLabel => '音频输出';
|
||||
|
||||
@override
|
||||
String get audioRouteSpeaker => '扬声器';
|
||||
|
||||
@override
|
||||
String get audioRouteReceiver => '听筒';
|
||||
|
||||
@override
|
||||
String get audioRouteBluetooth => '蓝牙';
|
||||
|
||||
@override
|
||||
String get audioRouteWiredHeadset => '有线耳机';
|
||||
|
||||
@override
|
||||
String get audioRouteCarAudio => '车载音频';
|
||||
|
||||
@override
|
||||
String get audioRouteAirplay => 'AirPlay';
|
||||
|
||||
@override
|
||||
String get audioRouteUnknown => '未知';
|
||||
|
||||
@override
|
||||
String get channelJoinFailedPermission => '权限不足,无法加入此频道。';
|
||||
|
||||
|
||||
@@ -563,31 +563,18 @@ class _BetaHomeState extends State<_BetaHome> {
|
||||
}
|
||||
|
||||
Future<String?> _askChannelPassword(AppL10n l10n) async {
|
||||
final ctl = TextEditingController();
|
||||
final result = await showDialog<String>(
|
||||
// Same pattern as _onAddCurrentBookmark: route the dialog
|
||||
// through a dedicated StatefulWidget so its
|
||||
// TextEditingController is disposed at unmount time, not
|
||||
// synchronously after `await showDialog` resumes. Inline
|
||||
// dispose-after-await caused framework.dart:6268
|
||||
// _dependents.isEmpty assertions on iOS \u2014 the controller was
|
||||
// torn out while EditableText still depended on InheritedWidgets
|
||||
// belonging to the still-popping dialog route.
|
||||
return await showDialog<String>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: Text(l10n.channelPasswordTitle),
|
||||
content: TextField(
|
||||
controller: ctl,
|
||||
obscureText: true,
|
||||
autofocus: true,
|
||||
decoration: InputDecoration(labelText: l10n.fieldPassword),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(ctx).pop(),
|
||||
child: Text(l10n.closeAction),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.of(ctx).pop(ctl.text),
|
||||
child: Text(l10n.connectAction),
|
||||
),
|
||||
],
|
||||
),
|
||||
builder: (ctx) => const _ChannelPasswordDialog(),
|
||||
);
|
||||
ctl.dispose();
|
||||
return result;
|
||||
}
|
||||
|
||||
Future<void> _onRefresh() async {
|
||||
@@ -794,31 +781,33 @@ class _BetaHomeState extends State<_BetaHome> {
|
||||
}
|
||||
|
||||
Future<void> _onAddCurrentBookmark() async {
|
||||
final l10n = AppL10n.of(context);
|
||||
final nameCtl = TextEditingController(text: _hostCtl.text.trim());
|
||||
// Use the dialog's own context for AppL10n.of(...) inside its
|
||||
// builder. Capturing the outer _HomePageState context's l10n in
|
||||
// a closure and reading it inside the dialog's widget tree
|
||||
// caused the dialog's TextField to depend on InheritedElements
|
||||
// (Localizations / _LocalizationsScope) that belong to the
|
||||
// outer route. When the dialog popped, the framework
|
||||
// deactivated the dialog route's elements first while those
|
||||
// outer-route InheritedElements were still alive but had
|
||||
// dependents from the disposed dialog tree \u2014 producing the
|
||||
// assertion 'package:flutter/src/widgets/framework.dart line
|
||||
// 6268 _dependents.isEmpty is not true'.
|
||||
//
|
||||
// Also: dispose the TextEditingController via the dialog's own
|
||||
// StatefulBuilder lifecycle instead of an inline dispose() right
|
||||
// after showDialog returns. The inline dispose runs synchronously
|
||||
// before the dialog route is fully torn down (the route pop
|
||||
// animation is still mid-flight on iOS), and tearing the
|
||||
// controller out from under EditableText while it has a live
|
||||
// InheritedWidget dependency was the second trigger for the
|
||||
// same assertion.
|
||||
final initial = _hostCtl.text.trim();
|
||||
final name = await showDialog<String>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: Text(l10n.bookmarkAddTitle),
|
||||
content: TextField(
|
||||
controller: nameCtl,
|
||||
autofocus: true,
|
||||
decoration: InputDecoration(labelText: l10n.fieldDisplayName),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(ctx).pop(),
|
||||
child: Text(l10n.closeAction),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.of(ctx).pop(nameCtl.text),
|
||||
child: Text(l10n.bookmarkAddAction),
|
||||
),
|
||||
],
|
||||
),
|
||||
builder: (ctx) => _BookmarkNameDialog(initialName: initial),
|
||||
);
|
||||
nameCtl.dispose();
|
||||
if (name == null || name.trim().isEmpty) return;
|
||||
if (!mounted) return;
|
||||
try {
|
||||
await rust.addBookmark(
|
||||
b: rust.BridgeBookmark(
|
||||
@@ -1238,6 +1227,108 @@ class _AppBarTitle extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
/// 'Save bookmark' name-entry dialog. Owns its own
|
||||
/// TextEditingController via a StatefulWidget lifecycle so the
|
||||
/// dispose() runs cleanly at unmount time (after the route pop
|
||||
/// animation has fully detached the dialog subtree), not
|
||||
/// synchronously from the caller's `await showDialog` resumption
|
||||
/// point.
|
||||
///
|
||||
/// Inline dispose-after-showDialog pattern previously caused
|
||||
/// 'framework.dart line 6268 _dependents.isEmpty' on iOS because
|
||||
/// the controller was torn down mid-pop while EditableText still
|
||||
/// had live InheritedWidget dependencies on the dialog route.
|
||||
class _BookmarkNameDialog extends StatefulWidget {
|
||||
const _BookmarkNameDialog({required this.initialName});
|
||||
|
||||
final String initialName;
|
||||
|
||||
@override
|
||||
State<_BookmarkNameDialog> createState() => _BookmarkNameDialogState();
|
||||
}
|
||||
|
||||
class _BookmarkNameDialogState extends State<_BookmarkNameDialog> {
|
||||
late final TextEditingController _ctl =
|
||||
TextEditingController(text: widget.initialName);
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_ctl.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppL10n.of(context);
|
||||
return AlertDialog(
|
||||
title: Text(l10n.bookmarkAddTitle),
|
||||
content: TextField(
|
||||
controller: _ctl,
|
||||
autofocus: true,
|
||||
decoration: InputDecoration(labelText: l10n.fieldDisplayName),
|
||||
onSubmitted: (_) => Navigator.of(context).pop(_ctl.text),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: Text(l10n.closeAction),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.of(context).pop(_ctl.text),
|
||||
child: Text(l10n.bookmarkAddAction),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Channel-password dialog. Same StatefulWidget pattern as
|
||||
/// [_BookmarkNameDialog] to keep TextEditingController disposal
|
||||
/// inside the dialog's own lifecycle and avoid the
|
||||
/// framework.dart:6268 _dependents.isEmpty assertion.
|
||||
class _ChannelPasswordDialog extends StatefulWidget {
|
||||
const _ChannelPasswordDialog();
|
||||
|
||||
@override
|
||||
State<_ChannelPasswordDialog> createState() =>
|
||||
_ChannelPasswordDialogState();
|
||||
}
|
||||
|
||||
class _ChannelPasswordDialogState extends State<_ChannelPasswordDialog> {
|
||||
final TextEditingController _ctl = TextEditingController();
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_ctl.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppL10n.of(context);
|
||||
return AlertDialog(
|
||||
title: Text(l10n.channelPasswordTitle),
|
||||
content: TextField(
|
||||
controller: _ctl,
|
||||
obscureText: true,
|
||||
autofocus: true,
|
||||
decoration: InputDecoration(labelText: l10n.fieldPassword),
|
||||
onSubmitted: (_) => Navigator.of(context).pop(_ctl.text),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: Text(l10n.closeAction),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.of(context).pop(_ctl.text),
|
||||
child: Text(l10n.connectAction),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ConnectForm extends StatefulWidget {
|
||||
const _ConnectForm({
|
||||
required this.hostCtl,
|
||||
|
||||
Reference in New Issue
Block a user