seilon_voice_plugin 0.1.2 copy "seilon_voice_plugin: ^0.1.2" to clipboard
seilon_voice_plugin: ^0.1.2 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.0',
                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}';
}
0
likes
0
points
266
downloads

Publisher

unverified uploader

Weekly Downloads

Flutter bridge for the Seilon BLE recorder SDK on Android and iOS.

Homepage

Topics

#bluetooth #ble #recorder #audio #seilon

License

unknown (license)

Dependencies

flutter, plugin_platform_interface

More

Packages that depend on seilon_voice_plugin

Packages that implement seilon_voice_plugin