feat(remote): 修复键盘输入映射并重构连接流程
- Android端注入按键时显式设置SOURCE_KEYBOARD,修复IME丢弃事件导致输入框无法输入文字的问题 - Flutter端新增Flutter逻辑键到Android keyCode的转换映射,避免功能键误触发 - 重构RemoteController,分离信令连接与远程控制逻辑,支持设备列表选择与配对码兑换 - AuthRepository接口新增getBindings、getOnlineDevices、redeemPairingCode方法 - DioAuthRepository实现401自动刷新token重试机制 - ConnectionSessionState扩展设备列表、在线状态、配对码等状态字段
This commit is contained in:
@@ -266,7 +266,8 @@ class _ControlPageState extends ConsumerState<ControlPage> {
|
||||
CupertinoButton(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
|
||||
onPressed: () async {
|
||||
await ref.read(connectionControllerProvider.notifier).disconnect();
|
||||
// 仅结束远程控制,保留信令连接以便返回设置页后直接重连。
|
||||
await ref.read(connectionControllerProvider.notifier).stopControl();
|
||||
if (mounted) context.go('/');
|
||||
},
|
||||
child: const Icon(
|
||||
|
||||
@@ -6,11 +6,12 @@ import 'package:webrtc_controller_flutter/l10n/app_localizations.dart';
|
||||
import '../../../../app/constants/app_constants.dart';
|
||||
import '../../../auth/presentation/auth_controller.dart';
|
||||
import '../../../auth/presentation/widgets/login_dialog.dart';
|
||||
import '../../domain/binding_device.dart';
|
||||
import '../../domain/connection_session_state.dart';
|
||||
import '../connection_controller.dart';
|
||||
import '../widgets/auth_dialog.dart';
|
||||
|
||||
/// 连接设置页:服务器地址 / 目标设备ID / 登录 / 连接。
|
||||
/// 连接设置页:服务器地址 / 绑定设备选择 / 登录 / 连接。
|
||||
class SetupPage extends ConsumerStatefulWidget {
|
||||
const SetupPage({super.key});
|
||||
|
||||
@@ -19,32 +20,73 @@ class SetupPage extends ConsumerStatefulWidget {
|
||||
}
|
||||
|
||||
class _SetupPageState extends ConsumerState<SetupPage> {
|
||||
final _serverUrlController = TextEditingController(
|
||||
text: kDefaultSignalServer,
|
||||
);
|
||||
final _deviceIdController = TextEditingController();
|
||||
final _targetController = TextEditingController(text: '981964879');
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
// build() 期间不能改动 provider 状态,推迟到首帧之后。
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) => _autoConnectSignaling());
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_serverUrlController.dispose();
|
||||
_deviceIdController.dispose();
|
||||
_targetController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
/// 进入页面后自动建立信令(WebSocket)连接,并拉取绑定设备列表。
|
||||
///
|
||||
/// 仅连接信令并完成注册,**不会**向任何设备发起远程控制;
|
||||
/// 能进入本页即已登录,携带 Bearer token 即可建连。
|
||||
Future<void> _autoConnectSignaling() async {
|
||||
if (!mounted) return;
|
||||
final loggedIn =
|
||||
ref.read(authControllerProvider).valueOrNull?.loggedIn ?? false;
|
||||
if (!loggedIn) return;
|
||||
|
||||
await ref
|
||||
.read(connectionControllerProvider.notifier)
|
||||
.connectSignaling(serverUrl: kDefaultSignalServer);
|
||||
if (!mounted) return;
|
||||
await ref.read(connectionControllerProvider.notifier).loadDevices();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final authState = ref.watch(authControllerProvider).valueOrNull;
|
||||
final connection = ref.watch(connectionControllerProvider);
|
||||
|
||||
// 连接建立后跳转控制页。
|
||||
// 登录完成后补发信令连接;退出登录则断开信令。
|
||||
ref.listen(authControllerProvider, (prev, next) {
|
||||
final was = prev?.valueOrNull?.loggedIn ?? false;
|
||||
final now = next.valueOrNull?.loggedIn ?? false;
|
||||
if (!was && now) {
|
||||
_autoConnectSignaling();
|
||||
} else if (was && !now) {
|
||||
ref.read(connectionControllerProvider.notifier).disconnect();
|
||||
}
|
||||
});
|
||||
|
||||
// 信令注册成功后回显服务端下发的本机设备ID。
|
||||
final myDeviceId = connection.myDeviceId ?? '';
|
||||
if (_deviceIdController.text != myDeviceId) {
|
||||
_deviceIdController.text = myDeviceId;
|
||||
}
|
||||
|
||||
// 连接建立后跳转控制页;连接失败等一次性提示展示后立即消费。
|
||||
ref.listen<ConnectionSessionState>(
|
||||
connectionControllerProvider,
|
||||
(prev, next) {
|
||||
if ((prev?.connected ?? false) == false && next.connected) {
|
||||
context.go('/control');
|
||||
return;
|
||||
}
|
||||
final alert = next.alert;
|
||||
if (alert != null && alert.isNotEmpty) {
|
||||
ref.read(connectionControllerProvider.notifier).consumeAlert();
|
||||
_showAlert(alert);
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -59,25 +101,6 @@ class _SetupPageState extends ConsumerState<SetupPage> {
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
l10n.appTitle,
|
||||
style: const TextStyle(
|
||||
fontSize: 24,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Text(l10n.serverUrl, style: const TextStyle(fontSize: 14)),
|
||||
const SizedBox(height: 8),
|
||||
CupertinoTextField(
|
||||
controller: _serverUrlController,
|
||||
placeholder: l10n.serverUrlPlaceholder,
|
||||
padding: const EdgeInsets.symmetric(
|
||||
vertical: 12,
|
||||
horizontal: 12,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(l10n.deviceId, style: const TextStyle(fontSize: 14)),
|
||||
const SizedBox(height: 8),
|
||||
CupertinoTextField(
|
||||
@@ -89,18 +112,9 @@ class _SetupPageState extends ConsumerState<SetupPage> {
|
||||
horizontal: 12,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(l10n.targetDeviceId, style: const TextStyle(fontSize: 14)),
|
||||
const SizedBox(height: 8),
|
||||
CupertinoTextField(
|
||||
controller: _targetController,
|
||||
placeholder: l10n.targetDeviceIdPlaceholder,
|
||||
padding: const EdgeInsets.symmetric(
|
||||
vertical: 12,
|
||||
horizontal: 12,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
_buildDeviceSection(context, l10n, connection),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
connection.status,
|
||||
style: const TextStyle(
|
||||
@@ -108,6 +122,13 @@ class _SetupPageState extends ConsumerState<SetupPage> {
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
if (connection.deviceError != null) ...[
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
connection.deviceError!,
|
||||
style: const TextStyle(fontSize: 13, color: CupertinoColors.systemRed),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 16),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
@@ -129,7 +150,9 @@ class _SetupPageState extends ConsumerState<SetupPage> {
|
||||
}
|
||||
},
|
||||
child: Text(
|
||||
(authState?.loggedIn ?? false) ? l10n.logout : l10n.loginAccount,
|
||||
(authState?.loggedIn ?? false)
|
||||
? l10n.logout
|
||||
: l10n.loginAccount,
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -150,12 +173,142 @@ class _SetupPageState extends ConsumerState<SetupPage> {
|
||||
);
|
||||
}
|
||||
|
||||
/// 绑定设备列表:展示已绑定设备、在线状态、选择,并提供刷新与配对码绑定。
|
||||
Widget _buildDeviceSection(
|
||||
BuildContext context,
|
||||
AppLocalizations l10n,
|
||||
ConnectionSessionState connection,
|
||||
) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(l10n.boundDevices, style: const TextStyle(fontSize: 16)),
|
||||
CupertinoButton(
|
||||
padding: EdgeInsets.zero,
|
||||
onPressed: connection.deviceRefreshing
|
||||
? null
|
||||
: () => ref
|
||||
.read(connectionControllerProvider.notifier)
|
||||
.loadDevices(),
|
||||
child: connection.deviceRefreshing
|
||||
? const CupertinoActivityIndicator(radius: 9)
|
||||
: Text(l10n.refreshDevices),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
if (connection.bindings.isEmpty)
|
||||
_buildEmptyHint(context, l10n, connection)
|
||||
else
|
||||
_DeviceList(
|
||||
devices: connection.bindings,
|
||||
selectedUid: connection.selectedDeviceUid,
|
||||
onSelect: (uid) => ref
|
||||
.read(connectionControllerProvider.notifier)
|
||||
.selectDevice(uid),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
CupertinoButton(
|
||||
padding: EdgeInsets.zero,
|
||||
onPressed: connection.redeeming
|
||||
? null
|
||||
: () => _showRedeemDialog(context, l10n),
|
||||
child: connection.redeeming
|
||||
? const CupertinoActivityIndicator()
|
||||
: Text(l10n.redeemPairingCode),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// 无绑定设备时的引导提示(含配对失败提示)。
|
||||
Widget _buildEmptyHint(
|
||||
BuildContext context,
|
||||
AppLocalizations l10n,
|
||||
ConnectionSessionState connection,
|
||||
) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
l10n.noBoundDevices,
|
||||
style: const TextStyle(fontSize: 14, color: CupertinoColors.systemGrey),
|
||||
),
|
||||
if (connection.redeemError != null) ...[
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
connection.redeemError!,
|
||||
style: const TextStyle(fontSize: 13, color: CupertinoColors.systemRed),
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// 兑换配对码对话框。
|
||||
Future<void> _showRedeemDialog(
|
||||
BuildContext context,
|
||||
AppLocalizations l10n,
|
||||
) async {
|
||||
final controller = TextEditingController();
|
||||
final ok = await showCupertinoDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => CupertinoAlertDialog(
|
||||
title: Text(l10n.redeemPairingCode),
|
||||
content: Column(
|
||||
children: [
|
||||
const SizedBox(height: 12),
|
||||
CupertinoTextField(
|
||||
controller: controller,
|
||||
placeholder: l10n.pairingCodeHint,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
|
||||
),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
CupertinoDialogAction(
|
||||
child: Text(l10n.cancel),
|
||||
onPressed: () => Navigator.of(ctx).pop(false),
|
||||
),
|
||||
CupertinoDialogAction(
|
||||
child: Text(l10n.confirm),
|
||||
onPressed: () => Navigator.of(ctx).pop(true),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (ok != true || !mounted) {
|
||||
controller.dispose();
|
||||
return;
|
||||
}
|
||||
final code = controller.text.trim();
|
||||
// 延迟到下一帧再释放,避免对话框卸载时焦点回调访问已释放的 controller。
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) => controller.dispose());
|
||||
if (code.isEmpty) return;
|
||||
final success = await ref
|
||||
.read(connectionControllerProvider.notifier)
|
||||
.redeemPairingCode(code);
|
||||
if (success && mounted) {
|
||||
_showAlert(l10n.bindSuccess);
|
||||
} else if (mounted) {
|
||||
final err = ref.read(connectionControllerProvider).redeemError;
|
||||
if (err != null && err.isNotEmpty) _showAlert(err);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _onConnectPressed() async {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final serverUrl = _serverUrlController.text.trim();
|
||||
final target = _targetController.text.trim();
|
||||
if (serverUrl.isEmpty || target.isEmpty) {
|
||||
_showAlert(l10n.serverAndTargetRequired);
|
||||
final selected =
|
||||
ref.read(connectionControllerProvider.notifier).selectedDevice;
|
||||
if (selected == null) {
|
||||
_showAlert(l10n.pleaseSelectDevice);
|
||||
return;
|
||||
}
|
||||
if (!selected.online) {
|
||||
_showAlert(l10n.deviceOfflineCannotConnect(selected.name ?? selected.deviceUid));
|
||||
return;
|
||||
}
|
||||
// 连接前确保已登录(Bearer token)。
|
||||
@@ -169,12 +322,16 @@ class _SetupPageState extends ConsumerState<SetupPage> {
|
||||
if (ok != true || !mounted) return;
|
||||
}
|
||||
|
||||
// 信令若尚未就绪(如刚登录或曾断开),先补建连接。
|
||||
if (!ref.read(connectionControllerProvider).signalingConnected) {
|
||||
await _autoConnectSignaling();
|
||||
if (!mounted) return;
|
||||
}
|
||||
|
||||
final selection = await showAuthDialog(context);
|
||||
if (selection == null || !mounted) return;
|
||||
|
||||
await ref.read(connectionControllerProvider.notifier).connect(
|
||||
serverUrl: serverUrl,
|
||||
targetDeviceId: target,
|
||||
await ref.read(connectionControllerProvider.notifier).connectSelectedDevice(
|
||||
authType: selection.type,
|
||||
authValue: selection.value,
|
||||
);
|
||||
@@ -195,3 +352,38 @@ class _SetupPageState extends ConsumerState<SetupPage> {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 绑定设备选择列表(Cupertino 风格)。
|
||||
class _DeviceList extends StatelessWidget {
|
||||
const _DeviceList({
|
||||
required this.devices,
|
||||
required this.selectedUid,
|
||||
required this.onSelect,
|
||||
});
|
||||
|
||||
final List<BindingDevice> devices;
|
||||
final String? selectedUid;
|
||||
final ValueChanged<String> onSelect;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
return CupertinoListSection.insetGrouped(
|
||||
margin: EdgeInsets.zero,
|
||||
children: [
|
||||
for (final d in devices)
|
||||
CupertinoListTile(
|
||||
title: Text(d.name ?? d.deviceUid),
|
||||
subtitle: Text(d.online ? l10n.online : l10n.offline),
|
||||
trailing: selectedUid == d.deviceUid
|
||||
? const Icon(
|
||||
CupertinoIcons.check_mark_circled_solid,
|
||||
color: CupertinoColors.activeBlue,
|
||||
)
|
||||
: null,
|
||||
onTap: () => onSelect(d.deviceUid),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user