seilon_voice_plugin 0.1.4
seilon_voice_plugin: ^0.1.4 copied to clipboard
Flutter bridge for the Seilon BLE recorder SDK on Android and iOS.
example/lib/main.dart
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:seilon_voice_plugin/seilon_voice_plugin.dart';
void main() {
runApp(const SeilonExampleApp());
}
class SeilonExampleApp extends StatefulWidget {
const SeilonExampleApp({super.key, this.plugin});
final SeilonVoicePlugin? plugin;
@override
State<SeilonExampleApp> createState() => _SeilonExampleAppState();
}
class _SeilonExampleAppState extends State<SeilonExampleApp> {
late final SeilonVoicePlugin _plugin = widget.plugin ?? SeilonVoicePlugin();
StreamSubscription<SeilonSdkEvent>? _eventSubscription;
final Map<String, SeilonDevice> _devices = <String, SeilonDevice>{};
final List<SeilonLogEntry> _logs = <SeilonLogEntry>[];
final List<SeilonFileEntry> _files = <SeilonFileEntry>[];
final TextEditingController _ssidController = TextEditingController();
final TextEditingController _passwordController = TextEditingController();
final TextEditingController _tcpHostController = TextEditingController(
text: '192.168.4.1',
);
final TextEditingController _tcpPortController = TextEditingController(
text: '8899',
);
final TextEditingController _pageController = TextEditingController(
text: '1',
);
final TextEditingController _screenSecondsController = TextEditingController(
text: '60',
);
final TextEditingController _brightnessController = TextEditingController(
text: '99',
);
SeilonRecorderSession? _session;
SeilonDevice? _deviceInfo;
SeilonFilePage? _filePage;
String? _selectedDeviceId;
String? _selectedFileName;
String? _testRecordingFileName;
String _status = '正在初始化 SDK…';
String _downloadPath = '';
bool _initialized = false;
bool _scanning = false;
bool _busy = false;
bool _downloading = false;
bool _wifiCredentialsFilled = false;
bool _obscureWifiPassword = true;
int _audioFrames = 0;
int _audioBytes = 0;
int _lastAudioFrameBytes = 0;
int _downloadReceivedBytes = 0;
int _downloadTotalBytes = 0;
String? _downloadFileName;
int _selectedTab = 0;
int _bindStep = 0;
bool get _connected => _session != null;
bool get _canOperate => _initialized && !_busy;
bool get _canUseSession => _connected && !_busy;
double? get _downloadProgress => _downloadTotalBytes <= 0
? null
: (_downloadReceivedBytes / _downloadTotalBytes).clamp(0, 1);
int get _downloadPercent => ((_downloadProgress ?? 0) * 100).round();
@override
void initState() {
super.initState();
_eventSubscription = _plugin.events.listen(
_onEvent,
onError: (Object error) => _setStatus('事件流错误:$error'),
);
unawaited(_initialize());
}
Future<void> _initialize() => _run('初始化 SDK', () async {
await _plugin.initialize();
_initialized = true;
});
void _onEvent(SeilonSdkEvent event) {
if (!mounted) return;
switch (event) {
case SeilonScanStartedEvent():
setState(() {
_scanning = true;
_status = '扫描已启动';
});
case SeilonScanStoppedEvent():
setState(() {
_scanning = false;
_status = '扫描已停止,发现 ${_devices.length} 台设备';
});
case SeilonDeviceFoundEvent(:final device):
setState(() {
_devices[device.id] = device;
_selectedDeviceId ??= device.id;
_status = '发现 ${device.name} · ${device.id} · RSSI ${device.rssi}';
});
case SeilonConnectedEvent(:final device):
setState(() {
_devices[device.id] = device;
_status = '原生事件:已连接 ${device.id}';
});
case SeilonDisconnectedEvent(:final reason):
setState(() {
_session = null;
_deviceInfo = null;
_bindStep = 0;
_status = reason.isEmpty ? '设备已断开' : '设备已断开:$reason';
});
case SeilonScanFailedEvent(:final error):
setState(() {
_scanning = false;
_status = '${error.code.name}: ${error.message}';
});
case SeilonLogEvent(:final entry):
final credentials = SeilonWifiCredentials.tryParsePacketHex(
entry.rawPacketHex,
);
final transferProgress = SeilonTransferProgress.tryParseLog(entry);
setState(() {
_logs.add(entry);
if (_logs.length > 100) _logs.removeAt(0);
if (credentials != null) {
_ssidController.text = credentials.ssid;
_passwordController.text = credentials.password;
_wifiCredentialsFilled = true;
_status = '已获取设备热点 ${credentials.ssid},账号和密码已自动填入网络页';
}
if (_downloading && transferProgress != null) {
_downloadReceivedBytes = transferProgress.receivedBytes;
_downloadTotalBytes = transferProgress.totalBytes;
}
});
case SeilonAudioFrameEvent(:final opus):
_audioFrames += 1;
_audioBytes += opus.length;
_lastAudioFrameBytes = opus.length;
if (_audioFrames % 10 == 0) setState(() {});
}
}
Future<void> _run(String action, Future<void> Function() operation) async {
if (_busy) return;
final pendingStatus = '$action…';
setState(() {
_busy = true;
_status = pendingStatus;
});
try {
await operation();
if (mounted) {
if (_status == pendingStatus) {
_setStatus('$action 成功');
} else {
setState(() {});
}
}
} on SeilonSdkException catch (error) {
if (mounted) _setStatus('${error.code.name}: ${error.message}');
} on Object catch (error) {
if (mounted) _setStatus('$action 失败:$error');
} finally {
if (mounted) setState(() => _busy = false);
}
}
void _setStatus(String value) {
if (mounted) setState(() => _status = value);
}
Future<void> _requestPermission(SeilonPermissionOperation operation) =>
_run('申请 ${operation.name} 权限', () async {
if (defaultTargetPlatform == TargetPlatform.android) {
final report = await _plugin.requestPermissions(operation);
_status = report.granted
? '${operation.name} 权限与系统服务可用'
: '缺少权限 ${report.missingRuntimePermissions.join(', ')};'
'Manifest ${report.missingManifestPermissions.join(', ')};'
'蓝牙 ${report.bluetoothEnabled},Wi-Fi ${report.wifiEnabled}';
} else {
_status = 'iOS 将在扫描或连接本地网络时显示系统授权提示';
}
});
Future<void> _startScan() => _run('启动扫描', () async {
if (defaultTargetPlatform == TargetPlatform.android) {
final report = await _plugin.requestPermissions(
SeilonPermissionOperation.scan,
);
if (!report.granted) {
throw const SeilonSdkException(
SeilonErrorCode.permissionDenied,
'扫描权限、Manifest 声明或蓝牙状态不可用。',
);
}
}
_devices.clear();
_selectedDeviceId = null;
await _plugin.startScan();
});
Future<void> _connect() => _run('连接设备', () async {
final id = _selectedDeviceId;
if (id == null) throw ArgumentError('请先扫描并选择设备');
if (defaultTargetPlatform == TargetPlatform.android) {
final report = await _plugin.requestPermissions(
SeilonPermissionOperation.connect,
);
if (!report.granted) {
throw const SeilonSdkException(
SeilonErrorCode.permissionDenied,
'连接权限或蓝牙状态不可用。',
);
}
}
_session = await _plugin.connect(id);
_bindStep = 0;
});
SeilonRecorderSession _requireSession() {
final value = _session;
if (value == null) throw StateError('请先连接设备');
return value;
}
Future<void> _queryDevice() => _run('读取设备信息', () async {
_deviceInfo = await _requireSession().queryDeviceInfo();
});
Future<void> _beginBind() => _run('开始绑定', () async {
await _requireSession().beginBind();
_bindStep = 1;
});
Future<void> _handshake() => _run('设备握手', () async {
await _requireSession().handshake();
_bindStep = 2;
});
Future<void> _completeBind() => _run('完成绑定', () async {
await _requireSession().completeBind();
_bindStep = 3;
});
Future<void> _startRecording() => _run('开始录音', () async {
final session = _requireSession();
await session.startRecording();
await Future<void>.delayed(const Duration(milliseconds: 600));
final info = await session.queryDeviceInfo();
_deviceInfo = info;
if (info.recordingFileName.isNotEmpty) {
_testRecordingFileName = info.recordingFileName;
}
});
Future<void> _enableDeviceWifi() => _run('开启设备 Wi-Fi', () async {
_wifiCredentialsFilled = false;
await _requireSession().setWifiEnabled(true);
});
Future<void> _connectTcpUsingCurrentEndpoint() =>
_run('使用当前地址连接 TCP', () async {
final host = _tcpHostController.text.trim();
final port = int.tryParse(_tcpPortController.text) ?? 0;
final session = _requireSession();
await session.configureTcp(host: host, port: port);
await session.connectTcp();
_status = 'TCP 已连接:$host:$port';
});
Future<void> _queryFiles({bool countOnly = false}) =>
_run(countOnly ? '读取文件数量' : '读取文件列表', () async {
final session = _requireSession();
final page = countOnly
? await session.queryFileCount()
: await session.queryFiles(int.tryParse(_pageController.text) ?? 1);
_filePage = page;
_files
..clear()
..addAll(page.files);
_selectedFileName =
_files.any((file) => file.fileName == _selectedFileName)
? _selectedFileName
: _files.firstOrNull?.fileName;
_status =
'共 ${page.totalCount} 个文件,第 ${page.pageNumber}/${page.pageCount} 页';
});
Future<void> _download({required bool resume}) async {
if (_busy) return;
final fileName = _selectedFileName;
if (fileName == null) {
_setStatus('请先选择文件');
return;
}
setState(() {
_busy = true;
_downloading = true;
_downloadFileName = fileName;
final selectedFile = _files
.where((file) => file.fileName == fileName)
.firstOrNull;
if (!resume || _downloadTotalBytes <= 0) {
_downloadReceivedBytes = 0;
_downloadTotalBytes = selectedFile?.fileSizeBytes ?? 0;
}
_status = resume ? '续传 $fileName…' : '下载 $fileName…';
});
try {
_downloadPath = resume
? await _requireSession().resumeFileDownload(fileName)
: await _requireSession().downloadFile(fileName);
if (_downloadTotalBytes > 0) {
_downloadReceivedBytes = _downloadTotalBytes;
}
_setStatus('文件已保存:$_downloadPath');
} on SeilonSdkException catch (error) {
_setStatus('${error.code.name}: ${error.message}');
} finally {
if (mounted) {
setState(() {
_downloading = false;
_busy = false;
});
}
}
}
Future<void> _cancelDownload() async {
try {
await _requireSession().cancelDownload();
_setStatus('已请求取消下载,可使用断点续传');
} on Object catch (error) {
_setStatus('取消下载失败:$error');
}
}
Future<bool> _confirm(String title, String message) async {
return await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: Text(title),
content: Text(message),
actions: <Widget>[
TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text('取消'),
),
FilledButton(
onPressed: () => Navigator.pop(context, true),
child: const Text('确认'),
),
],
),
) ??
false;
}
Future<void> _deleteTestFile() async {
final fileName = _selectedFileName;
if (fileName == null || fileName != _testRecordingFileName) {
_setStatus('安全限制:只能删除本轮测试录音 ${_testRecordingFileName ?? '(尚未识别)'}');
return;
}
if (!await _confirm('删除测试文件', '确认永久删除本轮新建的 $fileName?')) return;
await _run('删除测试文件', () async {
await _requireSession().deleteFile(fileName);
_files.removeWhere((file) => file.fileName == fileName);
_selectedFileName = _files.firstOrNull?.fileName;
});
}
Future<void> _powerOff() async {
if (!await _confirm('设备关机', '此操作应作为全部真机测试的最后一步。确认关机?')) return;
await _run('设备关机', () => _requireSession().powerOff());
}
@override
void dispose() {
unawaited(_eventSubscription?.cancel());
unawaited(_plugin.close());
for (final controller in <TextEditingController>[
_ssidController,
_passwordController,
_tcpHostController,
_tcpPortController,
_pageController,
_screenSecondsController,
_brightnessController,
]) {
controller.dispose();
}
super.dispose();
}
@override
Widget build(BuildContext context) {
const seed = Color(0xFF3157D5);
return MaterialApp(
debugShowCheckedModeBanner: false,
title: 'Seilon SDK 测试台',
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: seed),
useMaterial3: true,
scaffoldBackgroundColor: const Color(0xFFF6F7FB),
inputDecorationTheme: const InputDecorationTheme(
border: OutlineInputBorder(),
filled: true,
),
),
home: Scaffold(
appBar: AppBar(
title: const Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text('Seilon SDK 测试台'),
Text(
'BLE SDK 1.0.2',
style: TextStyle(fontSize: 12, fontWeight: FontWeight.normal),
),
],
),
actions: <Widget>[
if (_busy && !_downloading)
const Padding(
padding: EdgeInsets.only(right: 16),
child: SizedBox.square(
dimension: 22,
child: CircularProgressIndicator(strokeWidth: 2.5),
),
),
],
),
body: Column(
key: const ValueKey<String>('test_panel'),
children: <Widget>[
_statusBanner(),
Expanded(child: _tabBody()),
],
),
bottomNavigationBar: NavigationBar(
selectedIndex: _selectedTab,
onDestinationSelected: _busy
? null
: (value) => setState(() => _selectedTab = value),
destinations: const <NavigationDestination>[
NavigationDestination(
key: ValueKey<String>('workflow_tab'),
icon: Icon(Icons.route_outlined),
selectedIcon: Icon(Icons.route),
label: '流程',
),
NavigationDestination(
key: ValueKey<String>('control_tab'),
icon: Icon(Icons.tune_outlined),
selectedIcon: Icon(Icons.tune),
label: '设备',
),
NavigationDestination(
key: ValueKey<String>('network_tab'),
icon: Icon(Icons.wifi_outlined),
selectedIcon: Icon(Icons.wifi),
label: '网络',
),
NavigationDestination(
key: ValueKey<String>('file_tab'),
icon: Icon(Icons.folder_outlined),
selectedIcon: Icon(Icons.folder),
label: '文件',
),
NavigationDestination(
key: ValueKey<String>('event_tab'),
icon: Icon(Icons.terminal_outlined),
selectedIcon: Icon(Icons.terminal),
label: '日志',
),
],
),
),
);
}
Widget _tabBody() => switch (_selectedTab) {
0 => _workflowPage(),
1 => _controlPage(),
2 => _networkPage(),
3 => _filesPage(),
_ => _logsPage(),
};
Widget _statusBanner() {
final selected = _devices[_selectedDeviceId];
return Material(
color: Theme.of(context).colorScheme.surface,
elevation: 1,
child: SafeArea(
bottom: false,
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 10, 16, 12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: <Widget>[
Wrap(
spacing: 8,
runSpacing: 6,
children: <Widget>[
_statusChip(
_initialized ? 'SDK 已就绪' : 'SDK 未初始化',
_initialized,
),
_statusChip(_connected ? '设备已连接' : '设备未连接', _connected),
if (selected != null)
Chip(
avatar: const Icon(Icons.memory, size: 16),
label: Text(selected.name),
visualDensity: VisualDensity.compact,
),
],
),
const SizedBox(height: 8),
Row(
children: <Widget>[
Icon(
_busy ? Icons.pending_outlined : Icons.info_outline,
size: 18,
color: Theme.of(context).colorScheme.primary,
),
const SizedBox(width: 8),
Expanded(
child: Text(
_status,
key: const ValueKey<String>('status_text'),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
),
],
),
if (_busy) ...<Widget>[
const SizedBox(height: 8),
LinearProgressIndicator(
value: _downloading ? _downloadProgress : null,
minHeight: 2,
),
],
],
),
),
),
);
}
Widget _statusChip(String label, bool positive) => Chip(
avatar: Icon(
positive ? Icons.check_circle : Icons.circle_outlined,
size: 16,
color: positive ? Colors.green.shade700 : Colors.grey.shade600,
),
label: Text(label),
visualDensity: VisualDensity.compact,
);
Widget _workflowPage() => _pageShell(
key: const ValueKey<String>('device_section'),
children: <Widget>[
_pageHeading('快速验收', '从上到下完成 4 步。每一步只显示当前需要的操作。'),
_workflowCard(
step: 1,
title: '准备 SDK 与权限',
completed: _initialized,
active: !_initialized,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: <Widget>[
Text(_initialized ? 'SDK 已自动初始化,可以开始扫描。' : '初始化失败时,可在这里重新尝试。'),
const SizedBox(height: 12),
Wrap(
spacing: 8,
runSpacing: 8,
children: <Widget>[
_primaryAction(
'初始化 SDK',
Icons.power_settings_new,
_initialize,
key: const ValueKey<String>('initialize_button'),
enabled: !_initialized,
),
_action(
'检查扫描权限',
() => _requestPermission(SeilonPermissionOperation.scan),
icon: Icons.verified_user_outlined,
),
_action(
'关闭 SDK',
() => _run('关闭 SDK', () async {
await _plugin.close();
_initialized = false;
_session = null;
_deviceInfo = null;
_bindStep = 0;
}),
icon: Icons.power_off_outlined,
enabled: _initialized,
),
],
),
],
),
),
_workflowCard(
step: 2,
title: '扫描并连接设备',
completed: _connected,
active: _initialized && !_connected,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: <Widget>[
Wrap(
spacing: 8,
runSpacing: 8,
children: <Widget>[
if (!_scanning)
_primaryAction(
'开始扫描',
Icons.bluetooth_searching,
_startScan,
key: const ValueKey<String>('scan_button'),
enabled: _initialized && !_connected,
)
else
_primaryAction(
'停止扫描',
Icons.stop_circle_outlined,
() => _run('停止扫描', _plugin.stopScan),
key: const ValueKey<String>('scan_button'),
),
if (_connected)
_action(
'断开当前设备',
() => _run('断开设备', _plugin.disconnect),
icon: Icons.link_off,
),
],
),
const SizedBox(height: 12),
if (_devices.isEmpty)
_emptyState(
Icons.bluetooth_disabled,
_scanning ? '正在扫描附近设备…' : '尚未发现设备',
_scanning ? '设备出现后会自动显示在这里' : '点击“开始扫描”查找录音设备',
)
else
Column(
key: const ValueKey<String>('device_picker'),
children: _devices.values
.map(_deviceChoice)
.toList(growable: false),
),
if (!_connected && _selectedDeviceId != null) ...<Widget>[
const SizedBox(height: 12),
_primaryAction(
'连接所选设备',
Icons.link,
_connect,
key: const ValueKey<String>('connect_button'),
),
],
],
),
),
_workflowCard(
step: 3,
title: '完成设备绑定',
completed: _bindStep >= 3,
active: _connected && _bindStep < 3,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: <Widget>[
const Text('新设备依次执行三步;已经绑定过的测试设备可以直接跳过。'),
const SizedBox(height: 12),
Wrap(
spacing: 8,
runSpacing: 8,
children: <Widget>[
_bindAction(1, '1 开始绑定', _beginBind),
_bindAction(2, '2 设备握手', _handshake),
_bindAction(3, '3 完成绑定', _completeBind),
],
),
Align(
alignment: Alignment.centerLeft,
child: TextButton.icon(
onPressed: _connected && !_busy
? () => setState(() {
_bindStep = 3;
_status = '已跳过绑定步骤,请确认这是已绑定的测试设备';
})
: null,
icon: const Icon(Icons.skip_next),
label: const Text('设备已绑定,跳过此步'),
),
),
],
),
),
_workflowCard(
step: 4,
title: '读取信息并开始测试',
completed: _deviceInfo != null,
active: _connected && _bindStep >= 3,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: <Widget>[
Text(
_deviceInfo == null
? '读取设备状态,确认连接和绑定结果。'
: '${_deviceInfo!.name} · 电量 ${_deviceInfo!.batteryPercent}% · 固件 ${_deviceInfo!.firmwareVersion}',
),
const SizedBox(height: 12),
_primaryAction(
_deviceInfo == null ? '读取设备信息' : '进入设备测试',
Icons.arrow_forward,
() async {
if (_deviceInfo == null) await _queryDevice();
if (mounted) setState(() => _selectedTab = 1);
},
enabled: _connected,
),
],
),
),
const SizedBox(height: 12),
],
);
Widget _workflowCard({
required int step,
required String title,
required bool completed,
required bool active,
required Widget child,
}) {
final scheme = Theme.of(context).colorScheme;
return Card(
color: active ? scheme.primaryContainer.withValues(alpha: 0.35) : null,
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: <Widget>[
Row(
children: <Widget>[
CircleAvatar(
radius: 16,
backgroundColor: completed
? Colors.green.shade700
: active
? scheme.primary
: scheme.surfaceContainerHighest,
foregroundColor: completed || active
? Colors.white
: scheme.onSurfaceVariant,
child: completed
? const Icon(Icons.check, size: 18)
: Text('$step'),
),
const SizedBox(width: 12),
Expanded(
child: Text(
title,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w700,
),
),
),
if (active)
Text(
'当前步骤',
style: TextStyle(
color: scheme.primary,
fontWeight: FontWeight.w600,
),
),
],
),
const SizedBox(height: 14),
child,
],
),
),
);
}
Widget _deviceChoice(SeilonDevice device) {
final selected = device.id == _selectedDeviceId;
final scheme = Theme.of(context).colorScheme;
return Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Material(
color: selected ? scheme.secondaryContainer : scheme.surfaceContainer,
borderRadius: BorderRadius.circular(14),
child: InkWell(
borderRadius: BorderRadius.circular(14),
onTap: _canOperate && !_connected
? () => setState(() => _selectedDeviceId = device.id)
: null,
child: ListTile(
leading: CircleAvatar(
child: Icon(selected ? Icons.check : Icons.mic_none),
),
title: Text(
device.name.isEmpty ? '未命名 Seilon 设备' : device.name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
subtitle: Text('${device.id}\nRSSI ${device.rssi} dBm'),
isThreeLine: true,
trailing: selected
? Icon(Icons.check_circle, color: scheme.primary)
: const Icon(Icons.chevron_right),
),
),
),
);
}
Widget _controlPage() => _pageShell(
key: const ValueKey<String>('control_section'),
children: <Widget>[
_pageHeading('设备测试', '录音、设备信息和常用硬件控制。'),
_connectionNotice(),
_sectionCard(
Icons.info_outline,
'设备信息',
_deviceInfo == null
? Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: <Widget>[
const Text('连接后读取一次设备信息,页面会显示完整状态。'),
const SizedBox(height: 12),
_primaryAction(
'读取设备信息',
Icons.refresh,
_queryDevice,
enabled: _connected,
),
],
)
: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: <Widget>[
Wrap(
spacing: 8,
runSpacing: 8,
children: <Widget>[
_metric('电量', '${_deviceInfo!.batteryPercent}%'),
_metric(
'存储',
'${_deviceInfo!.storageUsedMb}/${_deviceInfo!.storageTotalMb} MB',
),
_metric('固件', _deviceInfo!.firmwareVersion),
_metric('录音', _deviceInfo!.recording ? '进行中' : '空闲'),
],
),
const SizedBox(height: 12),
ExpansionTile(
tilePadding: EdgeInsets.zero,
title: const Text('查看全部原始字段'),
children: <Widget>[
SelectableText(
_deviceText(_deviceInfo!),
key: const ValueKey<String>('device_info'),
style: const TextStyle(
fontFamily: 'monospace',
fontSize: 12,
),
),
],
),
Align(
alignment: Alignment.centerLeft,
child: _action('刷新设备信息', _queryDevice, icon: Icons.refresh),
),
],
),
),
_sectionCard(
Icons.graphic_eq,
'录音与 OPUS',
Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: <Widget>[
Text(
_deviceInfo?.recording == true
? '正在录音:${_deviceInfo!.recordingFileName}'
: '开始录音后,这里会实时统计 OPUS 帧。',
),
const SizedBox(height: 12),
Wrap(
spacing: 8,
runSpacing: 8,
children: <Widget>[
_primaryAction(
'开始录音',
Icons.fiber_manual_record,
_startRecording,
enabled: _connected && _deviceInfo?.recording != true,
),
_action(
'停止录音',
() => _run('停止录音', () => _requireSession().stopRecording()),
icon: Icons.stop,
enabled: _connected,
),
],
),
const SizedBox(height: 12),
Row(
children: <Widget>[
Expanded(child: _smallMetric('帧数', '$_audioFrames')),
const SizedBox(width: 8),
Expanded(child: _smallMetric('总字节', '$_audioBytes')),
const SizedBox(width: 8),
Expanded(child: _smallMetric('最后帧', '$_lastAudioFrameBytes B')),
],
),
],
),
),
_sectionCard(
Icons.settings_remote_outlined,
'硬件控制',
Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: <Widget>[
_controlGroup(
'USB 模式',
_deviceInfo == null
? '状态未知'
: _deviceInfo!.usbModeEnabled
? '当前已开启'
: '当前已关闭',
<Widget>[
_sessionAction('开启 USB', (s) => s.setUsbMode(true)),
_sessionAction('关闭 USB', (s) => s.setUsbMode(false)),
],
),
const Divider(height: 28),
_controlGroup(
'设备 Wi-Fi',
_deviceInfo == null
? '状态未知'
: _deviceInfo!.wifiEnabled
? '当前已开启'
: '当前已关闭',
<Widget>[
_primaryAction(
'开启并获取热点账号',
Icons.wifi_tethering,
_enableDeviceWifi,
enabled: _connected,
),
_sessionAction('关闭 Wi-Fi', (s) => s.setWifiEnabled(false)),
],
),
const Divider(height: 28),
Text('屏幕', style: Theme.of(context).textTheme.titleSmall),
const SizedBox(height: 10),
Row(
children: <Widget>[
Expanded(child: _numberField(_screenSecondsController, '亮屏秒数')),
const SizedBox(width: 8),
Expanded(
child: _numberField(_brightnessController, '亮度 0–100'),
),
],
),
const SizedBox(height: 10),
Wrap(
spacing: 8,
runSpacing: 8,
children: <Widget>[
_sessionAction(
'点亮屏幕',
(s) => s.setScreen(
enabled: true,
seconds: int.tryParse(_screenSecondsController.text) ?? 60,
brightness: int.tryParse(_brightnessController.text) ?? 99,
),
),
_sessionAction('关闭屏幕', (s) => s.setScreen(enabled: false)),
],
),
],
),
),
Card(
color: Theme.of(context).colorScheme.errorContainer,
child: ExpansionTile(
leading: const Icon(Icons.warning_amber_rounded),
title: const Text('危险操作'),
subtitle: const Text('关机必须放在全部测试的最后一步'),
childrenPadding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
children: <Widget>[
Align(
alignment: Alignment.centerLeft,
child: FilledButton.icon(
onPressed: _connected && !_busy ? _powerOff : null,
icon: const Icon(Icons.power_settings_new),
label: const Text('设备关机'),
),
),
],
),
),
const SizedBox(height: 12),
],
);
Widget _networkPage() => _pageShell(
key: const ValueKey<String>('network_section'),
children: <Widget>[
_pageHeading('网络与通道', '先连接设备热点,再配置 TCP 和切换数据通道。'),
_connectionNotice(),
_sectionCard(
Icons.wifi,
'1 · 连接设备热点',
Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: <Widget>[
if (_wifiCredentialsFilled) ...<Widget>[
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.green.shade50,
borderRadius: BorderRadius.circular(12),
),
child: Row(
children: <Widget>[
Icon(Icons.check_circle, color: Colors.green.shade700),
const SizedBox(width: 8),
const Expanded(child: Text('已从设备回调自动填入热点账号和密码')),
],
),
),
const SizedBox(height: 12),
],
TextField(
key: const ValueKey<String>('ssid_field'),
controller: _ssidController,
autocorrect: false,
decoration: const InputDecoration(
labelText: '热点 SSID',
prefixIcon: Icon(Icons.wifi),
),
),
const SizedBox(height: 10),
TextField(
key: const ValueKey<String>('wifi_password_field'),
controller: _passwordController,
obscureText: _obscureWifiPassword,
autocorrect: false,
enableSuggestions: false,
decoration: InputDecoration(
labelText: '热点密码',
prefixIcon: const Icon(Icons.key),
suffixIcon: IconButton(
tooltip: _obscureWifiPassword ? '显示密码' : '隐藏密码',
onPressed: () => setState(
() => _obscureWifiPassword = !_obscureWifiPassword,
),
icon: Icon(
_obscureWifiPassword
? Icons.visibility_outlined
: Icons.visibility_off_outlined,
),
),
),
),
const SizedBox(height: 12),
Wrap(
spacing: 8,
runSpacing: 8,
children: <Widget>[
_action(
'检查 Wi-Fi 权限',
() => _requestPermission(SeilonPermissionOperation.wifi),
icon: Icons.verified_user_outlined,
),
_primaryAction(
'连接 Wi-Fi',
Icons.wifi_find,
() => _run('连接 Wi-Fi', () async {
await _requireSession().connectWifi(
ssid: _ssidController.text,
password: _passwordController.text,
);
}),
enabled: _connected,
),
_sessionAction('断开 Wi-Fi', (s) => s.disconnectWifi()),
],
),
],
),
),
_sectionCard(
Icons.lan_outlined,
'2 · 配置 TCP 服务',
Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: <Widget>[
Row(
children: <Widget>[
Expanded(
child: TextField(
key: const ValueKey<String>('tcp_host_field'),
controller: _tcpHostController,
autocorrect: false,
decoration: const InputDecoration(labelText: 'TCP Host'),
),
),
const SizedBox(width: 8),
SizedBox(
width: 112,
child: _numberField(_tcpPortController, '端口'),
),
],
),
const SizedBox(height: 12),
Wrap(
spacing: 8,
runSpacing: 8,
children: <Widget>[
_action(
'从设备读取地址',
() => _run('查询 TCP 地址', () async {
final endpoint = await _requireSession().queryTcpEndpoint();
_tcpHostController.text = endpoint.host;
_tcpPortController.text = endpoint.port.toString();
}),
key: const ValueKey<String>('query_tcp_button'),
icon: Icons.download,
enabled: _connected,
),
_sessionAction(
'写入 TCP 配置',
(s) => s.configureTcp(
host: _tcpHostController.text,
port: int.tryParse(_tcpPortController.text) ?? 0,
),
),
_primaryAction(
'使用当前地址连接 TCP',
Icons.cable,
_connectTcpUsingCurrentEndpoint,
key: const ValueKey<String>('connect_tcp_button'),
enabled: _connected,
),
_sessionAction('断开 TCP', (s) => s.disconnectTcp()),
],
),
],
),
),
_sectionCard(
Icons.swap_horiz,
'3 · 切换数据通道',
Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: <Widget>[
const Text('TCP 连接成功后切换到 TCP;需要低功耗控制时切回 BLE。'),
const SizedBox(height: 12),
Wrap(
spacing: 8,
runSpacing: 8,
children: <Widget>[
_sessionAction(
'切换到 BLE',
(s) => s.setChannel(SeilonChannel.ble),
),
_sessionAction(
'切换到 TCP',
(s) => s.setChannel(SeilonChannel.tcp),
),
],
),
],
),
),
const SizedBox(height: 12),
],
);
Widget _filesPage() => _pageShell(
key: const ValueKey<String>('file_section'),
children: <Widget>[
_pageHeading('文件传输', '选择一个文件后再下载;删除仅允许本轮创建的测试录音。'),
_connectionNotice(),
_sectionCard(
Icons.manage_search,
'查询设备文件',
Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: <Widget>[
Row(
children: <Widget>[
SizedBox(
width: 112,
child: _numberField(_pageController, '页码'),
),
const SizedBox(width: 8),
Expanded(
child: _primaryAction(
'读取这一页',
Icons.refresh,
_queryFiles,
key: const ValueKey<String>('query_files_button'),
enabled: _connected,
),
),
],
),
const SizedBox(height: 8),
Align(
alignment: Alignment.centerLeft,
child: _action(
'仅查询文件数量',
() => _queryFiles(countOnly: true),
icon: Icons.numbers,
enabled: _connected,
),
),
if (_filePage case final page?)
Padding(
padding: const EdgeInsets.only(top: 8),
child: Text(
'共 ${page.totalCount} 个文件 · 第 ${page.pageNumber}/${page.pageCount} 页',
style: Theme.of(context).textTheme.titleSmall,
),
),
],
),
),
_sectionCard(
Icons.folder_open,
'选择文件',
_files.isEmpty
? _emptyState(Icons.folder_off_outlined, '尚未加载文件', '连接设备后点击“读取这一页”')
: Column(
key: const ValueKey<String>('file_picker'),
children: _files.map(_fileChoice).toList(growable: false),
),
),
_sectionCard(
Icons.download_outlined,
'传输所选文件',
Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: <Widget>[
Text(
_selectedFileName == null ? '请先选择一个文件' : _selectedFileName!,
style: Theme.of(context).textTheme.titleSmall,
),
if (_downloadFileName == _selectedFileName &&
(_downloading || _downloadTotalBytes > 0)) ...<Widget>[
const SizedBox(height: 10),
Row(
children: <Widget>[
Expanded(
child: Text(
_downloading
? '下载中'
: _downloadPercent >= 100
? '下载完成'
: '已暂停,可断点续传',
),
),
Text(
'$_downloadPercent%',
key: const ValueKey<String>('file_download_progress_text'),
style: const TextStyle(fontWeight: FontWeight.w700),
),
],
),
const SizedBox(height: 6),
LinearProgressIndicator(
key: const ValueKey<String>('file_download_progress'),
value: _downloadProgress ?? 0,
minHeight: 8,
borderRadius: BorderRadius.circular(8),
),
const SizedBox(height: 6),
Text(
'${_formatBytes(_downloadReceivedBytes)} / '
'${_formatBytes(_downloadTotalBytes)}',
textAlign: TextAlign.right,
style: Theme.of(context).textTheme.bodySmall,
),
],
const SizedBox(height: 12),
Wrap(
spacing: 8,
runSpacing: 8,
children: <Widget>[
_primaryAction(
'下载',
Icons.download,
() => _download(resume: false),
key: const ValueKey<String>('download_file_button'),
enabled: _connected && _selectedFileName != null,
),
FilledButton.tonalIcon(
onPressed: _downloading ? _cancelDownload : null,
icon: const Icon(Icons.cancel_outlined),
label: const Text('取消'),
),
_action(
'断点续传',
() => _download(resume: true),
icon: Icons.play_arrow,
enabled: _connected && _selectedFileName != null,
),
],
),
if (_downloadPath.isNotEmpty) ...<Widget>[
const SizedBox(height: 12),
SelectableText('本地路径:$_downloadPath'),
],
],
),
),
Card(
color: Theme.of(context).colorScheme.errorContainer,
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: <Widget>[
Text(
'安全删除',
style: Theme.of(
context,
).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w700),
),
const SizedBox(height: 6),
Text(
_testRecordingFileName == null
? '本轮尚未识别到新建录音,因此删除被禁用。'
: '仅允许删除:$_testRecordingFileName',
),
const SizedBox(height: 12),
Align(
alignment: Alignment.centerLeft,
child: FilledButton.icon(
onPressed:
_connected &&
!_busy &&
_selectedFileName == _testRecordingFileName
? _deleteTestFile
: null,
icon: const Icon(Icons.delete_outline),
label: const Text('删除本轮测试录音'),
),
),
],
),
),
),
const SizedBox(height: 12),
],
);
Widget _fileChoice(SeilonFileEntry file) {
final selected = file.fileName == _selectedFileName;
return Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Material(
color: selected
? Theme.of(context).colorScheme.secondaryContainer
: Theme.of(context).colorScheme.surfaceContainer,
borderRadius: BorderRadius.circular(12),
child: ListTile(
onTap: _canUseSession
? () => setState(() => _selectedFileName = file.fileName)
: null,
leading: Icon(selected ? Icons.check_circle : Icons.audio_file),
title: Text(file.fileName),
subtitle: Text(_formatBytes(file.fileSizeBytes)),
trailing: selected ? const Text('已选择') : null,
),
),
);
}
Widget _logsPage() => _pageShell(
key: const ValueKey<String>('event_section'),
children: <Widget>[
_pageHeading('事件与日志', '查看 SDK 实时日志和 OPUS 数据是否持续到达。'),
Row(
children: <Widget>[
Expanded(child: _smallMetric('OPUS 帧', '$_audioFrames')),
const SizedBox(width: 8),
Expanded(child: _smallMetric('总字节', _formatBytes(_audioBytes))),
const SizedBox(width: 8),
Expanded(child: _smallMetric('最后帧', '$_lastAudioFrameBytes B')),
],
),
const SizedBox(height: 12),
Wrap(
spacing: 8,
runSpacing: 8,
children: <Widget>[
_action(
'清空原生日志',
() => _run('清空日志', _plugin.clearLogs),
icon: Icons.cleaning_services_outlined,
enabled: _initialized,
),
TextButton.icon(
onPressed: _logs.isEmpty ? null : () => setState(_logs.clear),
icon: const Icon(Icons.delete_sweep_outlined),
label: const Text('清空界面日志'),
),
],
),
const SizedBox(height: 8),
if (_logs.isEmpty)
_emptyState(Icons.terminal, '暂无事件日志', '扫描、连接或控制设备后,事件会显示在这里')
else
Card(
child: Column(
key: const ValueKey<String>('event_log'),
children: _logs.reversed
.take(50)
.map(
(log) => ListTile(
dense: true,
leading: Icon(
log.level.name == 'error'
? Icons.error_outline
: Icons.chevron_right,
color: log.level.name == 'error'
? Theme.of(context).colorScheme.error
: null,
),
title: Text('${log.level.name} · ${log.source}'),
subtitle: SelectableText(log.message),
),
)
.toList(growable: false),
),
),
const SizedBox(height: 12),
],
);
Widget _pageShell({required Key key, required List<Widget> children}) =>
Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 760),
child: ListView(
key: key,
padding: const EdgeInsets.fromLTRB(12, 16, 12, 24),
children: children,
),
),
);
Widget _pageHeading(String title, String subtitle) => Padding(
padding: const EdgeInsets.fromLTRB(4, 0, 4, 12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text(
title,
style: Theme.of(
context,
).textTheme.headlineSmall?.copyWith(fontWeight: FontWeight.w800),
),
const SizedBox(height: 4),
Text(subtitle, style: Theme.of(context).textTheme.bodyMedium),
],
),
);
Widget _sectionCard(IconData icon, String title, Widget child) => Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: <Widget>[
Row(
children: <Widget>[
Icon(icon, color: Theme.of(context).colorScheme.primary),
const SizedBox(width: 10),
Text(
title,
style: Theme.of(
context,
).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w700),
),
],
),
const SizedBox(height: 14),
child,
],
),
),
);
Widget _connectionNotice() {
if (_connected) return const SizedBox.shrink();
return Card(
color: Theme.of(context).colorScheme.tertiaryContainer,
child: ListTile(
leading: const Icon(Icons.link_off),
title: const Text('尚未连接设备'),
subtitle: const Text('请先在“流程”页扫描并连接。'),
trailing: TextButton(
onPressed: () => setState(() => _selectedTab = 0),
child: const Text('去连接'),
),
),
);
}
Widget _emptyState(IconData icon, String title, String subtitle) => Container(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 24),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceContainer,
borderRadius: BorderRadius.circular(14),
),
child: Column(
children: <Widget>[
Icon(icon, size: 32, color: Theme.of(context).colorScheme.outline),
const SizedBox(height: 8),
Text(title, style: Theme.of(context).textTheme.titleSmall),
const SizedBox(height: 4),
Text(
subtitle,
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodySmall,
),
],
),
);
Widget _metric(String label, String value) => Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceContainer,
borderRadius: BorderRadius.circular(12),
),
child: Text('$label $value'),
);
Widget _smallMetric(String label, String value) => Container(
constraints: const BoxConstraints(minHeight: 68),
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceContainer,
borderRadius: BorderRadius.circular(12),
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
Text(
value,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(
context,
).textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w700),
),
const SizedBox(height: 2),
Text(label, style: Theme.of(context).textTheme.labelSmall),
],
),
);
Widget _controlGroup(String title, String status, List<Widget> actions) =>
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text(title, style: Theme.of(context).textTheme.titleSmall),
Text(status, style: Theme.of(context).textTheme.bodySmall),
const SizedBox(height: 10),
Wrap(spacing: 8, runSpacing: 8, children: actions),
],
);
Widget _bindAction(int step, String label, Future<void> Function() action) =>
FilledButton.tonalIcon(
onPressed: _connected && !_busy && (step == 1 || _bindStep >= step - 1)
? action
: null,
icon: Icon(_bindStep >= step ? Icons.check : Icons.arrow_forward),
label: Text(label),
);
Widget _action(
String label,
Future<void> Function() action, {
Key? key,
bool enabled = true,
IconData icon = Icons.arrow_forward,
}) => FilledButton.tonalIcon(
key: key,
onPressed: enabled && _canOperate ? action : null,
icon: Icon(icon),
label: Text(label),
);
Widget _primaryAction(
String label,
IconData icon,
Future<void> Function() action, {
Key? key,
bool enabled = true,
}) => FilledButton.icon(
key: key,
onPressed: enabled && _canOperate ? action : null,
icon: Icon(icon),
label: Text(label),
);
Widget _sessionAction(
String label,
Future<Object?> Function(SeilonRecorderSession) action,
) => _action(
label,
() => _run(label, () async {
await action(_requireSession());
}),
enabled: _connected,
);
Widget _numberField(TextEditingController controller, String label) =>
TextField(
controller: controller,
keyboardType: TextInputType.number,
decoration: InputDecoration(labelText: label),
);
String _formatBytes(int value) {
if (value >= 1024 * 1024) {
return '${(value / (1024 * 1024)).toStringAsFixed(1)} MB';
}
if (value >= 1024) return '${(value / 1024).toStringAsFixed(1)} KB';
return '$value B';
}
String _deviceText(SeilonDevice device) =>
'名称: ${device.name}\n'
'ID: ${device.id}\n'
'MAC: ${device.mac}\n'
'型号/固件/SN: ${device.model} / ${device.firmwareVersion} / ${device.serialNumber}\n'
'电量/充电: ${device.batteryPercent}% / ${device.charging}\n'
'容量: ${device.storageUsedMb}/${device.storageTotalMb} MB,提醒 ${device.capacityAlertValue}\n'
'录音: ${device.recording} · ${device.recordingMode} · ${device.recordingStatus}\n'
'录音文件/格式: ${device.recordingFileName} / ${device.recordFormat}\n'
'USB/Wi-Fi: ${device.usbModeEnabled} / ${device.wifiEnabled}\n'
'耳机主 MAC: ${device.headsetPrimaryMac}\n'
'系统时长/屏幕亮度: ${device.systemDurationSeconds}s / ${device.screenBrightness}';
}