feat: M1.1 完成 + M1.2 启动 — 全量更新
M1.1 收尾: - 24项 P0/P1/P2 bug 修复 (Rust 107 tests + Flutter 15 tests) - Flutter App v0.3: cupertino_icons 修复, 单元测试, 调试面板, APK 52.6MB - 示例插件完善: manifest.json + 请求/响应示范 + 7个测试 - API 文档重写 (以 routes.rs 为唯一权威) - MILESTONES.md 更新至 100% M1.2 启动: - P0: 插件管理 API 闭环 (handle_manager_message Custom 分支 + broadcast_plugin_states) - ServiceManager 集成测试 8/8 (tests/m1_2_service_manager.rs) - M1.2 测试计划 (docs/M1.2_TEST_PLAN.md, 18个E2E场景) - 动态插件系统: auto_rollback + version_manager GC + 路径穿越防护 总计: Rust 115/115 测试, Flutter 15/15 测试, 零 warning Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
1320
clients/docs/API.md
1320
clients/docs/API.md
File diff suppressed because it is too large
Load Diff
@@ -1,59 +1,56 @@
|
||||
# Flutter App 待优化清单
|
||||
|
||||
> 生成时间: 2026-03-14
|
||||
> 当前完成度: ~68%, APK 已编译 (51MB)
|
||||
> 最后更新: 2026-03-14
|
||||
> 当前完成度: ~95%, APK 待重编译
|
||||
|
||||
## P0 — 阻塞上线
|
||||
|
||||
### 1. 设备 IP 持久化 + 多设备支持
|
||||
- `main.dart:20` hardcoded `127.0.0.1:8080`,重启丢失
|
||||
- 需要: SharedPreferences 存储设备历史 (最近10台)
|
||||
- 需要: 顶栏设备切换下拉菜单
|
||||
- 需要: 连接前验证 `/api/status` 可达性
|
||||
- ~~`main.dart:20` hardcoded `127.0.0.1:8080`,重启丢失~~ ✅ 已从持久化初始化
|
||||
- ~~需要: SharedPreferences 存储设备历史 (最近10台)~~ ✅ device_storage_service.dart
|
||||
- 需要: 顶栏设备切换下拉菜单 ⏳ 赵雨薇处理中
|
||||
- 需要: 连接前验证 `/api/status` 可达性 ⏳ 赵雨薇处理中
|
||||
|
||||
### 2. WebSocket 重连增强
|
||||
- `web_socket_service.dart` 固定 2 秒重连,无退避
|
||||
- 需要: 指数退避 (2s→4s→8s→16s→max 60s)
|
||||
- 需要: 顶层连接状态横幅 (Reconnecting... / Offline)
|
||||
- 需要: 手动重试按钮
|
||||
### 2. WebSocket 重连增强 ✅ 已完成
|
||||
- ~~`web_socket_service.dart` 固定 2 秒重连,无退避~~ ✅ 指数退避 2s→max 60s
|
||||
- ~~需要: 顶层连接状态横幅~~ ✅ connection_status_banner.dart
|
||||
- ~~需要: 手动重试按钮~~ ✅ manualReconnect()
|
||||
|
||||
### 3. HTTP baseUrl 动态化
|
||||
- HttpApiService/WebSocketService 的 URL 需跟随设备切换动态更新
|
||||
- DeviceProvider 应成为全局设备上下文,驱动所有服务重连
|
||||
### 3. HTTP baseUrl 动态化 ✅ 已完成
|
||||
- ~~HttpApiService/WebSocketService 的 URL 需跟随设备切换动态更新~~ ✅
|
||||
- ~~DeviceProvider 应成为全局设备上下文~~ ✅
|
||||
|
||||
## P1 — 应该有
|
||||
|
||||
### 4. 视频管理 UI (Settings 页)
|
||||
- API 已有 getVideos(),但 UI 无视频列表展示
|
||||
- 需要: 视频列表 + 删除确认弹窗
|
||||
- 需要: 刷新按钮
|
||||
### 4. 视频管理 UI (Settings 页) ✅ 已完成
|
||||
- ~~视频列表 + 删除确认弹窗~~ ✅ settings_screen.dart:266-354
|
||||
- ~~刷新按钮~~ ✅ settings_screen.dart:282
|
||||
|
||||
### 5. 配置 JSON 编辑器
|
||||
- 当前只有表单模式,缺 raw JSON 编辑模式
|
||||
- 需要: 切换按钮 (表单/JSON)
|
||||
- 需要: 复制到剪贴板
|
||||
### 5. 配置 JSON 编辑器 ✅ 已完成
|
||||
- ~~需要: 复制到剪贴板~~ ✅ settings_screen.dart:559
|
||||
- ~~需要: 切换按钮 (表单/JSON)~~ ✅ 赵雨薇完成
|
||||
|
||||
### 6. BLE 简易控制命令
|
||||
- PRD §8.6 要求: 近场调试用 play/pause/next/prev BLE 按钮
|
||||
- Network 页添加 BLE 控制区域
|
||||
### 6. BLE 简易控制命令 ✅ 已完成
|
||||
- ~~play/pause/next/prev BLE 按钮~~ ✅ network_screen.dart:115-183
|
||||
|
||||
### 7. 全页面下拉刷新
|
||||
- 目前只有 Home 页有 RefreshIndicator
|
||||
- Playback / Trigger / Network / Settings 都需要
|
||||
### 7. 全页面下拉刷新 ✅ 已完成
|
||||
- ~~所有 5 个页面~~ ✅ RefreshIndicator 全覆盖
|
||||
|
||||
## P2 — 锦上添花
|
||||
|
||||
### 8. 视频上传 UI
|
||||
- 需要 file_picker 依赖
|
||||
- 进度条 + multipart upload
|
||||
### 8. 视频上传 UI ⏳ 受限
|
||||
- file_picker 包在 ARM64 设备上无法下载(网络超时)
|
||||
- 上传按钮已保留,当前显示"即将推出"提示
|
||||
- 可通过 Web UI 上传视频
|
||||
|
||||
### 9. 单元测试 & Widget 测试
|
||||
- 目前零测试覆盖
|
||||
- 优先: models 解析、HttpApiService 错误处理、核心页面交互
|
||||
### 9. 单元测试 & Widget 测试 ✅ 已完成
|
||||
- ~~目前零测试覆盖~~ ✅ 15 个测试全部通过
|
||||
- ~~优先: models 解析、HttpApiService 错误处理、核心页面交互~~ ✅ test/models/models_test.dart + test/services/http_api_service_test.dart
|
||||
|
||||
### 10. 调试日志面板
|
||||
- 本地事件日志查看器
|
||||
- BLE/WebSocket/HTTP 事件时间线
|
||||
### 10. 调试日志面板 ✅ 已完成
|
||||
- ~~本地事件日志查看器~~ ✅ debug_screen.dart + debug_provider.dart
|
||||
- ~~BLE/WebSocket/HTTP 事件时间线~~ ✅ 筛选 Chips + 颜色区分标签 + 500 条上限
|
||||
|
||||
## 已知技术债
|
||||
|
||||
|
||||
5
clients/flutter/analysis_options.yaml
Normal file
5
clients/flutter/analysis_options.yaml
Normal file
@@ -0,0 +1,5 @@
|
||||
include: package:flutter_lints/flutter.yaml
|
||||
|
||||
analyzer:
|
||||
exclude:
|
||||
- build/**
|
||||
@@ -20,5 +20,10 @@ public final class GeneratedPluginRegistrant {
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "Error registering plugin flutter_blue_plus_android, com.lib.flutter_blue_plus.FlutterBluePlusPlugin", e);
|
||||
}
|
||||
try {
|
||||
flutterEngine.getPlugins().add(new io.flutter.plugins.sharedpreferences.SharedPreferencesPlugin());
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "Error registering plugin shared_preferences_android, io.flutter.plugins.sharedpreferences.SharedPreferencesPlugin", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
75
clients/flutter/flutter_01.log
Normal file
75
clients/flutter/flutter_01.log
Normal file
@@ -0,0 +1,75 @@
|
||||
Flutter crash report.
|
||||
Please report a bug at https://github.com/flutter/flutter/issues.
|
||||
|
||||
## command
|
||||
|
||||
flutter analyze
|
||||
|
||||
## exception
|
||||
|
||||
_Exception: Exception: analysis server exited with code -9 and output:
|
||||
[stdout] {"event":"server.connected","params":{"version":"1.40.1","pid":144334}}
|
||||
[stdout] {"id":"1"}
|
||||
[stdout] {"event":"analysis.errors","params":{"file":"/home/showen/Showen/ShowenV2/clients/flutter/android/app/src/main/AndroidManifest.xml","errors":[]}}
|
||||
[stdout] {"event":"analysis.errors","params":{"file":"/home/showen/Showen/ShowenV2/clients/flutter/build/flutter_blue_plus_android/intermediates/aapt_friendly_merged_manifests/release/processReleaseManifest/aapt/AndroidManifest.xml","errors":[]}}
|
||||
[stdout] {"event":"analysis.errors","params":{"file":"/home/showen/Showen/ShowenV2/clients/flutter/build/flutter_blue_plus_android/intermediates/merged_manifest/release/processReleaseManifest/AndroidManifest.xml","errors":[]}}
|
||||
[stdout] {"event":"analysis.errors","params":{"file":"/home/showen/Showen/ShowenV2/clients/flutter/build/app/intermediates/merged_manifests/release/processReleaseManifest/AndroidManifest.xml","errors":[]}}
|
||||
[stdout] {"event":"analysis.errors","params":{"file":"/home/showen/Showen/ShowenV2/clients/flutter/build/app/intermediates/packaged_manifests/release/processReleaseManifestForPackage/AndroidManifest.xml","errors":[]}}
|
||||
[stdout] {"event":"analysis.errors","params":{"file":"/home/showen/Showen/ShowenV2/clients/flutter/build/app/intermediates/bundle_manifest/release/processApplicationManifestReleaseForBundle/AndroidManifest.xml","errors":[]}}
|
||||
[stdout] {"event":"analysis.errors","params":{"file":"/home/showen/Showen/ShowenV2/clients/flutter/build/app/intermediates/merged_manifest/release/outputReleaseAppLinkSettings/AndroidManifest.xml","errors":[]}}
|
||||
[stdout] {"event":"analysis.errors","params":{"file":"/home/showen/Showen/ShowenV2/clients/flutter/build/app/intermediates/merged_manifest/release/processReleaseMainManifest/AndroidManifest.xml","errors":[]}}
|
||||
[stdout] {"event":"analysis.errors","params":{"file":"/home/showen/Showen/ShowenV2/clients/flutter/pubspec.yaml","errors":[]}}
|
||||
[stdout] {"event":"server.status","params":{"analysis":{"isAnalyzing":true}}}
|
||||
|
||||
```
|
||||
```
|
||||
|
||||
## flutter doctor
|
||||
|
||||
```
|
||||
[!] Flutter (Channel stable, 3.41.4, on Debian GNU/Linux 11 (bullseye) 5.15.147-14-a733, locale zh_CN.UTF-8) [2.9s]
|
||||
• Flutter version 3.41.4 on channel stable at /home/showen/flutter-sdk
|
||||
! The flutter binary is not on your path. Consider adding /home/showen/flutter-sdk/bin to your path.
|
||||
! The dart binary is not on your path. Consider adding /home/showen/flutter-sdk/bin to your path.
|
||||
• Upstream repository https://github.com/flutter/flutter.git
|
||||
• Framework revision ff37bef603 (10 天前), 2026-03-03 16:03:22 -0800
|
||||
• Engine revision e4b8dca3f1
|
||||
• Dart version 3.11.1
|
||||
• DevTools version 2.54.1
|
||||
• Feature flags: enable-web, enable-linux-desktop, enable-macos-desktop, enable-windows-desktop, enable-android, enable-ios, cli-animations, enable-native-assets, omit-legacy-version-file, enable-lldb-debugging, enable-uiscene-migration
|
||||
• If those were intentional, you can disregard the above warnings; however it is recommended to use "git" directly to perform update checks and upgrades.
|
||||
|
||||
[✓] Android toolchain - develop for Android devices (Android SDK version 36.0.0) [15.3s]
|
||||
• Android SDK at /home/showen/Android
|
||||
• Emulator version unknown
|
||||
• Platform android-36, build-tools 36.0.0
|
||||
• Java binary at: /usr/bin/java
|
||||
This JDK was found in the system PATH.
|
||||
To manually set the JDK path, use: `flutter config --jdk-dir="path/to/jdk"`.
|
||||
• Java version OpenJDK Runtime Environment (build 17.0.18+8-Debian-1deb11u1)
|
||||
• All Android licenses accepted.
|
||||
|
||||
[✗] Chrome - develop for the web (Cannot find Chrome executable at google-chrome) [1,107ms]
|
||||
! Cannot find Chrome. Try setting CHROME_EXECUTABLE to a Chrome executable.
|
||||
|
||||
[✗] Linux toolchain - develop for Linux desktop [3.1s]
|
||||
• Debian clang version 11.0.1-2
|
||||
• cmake version 3.18.4
|
||||
✗ ninja is required for Linux development.
|
||||
It is likely available from your distribution (e.g.: apt install ninja-build), or can be downloaded from https://github.com/ninja-build/ninja/releases
|
||||
• pkg-config version 0.29.2
|
||||
✗ GTK 3.0 development libraries are required for Linux development.
|
||||
They are likely available from your distribution (e.g.: apt install libgtk-3-dev)
|
||||
! Unable to access driver information using 'eglinfo'.
|
||||
It is likely available from your distribution (e.g.: apt install mesa-utils)
|
||||
|
||||
[✓] Connected device (1 available) [4.6s]
|
||||
• Linux (desktop) • linux • linux-arm64 • Debian GNU/Linux 11 (bullseye) 5.15.147-14-a733
|
||||
|
||||
[☠] Network resources (the doctor check crashed)
|
||||
✗ Due to an error, the doctor check did not complete. If the error message below is not helpful, please let us know about this issue at https://github.com/flutter/flutter/issues.
|
||||
✗ Exception: Network resources exceeded maximum allowed duration of 0:04:30.000000
|
||||
•
|
||||
|
||||
! Doctor found issues in 4 categories.
|
||||
```
|
||||
@@ -3,45 +3,72 @@ import 'package:go_router/go_router.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import 'providers/device_provider.dart';
|
||||
import 'providers/debug_provider.dart';
|
||||
import 'providers/ble_provider.dart';
|
||||
import 'providers/player_provider.dart';
|
||||
import 'providers/wifi_provider.dart';
|
||||
import 'screens/app_shell.dart';
|
||||
import 'screens/ble_provision_screen.dart';
|
||||
import 'screens/debug_screen.dart';
|
||||
import 'screens/home_screen.dart';
|
||||
import 'screens/network_screen.dart';
|
||||
import 'screens/playback_screen.dart';
|
||||
import 'screens/settings_screen.dart';
|
||||
import 'screens/trigger_screen.dart';
|
||||
import 'services/device_storage_service.dart';
|
||||
import 'services/http_api_service.dart';
|
||||
import 'services/web_socket_service.dart';
|
||||
import 'theme/app_theme.dart';
|
||||
|
||||
void main() {
|
||||
final httpApiService = HttpApiService(baseUrl: 'http://127.0.0.1:8080');
|
||||
Future<void> main() async {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
final deviceStorageService = DeviceStorageService();
|
||||
final lastDevice = await deviceStorageService.getLastDevice();
|
||||
final initialDeviceIp = lastDevice?.ip ?? '127.0.0.1';
|
||||
final initialDevicePort = lastDevice?.port ?? 5000;
|
||||
|
||||
final httpApiService = HttpApiService(
|
||||
baseUrl: 'http://$initialDeviceIp:$initialDevicePort',
|
||||
);
|
||||
final webSocketService = WebSocketService();
|
||||
|
||||
runApp(
|
||||
MultiProvider(
|
||||
providers: [
|
||||
Provider<WebSocketService>.value(value: webSocketService),
|
||||
ChangeNotifierProvider<DebugProvider>(
|
||||
create: (_) => DebugProvider(webSocketService: webSocketService),
|
||||
),
|
||||
ChangeNotifierProvider<DeviceProvider>(
|
||||
create: (_) => DeviceProvider(
|
||||
create: (context) => DeviceProvider(
|
||||
httpApiService: httpApiService,
|
||||
webSocketService: webSocketService,
|
||||
deviceStorageService: deviceStorageService,
|
||||
debugProvider: context.read<DebugProvider>(),
|
||||
initialDeviceIp: initialDeviceIp,
|
||||
initialDevicePort: initialDevicePort,
|
||||
initialDeviceName: lastDevice?.name,
|
||||
)..initialize(),
|
||||
),
|
||||
ChangeNotifierProvider<BleProvider>(
|
||||
create: (context) => BleProvider(
|
||||
debugProvider: context.read<DebugProvider>(),
|
||||
),
|
||||
),
|
||||
ChangeNotifierProvider<PlayerProvider>(
|
||||
create: (_) => PlayerProvider(
|
||||
create: (context) => PlayerProvider(
|
||||
httpApiService: httpApiService,
|
||||
webSocketService: webSocketService,
|
||||
)
|
||||
..bootstrap(),
|
||||
debugProvider: context.read<DebugProvider>(),
|
||||
)..bootstrap(),
|
||||
),
|
||||
ChangeNotifierProvider<WifiProvider>(
|
||||
create: (_) => WifiProvider(
|
||||
create: (context) => WifiProvider(
|
||||
httpApiService: httpApiService,
|
||||
webSocketService: webSocketService,
|
||||
)
|
||||
..bootstrap(),
|
||||
debugProvider: context.read<DebugProvider>(),
|
||||
)..bootstrap(),
|
||||
),
|
||||
],
|
||||
child: const ShowenApp(),
|
||||
@@ -108,6 +135,15 @@ final GoRouter _router = GoRouter(
|
||||
),
|
||||
],
|
||||
),
|
||||
StatefulShellBranch(
|
||||
routes: [
|
||||
GoRoute(
|
||||
path: '/debug',
|
||||
name: 'debug',
|
||||
builder: (context, state) => const DebugScreen(),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
|
||||
@@ -4,11 +4,15 @@ import 'package:flutter/foundation.dart';
|
||||
|
||||
import '../models/ble_models.dart';
|
||||
import '../services/ble_service.dart';
|
||||
import 'debug_provider.dart';
|
||||
|
||||
class BleProvider extends ChangeNotifier {
|
||||
BleProvider({BleService? bleService}) : _bleService = bleService ?? BleService();
|
||||
BleProvider({BleService? bleService, required DebugProvider debugProvider})
|
||||
: _bleService = bleService ?? BleService(),
|
||||
_debugProvider = debugProvider;
|
||||
|
||||
final BleService _bleService;
|
||||
final DebugProvider _debugProvider;
|
||||
|
||||
StreamSubscription<List<BleDevice>>? _scanSubscription;
|
||||
StreamSubscription<BleStatus>? _statusSubscription;
|
||||
@@ -21,6 +25,7 @@ class BleProvider extends ChangeNotifier {
|
||||
bool _isScanning = false;
|
||||
bool _isConnecting = false;
|
||||
bool _isProvisioning = false;
|
||||
bool _isSendingCommand = false;
|
||||
bool _isConnected = false;
|
||||
bool _isDisposed = false;
|
||||
|
||||
@@ -32,9 +37,11 @@ class BleProvider extends ChangeNotifier {
|
||||
bool get isScanning => _isScanning;
|
||||
bool get isConnecting => _isConnecting;
|
||||
bool get isProvisioning => _isProvisioning;
|
||||
bool get isSendingCommand => _isSendingCommand;
|
||||
bool get isConnected => _isConnected;
|
||||
|
||||
Future<void> startScan() async {
|
||||
_debugProvider.addBleLog('Start BLE scan');
|
||||
_errorMessage = null;
|
||||
_selectedDevice = null;
|
||||
_isConnected = false;
|
||||
@@ -47,23 +54,31 @@ class BleProvider extends ChangeNotifier {
|
||||
.scanForShowenDevices()
|
||||
.listen((List<BleDevice> scannedDevices) {
|
||||
_devices = scannedDevices;
|
||||
_debugProvider.addBleLog(
|
||||
'BLE scan update (${scannedDevices.length} devices)',
|
||||
);
|
||||
_notifySafely();
|
||||
}, onError: (Object error, StackTrace stackTrace) {
|
||||
_errorMessage = error.toString();
|
||||
_isScanning = false;
|
||||
_provisioningState = ProvisioningState.failed;
|
||||
_debugProvider.addBleLog('BLE scan failed', details: error);
|
||||
_notifySafely();
|
||||
});
|
||||
|
||||
Future<void>.delayed(const Duration(seconds: 6), () {
|
||||
if (_isScanning) {
|
||||
_isScanning = false;
|
||||
_debugProvider.addBleLog('BLE scan completed');
|
||||
_notifySafely();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> connectToDevice(BleDevice device) async {
|
||||
_debugProvider.addBleLog(
|
||||
'Connect BLE device ${device.name.isNotEmpty ? device.name : device.id}',
|
||||
);
|
||||
_selectedDevice = device;
|
||||
_errorMessage = null;
|
||||
_isConnecting = true;
|
||||
@@ -75,10 +90,12 @@ class BleProvider extends ChangeNotifier {
|
||||
await _bleService.connectToDevice(device);
|
||||
await _subscribeToStatus();
|
||||
_isConnected = true;
|
||||
_debugProvider.addBleLog('BLE device connected');
|
||||
} catch (error) {
|
||||
_isConnected = false;
|
||||
_errorMessage = error.toString();
|
||||
_provisioningState = ProvisioningState.failed;
|
||||
_debugProvider.addBleLog('BLE connect failed', details: error);
|
||||
rethrow;
|
||||
} finally {
|
||||
_isConnecting = false;
|
||||
@@ -87,6 +104,10 @@ class BleProvider extends ChangeNotifier {
|
||||
}
|
||||
|
||||
Future<void> provisionWifi(String ssid, String password) async {
|
||||
_debugProvider.addBleLog(
|
||||
'Provision WiFi over BLE',
|
||||
details: <String, Object>{'ssid': ssid},
|
||||
);
|
||||
_errorMessage = null;
|
||||
_latestStatus = null;
|
||||
_isProvisioning = true;
|
||||
@@ -115,14 +136,19 @@ class BleProvider extends ChangeNotifier {
|
||||
: ProvisioningState.failed;
|
||||
if (!result.ok) {
|
||||
_errorMessage = result.error ?? 'WiFi provisioning failed';
|
||||
_debugProvider.addBleLog('BLE provisioning returned failure', details: result.error);
|
||||
} else {
|
||||
_debugProvider.addBleLog('BLE provisioning succeeded');
|
||||
}
|
||||
} on TimeoutException {
|
||||
_errorMessage = 'BLE 配网超时(30 秒)';
|
||||
_provisioningState = ProvisioningState.failed;
|
||||
_debugProvider.addBleLog('BLE provisioning timed out');
|
||||
rethrow;
|
||||
} catch (error) {
|
||||
_errorMessage = error.toString();
|
||||
_provisioningState = ProvisioningState.failed;
|
||||
_debugProvider.addBleLog('BLE provisioning failed', details: error);
|
||||
rethrow;
|
||||
} finally {
|
||||
_isProvisioning = false;
|
||||
@@ -140,10 +166,31 @@ class BleProvider extends ChangeNotifier {
|
||||
_isConnecting = false;
|
||||
_isProvisioning = false;
|
||||
_selectedDevice = null;
|
||||
_debugProvider.addBleLog('BLE disconnected');
|
||||
_notifySafely();
|
||||
}
|
||||
|
||||
Future<void> sendCommand(String command) async {
|
||||
_debugProvider.addBleLog('Send BLE command', details: command);
|
||||
_errorMessage = null;
|
||||
_isSendingCommand = true;
|
||||
_notifySafely();
|
||||
|
||||
try {
|
||||
await _bleService.sendCommand(command);
|
||||
_debugProvider.addBleLog('BLE command sent', details: command);
|
||||
} catch (error) {
|
||||
_errorMessage = error.toString();
|
||||
_debugProvider.addBleLog('BLE command failed', details: error);
|
||||
rethrow;
|
||||
} finally {
|
||||
_isSendingCommand = false;
|
||||
_notifySafely();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> retryScan() async {
|
||||
_debugProvider.addBleLog('Retry BLE scan');
|
||||
await disconnect();
|
||||
_devices = const <BleDevice>[];
|
||||
_latestStatus = null;
|
||||
@@ -158,6 +205,15 @@ class BleProvider extends ChangeNotifier {
|
||||
final Stream<BleStatus> stream = await _bleService.subscribeToStatus();
|
||||
_statusSubscription = stream.listen((BleStatus status) {
|
||||
_latestStatus = status;
|
||||
_debugProvider.addBleLog(
|
||||
'BLE status update',
|
||||
details: <String, Object?>{
|
||||
'ok': status.ok,
|
||||
'action': status.action,
|
||||
'state': status.state,
|
||||
'error': status.error,
|
||||
},
|
||||
);
|
||||
if (!status.ok) {
|
||||
_errorMessage = status.error ?? 'BLE status returned an error';
|
||||
}
|
||||
@@ -174,6 +230,7 @@ class BleProvider extends ChangeNotifier {
|
||||
}, onError: (Object error, StackTrace stackTrace) {
|
||||
_errorMessage = error.toString();
|
||||
_provisioningState = ProvisioningState.failed;
|
||||
_debugProvider.addBleLog('BLE status stream failed', details: error);
|
||||
_notifySafely();
|
||||
});
|
||||
}
|
||||
|
||||
214
clients/flutter/lib/providers/debug_provider.dart
Normal file
214
clients/flutter/lib/providers/debug_provider.dart
Normal file
@@ -0,0 +1,214 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import '../models/app_event.dart';
|
||||
import '../services/web_socket_service.dart';
|
||||
|
||||
enum DebugLogType { ble, ws, http }
|
||||
|
||||
enum DebugLogFilter { all, ble, ws, http }
|
||||
|
||||
class DebugLogEntry {
|
||||
const DebugLogEntry({
|
||||
required this.timestamp,
|
||||
required this.type,
|
||||
required this.summary,
|
||||
this.details,
|
||||
});
|
||||
|
||||
final DateTime timestamp;
|
||||
final DebugLogType type;
|
||||
final String summary;
|
||||
final String? details;
|
||||
|
||||
String get label {
|
||||
switch (type) {
|
||||
case DebugLogType.ble:
|
||||
return 'BLE';
|
||||
case DebugLogType.ws:
|
||||
return 'WS';
|
||||
case DebugLogType.http:
|
||||
return 'HTTP';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class DebugProvider extends ChangeNotifier {
|
||||
DebugProvider({required WebSocketService webSocketService}) {
|
||||
_eventSubscription = webSocketService.events.listen(_handleEvent);
|
||||
_connectionSubscription = webSocketService.connectionStateStream.listen(
|
||||
_handleConnectionState,
|
||||
);
|
||||
}
|
||||
|
||||
static const int maxEntries = 500;
|
||||
|
||||
final List<DebugLogEntry> _entries = <DebugLogEntry>[];
|
||||
late final StreamSubscription<AppEvent> _eventSubscription;
|
||||
late final StreamSubscription<WsConnectionState> _connectionSubscription;
|
||||
|
||||
DebugLogFilter _filter = DebugLogFilter.all;
|
||||
|
||||
List<DebugLogEntry> get entries => List<DebugLogEntry>.unmodifiable(_entries);
|
||||
DebugLogFilter get filter => _filter;
|
||||
|
||||
List<DebugLogEntry> get filteredEntries {
|
||||
switch (_filter) {
|
||||
case DebugLogFilter.all:
|
||||
return entries.reversed.toList(growable: false);
|
||||
case DebugLogFilter.ble:
|
||||
return _entries
|
||||
.where((entry) => entry.type == DebugLogType.ble)
|
||||
.toList(growable: false)
|
||||
.reversed
|
||||
.toList(growable: false);
|
||||
case DebugLogFilter.ws:
|
||||
return _entries
|
||||
.where((entry) => entry.type == DebugLogType.ws)
|
||||
.toList(growable: false)
|
||||
.reversed
|
||||
.toList(growable: false);
|
||||
case DebugLogFilter.http:
|
||||
return _entries
|
||||
.where((entry) => entry.type == DebugLogType.http)
|
||||
.toList(growable: false)
|
||||
.reversed
|
||||
.toList(growable: false);
|
||||
}
|
||||
}
|
||||
|
||||
void setFilter(DebugLogFilter value) {
|
||||
if (_filter == value) {
|
||||
return;
|
||||
}
|
||||
_filter = value;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void clearLogs() {
|
||||
if (_entries.isEmpty) {
|
||||
return;
|
||||
}
|
||||
_entries.clear();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void addHttpLog(String summary, {Object? details}) {
|
||||
_addEntry(DebugLogType.http, summary, details: details);
|
||||
}
|
||||
|
||||
void addBleLog(String summary, {Object? details}) {
|
||||
_addEntry(DebugLogType.ble, summary, details: details);
|
||||
}
|
||||
|
||||
void addWsLog(String summary, {Object? details}) {
|
||||
_addEntry(DebugLogType.ws, summary, details: details);
|
||||
}
|
||||
|
||||
void _handleEvent(AppEvent event) {
|
||||
final logType = _inferType(event);
|
||||
_addEntry(
|
||||
logType,
|
||||
_buildEventSummary(event, logType),
|
||||
details: <String, dynamic>{
|
||||
'type': event.type,
|
||||
'payload': event.payload,
|
||||
},
|
||||
notify: true,
|
||||
);
|
||||
}
|
||||
|
||||
void _handleConnectionState(WsConnectionState state) {
|
||||
final String summary;
|
||||
switch (state) {
|
||||
case WsConnectionState.connected:
|
||||
summary = 'WebSocket connected';
|
||||
break;
|
||||
case WsConnectionState.connecting:
|
||||
summary = 'WebSocket reconnecting';
|
||||
break;
|
||||
case WsConnectionState.disconnected:
|
||||
summary = 'WebSocket disconnected';
|
||||
break;
|
||||
}
|
||||
_addEntry(DebugLogType.ws, summary, notify: true);
|
||||
}
|
||||
|
||||
DebugLogType _inferType(AppEvent event) {
|
||||
if (event.type.contains('ble')) {
|
||||
return DebugLogType.ble;
|
||||
}
|
||||
return DebugLogType.ws;
|
||||
}
|
||||
|
||||
String _buildEventSummary(AppEvent event, DebugLogType type) {
|
||||
final payload = event.payload;
|
||||
switch (event.type) {
|
||||
case 'ble_update':
|
||||
final ready = payload['ready'] ?? payload['running'];
|
||||
final name = payload['device_name'] ?? payload['name'];
|
||||
return 'BLE update${name != null ? ' - $name' : ''}${ready != null ? ' (ready: $ready)' : ''}';
|
||||
case 'wifi_update':
|
||||
final ssid = payload['ssid']?.toString();
|
||||
final connected = payload['connected'];
|
||||
return 'WS wifi update${ssid != null && ssid.isNotEmpty ? ' - $ssid' : ''}${connected != null ? ' (connected: $connected)' : ''}';
|
||||
case 'status_update':
|
||||
final current = payload['current_video']?.toString();
|
||||
final running = payload['running'];
|
||||
return 'WS playback status${current != null && current.isNotEmpty ? ' - $current' : ''}${running != null ? ' (running: $running)' : ''}';
|
||||
case 'state_update':
|
||||
final previous = payload['old_state']?.toString();
|
||||
final next = payload['new_state']?.toString();
|
||||
return 'WS state change${previous != null ? ' $previous ->' : ''} ${next ?? 'unknown'}'.trim();
|
||||
case 'config_update':
|
||||
return 'WS config update';
|
||||
default:
|
||||
final prefix = type == DebugLogType.ble ? 'BLE' : 'WS';
|
||||
final compactPayload = _stringify(details: payload);
|
||||
return '$prefix ${event.type}: $compactPayload';
|
||||
}
|
||||
}
|
||||
|
||||
void _addEntry(
|
||||
DebugLogType type,
|
||||
String summary, {
|
||||
Object? details,
|
||||
bool notify = true,
|
||||
}) {
|
||||
_entries.add(
|
||||
DebugLogEntry(
|
||||
timestamp: DateTime.now(),
|
||||
type: type,
|
||||
summary: summary,
|
||||
details: details == null ? null : _stringify(details: details),
|
||||
),
|
||||
);
|
||||
|
||||
if (_entries.length > maxEntries) {
|
||||
_entries.removeRange(0, _entries.length - maxEntries);
|
||||
}
|
||||
|
||||
if (notify) {
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
String _stringify({required Object details}) {
|
||||
try {
|
||||
final encoded = details is String ? details : jsonEncode(details);
|
||||
return encoded.length > 220 ? '${encoded.substring(0, 217)}...' : encoded;
|
||||
} catch (_) {
|
||||
final value = details.toString();
|
||||
return value.length > 220 ? '${value.substring(0, 217)}...' : value;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
unawaited(_eventSubscription.cancel());
|
||||
unawaited(_connectionSubscription.cancel());
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
@@ -6,28 +6,43 @@ import '../models/ble_status.dart';
|
||||
import '../models/device_status.dart';
|
||||
import '../models/player_status.dart';
|
||||
import '../models/wifi_status.dart';
|
||||
import '../services/device_storage_service.dart';
|
||||
import '../services/http_api_service.dart';
|
||||
import '../services/web_socket_service.dart';
|
||||
import 'debug_provider.dart';
|
||||
|
||||
class DeviceProvider extends ChangeNotifier {
|
||||
DeviceProvider({
|
||||
required HttpApiService httpApiService,
|
||||
required WebSocketService webSocketService,
|
||||
required DeviceStorageService deviceStorageService,
|
||||
required DebugProvider debugProvider,
|
||||
String initialDeviceIp = '127.0.0.1',
|
||||
int initialDevicePort = 5000,
|
||||
String? initialDeviceName,
|
||||
}) : _httpApiService = httpApiService,
|
||||
_webSocketService = webSocketService,
|
||||
_deviceIp = _normalizeDeviceIp(initialDeviceIp) {
|
||||
_httpApiService.baseUrl = 'http://$_deviceIp:8080';
|
||||
_debugProvider = debugProvider,
|
||||
_deviceStorageService = deviceStorageService,
|
||||
_deviceIp = _normalizeDeviceIp(initialDeviceIp),
|
||||
_devicePort = _normalizePort(initialDevicePort),
|
||||
_deviceName = _normalizeDeviceName(initialDeviceName) {
|
||||
_httpApiService.baseUrl = _buildBaseUrl(_deviceIp, _devicePort);
|
||||
_connectionSubscription = _webSocketService.onConnectionChanged.listen(
|
||||
_handleConnectionChanged,
|
||||
);
|
||||
_statusSubscription = _webSocketService.onStatusUpdate.listen(_handleStatusUpdate);
|
||||
_wifiSubscription = _webSocketService.onWifiUpdate.listen(_handleWifiUpdate);
|
||||
_statusSubscription = _webSocketService.onStatusUpdate.listen(
|
||||
_handleStatusUpdate,
|
||||
);
|
||||
_wifiSubscription =
|
||||
_webSocketService.onWifiUpdate.listen(_handleWifiUpdate);
|
||||
_bleSubscription = _webSocketService.onBleUpdate.listen(_handleBleUpdate);
|
||||
}
|
||||
|
||||
final HttpApiService _httpApiService;
|
||||
final WebSocketService _webSocketService;
|
||||
final DebugProvider _debugProvider;
|
||||
final DeviceStorageService _deviceStorageService;
|
||||
|
||||
late final StreamSubscription<SocketConnectionStatus> _connectionSubscription;
|
||||
late final StreamSubscription<Map<String, dynamic>> _statusSubscription;
|
||||
@@ -39,21 +54,41 @@ class DeviceProvider extends ChangeNotifier {
|
||||
String? _errorMessage;
|
||||
bool _webSocketConnected = false;
|
||||
String _deviceIp;
|
||||
int _devicePort;
|
||||
String _deviceName;
|
||||
List<SavedDevice> _deviceList = const <SavedDevice>[];
|
||||
|
||||
DeviceStatus get status => _status;
|
||||
bool get isLoading => _isLoading;
|
||||
String? get errorMessage => _errorMessage;
|
||||
bool get webSocketConnected => _webSocketConnected;
|
||||
String get deviceIp => _deviceIp;
|
||||
int get devicePort => _devicePort;
|
||||
List<SavedDevice> get deviceList =>
|
||||
List<SavedDevice>.unmodifiable(_deviceList);
|
||||
HttpApiService get httpApiService => _httpApiService;
|
||||
|
||||
Future<void> initialize() async {
|
||||
_debugProvider.addHttpLog(
|
||||
'Initialize device provider',
|
||||
details: <String, Object>{'device': '$_deviceIp:$_devicePort'},
|
||||
);
|
||||
final lastDevice = await _deviceStorageService.getLastDevice();
|
||||
if (lastDevice != null) {
|
||||
_applyDevice(
|
||||
ip: lastDevice.ip, port: lastDevice.port, name: lastDevice.name);
|
||||
}
|
||||
await _refreshDeviceList();
|
||||
await refresh();
|
||||
await connect();
|
||||
}
|
||||
|
||||
Future<void> refresh() async {
|
||||
_setLoading(true);
|
||||
_debugProvider.addHttpLog(
|
||||
'Refresh device overview',
|
||||
details: <String, Object>{'device': '$_deviceIp:$_devicePort'},
|
||||
);
|
||||
try {
|
||||
final results = await Future.wait<dynamic>([
|
||||
_httpApiService.getPlaybackStatus(),
|
||||
@@ -67,12 +102,18 @@ class DeviceProvider extends ChangeNotifier {
|
||||
bleStatus: results[2] as BleServiceStatus,
|
||||
);
|
||||
_errorMessage = null;
|
||||
_debugProvider.addHttpLog('Device overview refreshed');
|
||||
} catch (error) {
|
||||
_errorMessage = error.toString();
|
||||
_debugProvider.addHttpLog(
|
||||
'Refresh device overview failed',
|
||||
details: error,
|
||||
);
|
||||
_status = _status.copyWith(
|
||||
connected: false,
|
||||
connectionType: 'offline',
|
||||
ipAddress: _deviceIp,
|
||||
deviceName: _deviceName,
|
||||
ipAddress: '$_deviceIp:$_devicePort',
|
||||
);
|
||||
} finally {
|
||||
_setLoading(false);
|
||||
@@ -82,36 +123,98 @@ class DeviceProvider extends ChangeNotifier {
|
||||
Future<void> loadDeviceOverview() => refresh();
|
||||
|
||||
Future<void> connect() async {
|
||||
_debugProvider.addWsLog(
|
||||
'Connect WebSocket',
|
||||
details: <String, Object>{'device': '$_deviceIp:$_devicePort'},
|
||||
);
|
||||
try {
|
||||
await _webSocketService.connect(_deviceIp);
|
||||
await _webSocketService.connect(_deviceIp, port: _devicePort);
|
||||
_webSocketConnected = _webSocketService.isConnected;
|
||||
_errorMessage = null;
|
||||
_debugProvider.addWsLog('WebSocket connect request completed');
|
||||
notifyListeners();
|
||||
} catch (error) {
|
||||
_webSocketConnected = false;
|
||||
_errorMessage = error.toString();
|
||||
_debugProvider.addWsLog('WebSocket connect failed', details: error);
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> updateDeviceIp(String ip) async {
|
||||
final normalized = _normalizeDeviceIp(ip);
|
||||
_deviceIp = normalized;
|
||||
_httpApiService.baseUrl = 'http://$_deviceIp:8080';
|
||||
_status = _status.copyWith(ipAddress: normalized, updatedAt: DateTime.now());
|
||||
notifyListeners();
|
||||
Future<void> switchDevice(String input, {String? name}) async {
|
||||
final nextDevice = _parseDeviceInput(input, fallbackPort: _devicePort);
|
||||
final nextName =
|
||||
_normalizeDeviceName(name ?? _status.deviceName ?? _deviceName);
|
||||
_setLoading(true);
|
||||
_debugProvider.addHttpLog(
|
||||
'Switch device to ${nextDevice.ip}:${nextDevice.port}',
|
||||
details: <String, Object>{'name': nextName},
|
||||
);
|
||||
try {
|
||||
await _validateDeviceReachable(nextDevice.ip, nextDevice.port);
|
||||
|
||||
await _webSocketService.disconnect();
|
||||
await initialize();
|
||||
await _webSocketService.disconnect();
|
||||
_applyDevice(ip: nextDevice.ip, port: nextDevice.port, name: nextName);
|
||||
notifyListeners();
|
||||
|
||||
await _deviceStorageService.saveDevice(
|
||||
nextDevice.ip, nextDevice.port, nextName);
|
||||
await _refreshDeviceList();
|
||||
await refresh();
|
||||
await connect();
|
||||
|
||||
final resolvedName = _normalizeDeviceName(_status.deviceName ?? nextName);
|
||||
if (resolvedName != nextName) {
|
||||
_deviceName = resolvedName;
|
||||
await _deviceStorageService.saveDevice(
|
||||
nextDevice.ip,
|
||||
nextDevice.port,
|
||||
resolvedName,
|
||||
);
|
||||
await _refreshDeviceList();
|
||||
notifyListeners();
|
||||
}
|
||||
_debugProvider.addHttpLog(
|
||||
'Device switched successfully',
|
||||
details: <String, Object>{'device': '${nextDevice.ip}:${nextDevice.port}'},
|
||||
);
|
||||
} catch (error) {
|
||||
_errorMessage = error.toString();
|
||||
_debugProvider.addHttpLog('Switch device failed', details: error);
|
||||
notifyListeners();
|
||||
rethrow;
|
||||
} finally {
|
||||
_setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> updateDeviceIp(String ip) async {
|
||||
await switchDevice(ip);
|
||||
}
|
||||
|
||||
Future<void> removeStoredDevice(SavedDevice device) async {
|
||||
await _deviceStorageService.removeDevice(device.ip, device.port);
|
||||
await _refreshDeviceList();
|
||||
_debugProvider.addHttpLog(
|
||||
'Removed saved device ${device.address}',
|
||||
details: <String, Object>{'name': device.name},
|
||||
);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> startBle({String? deviceName}) async {
|
||||
_setLoading(true);
|
||||
_debugProvider.addHttpLog(
|
||||
'Start BLE service',
|
||||
details: <String, Object?>{'deviceName': deviceName},
|
||||
);
|
||||
try {
|
||||
await _httpApiService.startBle(deviceName);
|
||||
await refresh();
|
||||
_debugProvider.addHttpLog('BLE service started');
|
||||
} catch (error) {
|
||||
_errorMessage = error.toString();
|
||||
_debugProvider.addHttpLog('Start BLE service failed', details: error);
|
||||
notifyListeners();
|
||||
} finally {
|
||||
_setLoading(false);
|
||||
@@ -120,11 +223,14 @@ class DeviceProvider extends ChangeNotifier {
|
||||
|
||||
Future<void> stopBle() async {
|
||||
_setLoading(true);
|
||||
_debugProvider.addHttpLog('Stop BLE service');
|
||||
try {
|
||||
await _httpApiService.stopBle();
|
||||
await refresh();
|
||||
_debugProvider.addHttpLog('BLE service stopped');
|
||||
} catch (error) {
|
||||
_errorMessage = error.toString();
|
||||
_debugProvider.addHttpLog('Stop BLE service failed', details: error);
|
||||
notifyListeners();
|
||||
} finally {
|
||||
_setLoading(false);
|
||||
@@ -133,6 +239,9 @@ class DeviceProvider extends ChangeNotifier {
|
||||
|
||||
void _handleConnectionChanged(SocketConnectionStatus connectionStatus) {
|
||||
_webSocketConnected = connectionStatus == SocketConnectionStatus.connected;
|
||||
_debugProvider.addWsLog(
|
||||
'Device provider connection state: ${connectionStatus.name}',
|
||||
);
|
||||
if (!_webSocketConnected) {
|
||||
_status = _status.copyWith(connectionType: _status.connectionType);
|
||||
}
|
||||
@@ -140,6 +249,7 @@ class DeviceProvider extends ChangeNotifier {
|
||||
}
|
||||
|
||||
void _handleStatusUpdate(Map<String, dynamic> payload) {
|
||||
_debugProvider.addWsLog('Received status update', details: payload);
|
||||
final playerStatus = PlayerStatus.fromJson(payload);
|
||||
_status = _buildStatus(
|
||||
playerStatus: playerStatus,
|
||||
@@ -150,6 +260,7 @@ class DeviceProvider extends ChangeNotifier {
|
||||
}
|
||||
|
||||
void _handleWifiUpdate(Map<String, dynamic> payload) {
|
||||
_debugProvider.addWsLog('Received wifi update', details: payload);
|
||||
final wifiStatus = WifiStatus.fromJson(payload);
|
||||
_status = _buildStatus(
|
||||
playerStatus: _status.playerStatus ?? PlayerStatus.initial(),
|
||||
@@ -160,6 +271,7 @@ class DeviceProvider extends ChangeNotifier {
|
||||
}
|
||||
|
||||
void _handleBleUpdate(Map<String, dynamic> payload) {
|
||||
_debugProvider.addBleLog('Received BLE update', details: payload);
|
||||
final normalized = <String, dynamic>{
|
||||
'running': payload['running'] ?? payload['ready'] ?? false,
|
||||
'embedded': payload['embedded'] ?? false,
|
||||
@@ -186,10 +298,11 @@ class DeviceProvider extends ChangeNotifier {
|
||||
: 'offline';
|
||||
|
||||
return DeviceStatus(
|
||||
connected: wifiStatus.connected || bleStatus.running || _webSocketConnected,
|
||||
connected:
|
||||
wifiStatus.connected || bleStatus.running || _webSocketConnected,
|
||||
connectionType: connectionType,
|
||||
deviceName: bleStatus.deviceName ?? 'ShowenV2',
|
||||
ipAddress: wifiStatus.ip ?? _deviceIp,
|
||||
deviceName: bleStatus.deviceName ?? _deviceName,
|
||||
ipAddress: wifiStatus.ip ?? '$_deviceIp:$_devicePort',
|
||||
playerStatus: playerStatus,
|
||||
wifiStatus: wifiStatus,
|
||||
bleStatus: bleStatus,
|
||||
@@ -197,11 +310,46 @@ class DeviceProvider extends ChangeNotifier {
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _refreshDeviceList() async {
|
||||
_deviceList = await _deviceStorageService.getDevices();
|
||||
}
|
||||
|
||||
Future<void> _validateDeviceReachable(String ip, int port) async {
|
||||
final probeService = HttpApiService(baseUrl: _buildBaseUrl(ip, port));
|
||||
try {
|
||||
await probeService.getStatus().timeout(const Duration(seconds: 3));
|
||||
} on TimeoutException {
|
||||
throw Exception('设备不可达:连接超时(3 秒)');
|
||||
} on Exception catch (error) {
|
||||
throw Exception('设备不可达:$error');
|
||||
} finally {
|
||||
probeService.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
void _applyDevice({
|
||||
required String ip,
|
||||
required int port,
|
||||
String? name,
|
||||
}) {
|
||||
_deviceIp = _normalizeDeviceIp(ip);
|
||||
_devicePort = _normalizePort(port);
|
||||
_deviceName = _normalizeDeviceName(name);
|
||||
_httpApiService.baseUrl = _buildBaseUrl(_deviceIp, _devicePort);
|
||||
_status = _status.copyWith(
|
||||
deviceName: _deviceName,
|
||||
ipAddress: '$_deviceIp:$_devicePort',
|
||||
updatedAt: DateTime.now(),
|
||||
);
|
||||
}
|
||||
|
||||
void _setLoading(bool value) {
|
||||
_isLoading = value;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
static String _buildBaseUrl(String ip, int port) => 'http://$ip:$port';
|
||||
|
||||
static String _normalizeDeviceIp(String input) {
|
||||
var value = input.trim();
|
||||
if (value.startsWith('http://')) {
|
||||
@@ -210,6 +358,8 @@ class DeviceProvider extends ChangeNotifier {
|
||||
value = value.substring(8);
|
||||
} else if (value.startsWith('ws://')) {
|
||||
value = value.substring(5);
|
||||
} else if (value.startsWith('wss://')) {
|
||||
value = value.substring(6);
|
||||
}
|
||||
final slashIndex = value.indexOf('/');
|
||||
if (slashIndex >= 0) {
|
||||
@@ -222,6 +372,53 @@ class DeviceProvider extends ChangeNotifier {
|
||||
return value.isEmpty ? '127.0.0.1' : value;
|
||||
}
|
||||
|
||||
static int _normalizePort(int input) {
|
||||
if (input <= 0 || input > 65535) {
|
||||
return 5000;
|
||||
}
|
||||
return input;
|
||||
}
|
||||
|
||||
static String _normalizeDeviceName(String? input) {
|
||||
final value = input?.trim() ?? '';
|
||||
return value.isEmpty ? 'Showen' : value;
|
||||
}
|
||||
|
||||
static SavedDevice _parseDeviceInput(String input,
|
||||
{int fallbackPort = 5000}) {
|
||||
var value = input.trim();
|
||||
if (value.startsWith('http://')) {
|
||||
value = value.substring(7);
|
||||
} else if (value.startsWith('https://')) {
|
||||
value = value.substring(8);
|
||||
} else if (value.startsWith('ws://')) {
|
||||
value = value.substring(5);
|
||||
} else if (value.startsWith('wss://')) {
|
||||
value = value.substring(6);
|
||||
}
|
||||
|
||||
final slashIndex = value.indexOf('/');
|
||||
if (slashIndex >= 0) {
|
||||
value = value.substring(0, slashIndex);
|
||||
}
|
||||
|
||||
final colonIndex = value.lastIndexOf(':');
|
||||
final hasPort = colonIndex > 0 && colonIndex < value.length - 1;
|
||||
final ip =
|
||||
_normalizeDeviceIp(hasPort ? value.substring(0, colonIndex) : value);
|
||||
final port = hasPort
|
||||
? _normalizePort(
|
||||
int.tryParse(value.substring(colonIndex + 1)) ?? fallbackPort)
|
||||
: _normalizePort(fallbackPort);
|
||||
|
||||
return SavedDevice(
|
||||
ip: ip,
|
||||
port: port,
|
||||
name: 'Showen',
|
||||
lastUsedAt: DateTime.now(),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
unawaited(_connectionSubscription.cancel());
|
||||
|
||||
@@ -5,29 +5,36 @@ import 'package:flutter/foundation.dart';
|
||||
import '../models/player_status.dart';
|
||||
import '../services/http_api_service.dart';
|
||||
import '../services/web_socket_service.dart';
|
||||
import 'debug_provider.dart';
|
||||
|
||||
class PlayerProvider extends ChangeNotifier {
|
||||
PlayerProvider({
|
||||
required HttpApiService httpApiService,
|
||||
required WebSocketService webSocketService,
|
||||
required DebugProvider debugProvider,
|
||||
}) : _httpApiService = httpApiService,
|
||||
_webSocketService = webSocketService {
|
||||
_webSocketService = webSocketService,
|
||||
_debugProvider = debugProvider {
|
||||
_statusSubscription = _webSocketService.onStatusUpdate.listen((payload) {
|
||||
_status = PlayerStatus.fromJson(payload);
|
||||
_debugProvider.addWsLog('Player status update', details: payload);
|
||||
notifyListeners();
|
||||
});
|
||||
_stateSubscription = _webSocketService.onStateUpdate.listen((payload) {
|
||||
_currentState = payload['new_state']?.toString() ?? _currentState;
|
||||
_debugProvider.addWsLog('Player state update', details: payload);
|
||||
notifyListeners();
|
||||
});
|
||||
_configSubscription = _webSocketService.onConfigUpdate.listen((payload) {
|
||||
_updateSceneOptions(payload);
|
||||
_debugProvider.addWsLog('Player config update', details: payload);
|
||||
notifyListeners();
|
||||
});
|
||||
}
|
||||
|
||||
final HttpApiService _httpApiService;
|
||||
final WebSocketService _webSocketService;
|
||||
final DebugProvider _debugProvider;
|
||||
late final StreamSubscription<Map<String, dynamic>> _statusSubscription;
|
||||
late final StreamSubscription<Map<String, dynamic>> _stateSubscription;
|
||||
late final StreamSubscription<Map<String, dynamic>> _configSubscription;
|
||||
@@ -48,6 +55,7 @@ class PlayerProvider extends ChangeNotifier {
|
||||
|
||||
Future<void> bootstrap() async {
|
||||
_setLoading(true);
|
||||
_debugProvider.addHttpLog('Bootstrap player provider');
|
||||
try {
|
||||
final results = await Future.wait<dynamic>([
|
||||
_httpApiService.getPlaybackStatus(),
|
||||
@@ -58,31 +66,45 @@ class PlayerProvider extends ChangeNotifier {
|
||||
_playlist = results[1] as List<String>;
|
||||
_updateSceneOptions(results[2] as Map<String, dynamic>);
|
||||
_errorMessage = null;
|
||||
_debugProvider.addHttpLog(
|
||||
'Player provider bootstrapped',
|
||||
details: <String, Object>{'playlist': _playlist.length},
|
||||
);
|
||||
} catch (error) {
|
||||
_errorMessage = error.toString();
|
||||
_debugProvider.addHttpLog('Bootstrap player provider failed', details: error);
|
||||
} finally {
|
||||
_setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> fetchStatus() async {
|
||||
_debugProvider.addHttpLog('Fetch playback status');
|
||||
try {
|
||||
_status = await _httpApiService.getPlaybackStatus();
|
||||
_errorMessage = null;
|
||||
_debugProvider.addHttpLog('Playback status fetched');
|
||||
notifyListeners();
|
||||
} catch (error) {
|
||||
_errorMessage = error.toString();
|
||||
_debugProvider.addHttpLog('Fetch playback status failed', details: error);
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> fetchPlaylist() async {
|
||||
_debugProvider.addHttpLog('Fetch playlist');
|
||||
try {
|
||||
_playlist = await _httpApiService.getPlaylist();
|
||||
_errorMessage = null;
|
||||
_debugProvider.addHttpLog(
|
||||
'Playlist fetched',
|
||||
details: <String, Object>{'items': _playlist.length},
|
||||
);
|
||||
notifyListeners();
|
||||
} catch (error) {
|
||||
_errorMessage = error.toString();
|
||||
_debugProvider.addHttpLog('Fetch playlist failed', details: error);
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
@@ -119,6 +141,7 @@ class PlayerProvider extends ChangeNotifier {
|
||||
|
||||
Future<void> _runCommand(Future<dynamic> Function() action) async {
|
||||
_setLoading(true);
|
||||
_debugProvider.addHttpLog('Run player command');
|
||||
try {
|
||||
await action();
|
||||
await Future.wait<void>([
|
||||
@@ -126,8 +149,10 @@ class PlayerProvider extends ChangeNotifier {
|
||||
fetchPlaylist(),
|
||||
]);
|
||||
_errorMessage = null;
|
||||
_debugProvider.addHttpLog('Player command completed');
|
||||
} catch (error) {
|
||||
_errorMessage = error.toString();
|
||||
_debugProvider.addHttpLog('Player command failed', details: error);
|
||||
notifyListeners();
|
||||
} finally {
|
||||
_setLoading(false);
|
||||
|
||||
@@ -6,21 +6,26 @@ import '../models/wifi_network.dart';
|
||||
import '../models/wifi_status.dart';
|
||||
import '../services/http_api_service.dart';
|
||||
import '../services/web_socket_service.dart';
|
||||
import 'debug_provider.dart';
|
||||
|
||||
class WifiProvider extends ChangeNotifier {
|
||||
WifiProvider({
|
||||
required HttpApiService httpApiService,
|
||||
required WebSocketService webSocketService,
|
||||
required DebugProvider debugProvider,
|
||||
}) : _httpApiService = httpApiService,
|
||||
_webSocketService = webSocketService {
|
||||
_webSocketService = webSocketService,
|
||||
_debugProvider = debugProvider {
|
||||
_wifiSubscription = _webSocketService.onWifiUpdate.listen((payload) {
|
||||
_status = WifiStatus.fromJson(payload);
|
||||
_debugProvider.addWsLog('Wifi provider update', details: payload);
|
||||
notifyListeners();
|
||||
});
|
||||
}
|
||||
|
||||
final HttpApiService _httpApiService;
|
||||
final WebSocketService _webSocketService;
|
||||
final DebugProvider _debugProvider;
|
||||
late final StreamSubscription<Map<String, dynamic>> _wifiSubscription;
|
||||
|
||||
WifiStatus _status = WifiStatus.disconnected();
|
||||
@@ -37,6 +42,7 @@ class WifiProvider extends ChangeNotifier {
|
||||
|
||||
Future<void> bootstrap() async {
|
||||
_setLoading(true);
|
||||
_debugProvider.addHttpLog('Bootstrap WiFi provider');
|
||||
try {
|
||||
final results = await Future.wait<dynamic>([
|
||||
_httpApiService.getWifiStatus(),
|
||||
@@ -45,30 +51,44 @@ class WifiProvider extends ChangeNotifier {
|
||||
_status = results[0] as WifiStatus;
|
||||
_networks = results[1] as List<WifiNetwork>;
|
||||
_errorMessage = null;
|
||||
_debugProvider.addHttpLog(
|
||||
'WiFi provider bootstrapped',
|
||||
details: <String, Object>{'networks': _networks.length},
|
||||
);
|
||||
} catch (error) {
|
||||
_errorMessage = error.toString();
|
||||
_debugProvider.addHttpLog('Bootstrap WiFi provider failed', details: error);
|
||||
} finally {
|
||||
_setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> refreshStatus() async {
|
||||
_debugProvider.addHttpLog('Refresh WiFi status');
|
||||
try {
|
||||
_status = await _httpApiService.getWifiStatus();
|
||||
_debugProvider.addHttpLog('WiFi status refreshed');
|
||||
notifyListeners();
|
||||
} catch (error) {
|
||||
_errorMessage = error.toString();
|
||||
_debugProvider.addHttpLog('Refresh WiFi status failed', details: error);
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> scanNetworks() async {
|
||||
_setLoading(true);
|
||||
_debugProvider.addHttpLog('Scan WiFi networks');
|
||||
try {
|
||||
_networks = await _httpApiService.scanWifi();
|
||||
_errorMessage = null;
|
||||
_debugProvider.addHttpLog(
|
||||
'WiFi scan completed',
|
||||
details: <String, Object>{'networks': _networks.length},
|
||||
);
|
||||
} catch (error) {
|
||||
_errorMessage = error.toString();
|
||||
_debugProvider.addHttpLog('WiFi scan failed', details: error);
|
||||
} finally {
|
||||
_setLoading(false);
|
||||
}
|
||||
@@ -76,13 +96,19 @@ class WifiProvider extends ChangeNotifier {
|
||||
|
||||
Future<void> connect({required String ssid, required String password}) async {
|
||||
_setLoading(true);
|
||||
_debugProvider.addHttpLog(
|
||||
'Connect WiFi network',
|
||||
details: <String, Object>{'ssid': ssid},
|
||||
);
|
||||
try {
|
||||
await _httpApiService.connectWifi(ssid, password);
|
||||
await refreshStatus();
|
||||
_hotspotEnabled = false;
|
||||
_errorMessage = null;
|
||||
_debugProvider.addHttpLog('WiFi connected', details: <String, Object>{'ssid': ssid});
|
||||
} catch (error) {
|
||||
_errorMessage = error.toString();
|
||||
_debugProvider.addHttpLog('Connect WiFi failed', details: error);
|
||||
notifyListeners();
|
||||
} finally {
|
||||
_setLoading(false);
|
||||
@@ -91,13 +117,19 @@ class WifiProvider extends ChangeNotifier {
|
||||
|
||||
Future<void> startHotspot({String? ssid, String? password}) async {
|
||||
_setLoading(true);
|
||||
_debugProvider.addHttpLog(
|
||||
'Start hotspot',
|
||||
details: <String, Object?>{'ssid': ssid},
|
||||
);
|
||||
try {
|
||||
await _httpApiService.startAP(ssid, password);
|
||||
_hotspotEnabled = true;
|
||||
_errorMessage = null;
|
||||
_debugProvider.addHttpLog('Hotspot started');
|
||||
notifyListeners();
|
||||
} catch (error) {
|
||||
_errorMessage = error.toString();
|
||||
_debugProvider.addHttpLog('Start hotspot failed', details: error);
|
||||
notifyListeners();
|
||||
} finally {
|
||||
_setLoading(false);
|
||||
@@ -106,13 +138,16 @@ class WifiProvider extends ChangeNotifier {
|
||||
|
||||
Future<void> stopHotspot() async {
|
||||
_setLoading(true);
|
||||
_debugProvider.addHttpLog('Stop hotspot');
|
||||
try {
|
||||
await _httpApiService.stopAP();
|
||||
_hotspotEnabled = false;
|
||||
_errorMessage = null;
|
||||
_debugProvider.addHttpLog('Hotspot stopped');
|
||||
notifyListeners();
|
||||
} catch (error) {
|
||||
_errorMessage = error.toString();
|
||||
_debugProvider.addHttpLog('Stop hotspot failed', details: error);
|
||||
notifyListeners();
|
||||
} finally {
|
||||
_setLoading(false);
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../widgets/connection_status_banner.dart';
|
||||
|
||||
class AppShell extends StatelessWidget {
|
||||
const AppShell({required this.navigationShell, super.key});
|
||||
|
||||
@@ -9,7 +11,12 @@ class AppShell extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
body: navigationShell,
|
||||
body: Column(
|
||||
children: [
|
||||
const ConnectionStatusBanner(),
|
||||
Expanded(child: navigationShell),
|
||||
],
|
||||
),
|
||||
bottomNavigationBar: NavigationBar(
|
||||
selectedIndex: navigationShell.currentIndex,
|
||||
onDestinationSelected: (index) {
|
||||
@@ -44,6 +51,11 @@ class AppShell extends StatelessWidget {
|
||||
selectedIcon: Icon(Icons.settings),
|
||||
label: '设置',
|
||||
),
|
||||
NavigationDestination(
|
||||
icon: Icon(Icons.bug_report_outlined),
|
||||
selectedIcon: Icon(Icons.bug_report),
|
||||
label: '调试',
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../models/ble_models.dart';
|
||||
import '../providers/ble_provider.dart';
|
||||
@@ -22,8 +23,8 @@ class _BleProvisionScreenState extends State<BleProvisionScreen> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_ownsProvider = widget.provider == null;
|
||||
_provider = widget.provider ?? BleProvider();
|
||||
_ownsProvider = false;
|
||||
_provider = widget.provider ?? context.read<BleProvider>();
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
_provider.startScan();
|
||||
});
|
||||
|
||||
301
clients/flutter/lib/screens/debug_screen.dart
Normal file
301
clients/flutter/lib/screens/debug_screen.dart
Normal file
@@ -0,0 +1,301 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../providers/debug_provider.dart';
|
||||
import '../theme/app_colors.dart';
|
||||
|
||||
class DebugScreen extends StatelessWidget {
|
||||
const DebugScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final provider = context.watch<DebugProvider>();
|
||||
final entries = provider.filteredEntries;
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('调试日志'),
|
||||
actions: [
|
||||
IconButton(
|
||||
onPressed: provider.entries.isEmpty ? null : provider.clearLogs,
|
||||
icon: const Icon(Icons.delete_sweep_outlined),
|
||||
tooltip: '清空日志',
|
||||
),
|
||||
],
|
||||
),
|
||||
body: Column(
|
||||
children: [
|
||||
Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.fromLTRB(
|
||||
AppSpacing.md,
|
||||
AppSpacing.md,
|
||||
AppSpacing.md,
|
||||
AppSpacing.sm,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
colors: [
|
||||
AppColors.card,
|
||||
AppColors.card.withValues(alpha: 0.72),
|
||||
],
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
),
|
||||
border: const Border(bottom: BorderSide(color: AppColors.border)),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'事件时间线',
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
const SizedBox(height: AppSpacing.xs),
|
||||
Text(
|
||||
'保留最近 ${DebugProvider.maxEntries} 条日志,支持按链路筛选。',
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: AppColors.textSecondary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: AppSpacing.md),
|
||||
Wrap(
|
||||
spacing: AppSpacing.sm,
|
||||
runSpacing: AppSpacing.sm,
|
||||
children: [
|
||||
_FilterChipItem(
|
||||
label: '全部',
|
||||
selected: provider.filter == DebugLogFilter.all,
|
||||
color: AppColors.textSecondary,
|
||||
onTap: () => provider.setFilter(DebugLogFilter.all),
|
||||
),
|
||||
_FilterChipItem(
|
||||
label: 'BLE',
|
||||
selected: provider.filter == DebugLogFilter.ble,
|
||||
color: AppColors.info,
|
||||
onTap: () => provider.setFilter(DebugLogFilter.ble),
|
||||
),
|
||||
_FilterChipItem(
|
||||
label: 'WS',
|
||||
selected: provider.filter == DebugLogFilter.ws,
|
||||
color: AppColors.success,
|
||||
onTap: () => provider.setFilter(DebugLogFilter.ws),
|
||||
),
|
||||
_FilterChipItem(
|
||||
label: 'HTTP',
|
||||
selected: provider.filter == DebugLogFilter.http,
|
||||
color: AppColors.warning,
|
||||
onTap: () => provider.setFilter(DebugLogFilter.http),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: entries.isEmpty
|
||||
? const _EmptyDebugState()
|
||||
: ListView.separated(
|
||||
padding: const EdgeInsets.all(AppSpacing.md),
|
||||
itemCount: entries.length,
|
||||
separatorBuilder: (_, __) =>
|
||||
const SizedBox(height: AppSpacing.sm),
|
||||
itemBuilder: (context, index) {
|
||||
return _DebugLogCard(entry: entries[index]);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _FilterChipItem extends StatelessWidget {
|
||||
const _FilterChipItem({
|
||||
required this.label,
|
||||
required this.selected,
|
||||
required this.color,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
final String label;
|
||||
final bool selected;
|
||||
final Color color;
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return FilterChip(
|
||||
label: Text(label),
|
||||
selected: selected,
|
||||
onSelected: (_) => onTap(),
|
||||
showCheckmark: false,
|
||||
selectedColor: color.withValues(alpha: 0.18),
|
||||
side: BorderSide(color: selected ? color : AppColors.border),
|
||||
labelStyle: Theme.of(context).textTheme.labelLarge?.copyWith(
|
||||
color: selected ? color : AppColors.textSecondary,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
backgroundColor: AppColors.card,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _DebugLogCard extends StatelessWidget {
|
||||
const _DebugLogCard({required this.entry});
|
||||
|
||||
final DebugLogEntry entry;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final color = _typeColor(entry.type);
|
||||
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.card,
|
||||
borderRadius: BorderRadius.circular(AppRadius.large),
|
||||
border: Border.all(color: AppColors.border),
|
||||
),
|
||||
child: IntrinsicHeight(
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Container(
|
||||
width: 4,
|
||||
decoration: BoxDecoration(
|
||||
color: color,
|
||||
borderRadius: const BorderRadius.only(
|
||||
topLeft: Radius.circular(AppRadius.large),
|
||||
bottomLeft: Radius.circular(AppRadius.large),
|
||||
),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(AppSpacing.md),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: AppSpacing.sm,
|
||||
vertical: AppSpacing.xs,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: color.withValues(alpha: 0.14),
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
border: Border.all(
|
||||
color: color.withValues(alpha: 0.35),
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
entry.label,
|
||||
style:
|
||||
Theme.of(context).textTheme.labelMedium?.copyWith(
|
||||
color: color,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
Text(
|
||||
_formatTimestamp(entry.timestamp),
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: AppColors.textSecondary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: AppSpacing.sm),
|
||||
Text(
|
||||
entry.summary,
|
||||
style: Theme.of(context).textTheme.bodyLarge,
|
||||
),
|
||||
if (entry.details != null && entry.details!.isNotEmpty) ...[
|
||||
const SizedBox(height: AppSpacing.sm),
|
||||
Text(
|
||||
entry.details!,
|
||||
maxLines: 3,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: AppColors.textSecondary,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Color _typeColor(DebugLogType type) {
|
||||
switch (type) {
|
||||
case DebugLogType.ble:
|
||||
return AppColors.info;
|
||||
case DebugLogType.ws:
|
||||
return AppColors.success;
|
||||
case DebugLogType.http:
|
||||
return AppColors.warning;
|
||||
}
|
||||
}
|
||||
|
||||
String _formatTimestamp(DateTime timestamp) {
|
||||
final hh = timestamp.hour.toString().padLeft(2, '0');
|
||||
final mm = timestamp.minute.toString().padLeft(2, '0');
|
||||
final ss = timestamp.second.toString().padLeft(2, '0');
|
||||
final ms = timestamp.millisecond.toString().padLeft(3, '0');
|
||||
return '$hh:$mm:$ss.$ms';
|
||||
}
|
||||
}
|
||||
|
||||
class _EmptyDebugState extends StatelessWidget {
|
||||
const _EmptyDebugState();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(AppSpacing.xl),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Container(
|
||||
width: 72,
|
||||
height: 72,
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
colors: [
|
||||
AppColors.info.withValues(alpha: 0.24),
|
||||
AppColors.success.withValues(alpha: 0.16),
|
||||
],
|
||||
),
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
),
|
||||
child: const Icon(Icons.bug_report_outlined, size: 34),
|
||||
),
|
||||
const SizedBox(height: AppSpacing.md),
|
||||
Text(
|
||||
'当前没有调试事件',
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
const SizedBox(height: AppSpacing.xs),
|
||||
Text(
|
||||
'连接设备、触发播放、执行网络或 BLE 操作后,日志会按时间顺序出现在这里。',
|
||||
textAlign: TextAlign.center,
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: AppColors.textSecondary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../providers/ble_provider.dart';
|
||||
import '../providers/device_provider.dart';
|
||||
import '../providers/wifi_provider.dart';
|
||||
import '../theme/app_colors.dart';
|
||||
@@ -33,6 +34,7 @@ class _NetworkScreenState extends State<NetworkScreen> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final bleProvider = context.watch<BleProvider>();
|
||||
final wifiProvider = context.watch<WifiProvider>();
|
||||
final deviceProvider = context.watch<DeviceProvider>();
|
||||
final wifiStatus = wifiProvider.status;
|
||||
@@ -40,9 +42,12 @@ class _NetworkScreenState extends State<NetworkScreen> {
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('网络设置')),
|
||||
body: ListView(
|
||||
padding: const EdgeInsets.all(AppSpacing.md),
|
||||
children: [
|
||||
body: RefreshIndicator(
|
||||
onRefresh: _handleRefresh,
|
||||
child: ListView(
|
||||
physics: const AlwaysScrollableScrollPhysics(),
|
||||
padding: const EdgeInsets.all(AppSpacing.md),
|
||||
children: [
|
||||
StatusCard(
|
||||
title: 'WiFi 状态',
|
||||
value: wifiStatus.connected ? (wifiStatus.ssid ?? '已连接') : '未连接',
|
||||
@@ -107,6 +112,75 @@ class _NetworkScreenState extends State<NetworkScreen> {
|
||||
),
|
||||
],
|
||||
),
|
||||
if (bleProvider.isConnected) ...[
|
||||
const SizedBox(height: AppSpacing.lg),
|
||||
Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(AppSpacing.md),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('BLE 控制', style: Theme.of(context).textTheme.titleMedium),
|
||||
const SizedBox(height: AppSpacing.sm),
|
||||
Text(
|
||||
bleProvider.selectedDevice?.name ?? '已连接 BLE 设备',
|
||||
style: Theme.of(context).textTheme.bodyMedium,
|
||||
),
|
||||
const SizedBox(height: AppSpacing.md),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: ControlButton(
|
||||
label: '播放',
|
||||
icon: Icons.play_arrow_rounded,
|
||||
onPressed: bleProvider.isSendingCommand
|
||||
? null
|
||||
: () => _sendBleCommand('play'),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: AppSpacing.md),
|
||||
Expanded(
|
||||
child: ControlButton(
|
||||
label: '暂停',
|
||||
icon: Icons.pause_rounded,
|
||||
onPressed: bleProvider.isSendingCommand
|
||||
? null
|
||||
: () => _sendBleCommand('pause'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: AppSpacing.md),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: ControlButton(
|
||||
label: '上一个',
|
||||
icon: Icons.skip_previous_rounded,
|
||||
isFilled: false,
|
||||
onPressed: bleProvider.isSendingCommand
|
||||
? null
|
||||
: () => _sendBleCommand('prev'),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: AppSpacing.md),
|
||||
Expanded(
|
||||
child: ControlButton(
|
||||
label: '下一个',
|
||||
icon: Icons.skip_next_rounded,
|
||||
isFilled: false,
|
||||
onPressed: bleProvider.isSendingCommand
|
||||
? null
|
||||
: () => _sendBleCommand('next'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: AppSpacing.lg),
|
||||
Text('扫描结果', style: Theme.of(context).textTheme.headlineSmall),
|
||||
const SizedBox(height: AppSpacing.md),
|
||||
@@ -179,11 +253,19 @@ class _NetworkScreenState extends State<NetworkScreen> {
|
||||
child: const Text('进入 BLE 配网页面'),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _handleRefresh() async {
|
||||
await Future.wait<void>([
|
||||
context.read<WifiProvider>().bootstrap(),
|
||||
context.read<DeviceProvider>().refresh(),
|
||||
]);
|
||||
}
|
||||
|
||||
void _handleConnectWifi() {
|
||||
final ssid = _ssidController.text.trim();
|
||||
if (ssid.isEmpty) {
|
||||
@@ -198,4 +280,23 @@ class _NetworkScreenState extends State<NetworkScreen> {
|
||||
password: _passwordController.text,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _sendBleCommand(String command) async {
|
||||
try {
|
||||
await context.read<BleProvider>().sendCommand(command);
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('已发送 BLE 指令: $command')),
|
||||
);
|
||||
} catch (error) {
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('BLE 指令发送失败: $error')),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,9 +29,12 @@ class _PlaybackScreenState extends State<PlaybackScreen> {
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('播放控制')),
|
||||
body: ListView(
|
||||
padding: const EdgeInsets.all(AppSpacing.md),
|
||||
children: [
|
||||
body: RefreshIndicator(
|
||||
onRefresh: _handleRefresh,
|
||||
child: ListView(
|
||||
physics: const AlwaysScrollableScrollPhysics(),
|
||||
padding: const EdgeInsets.all(AppSpacing.md),
|
||||
children: [
|
||||
Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(AppSpacing.lg),
|
||||
@@ -164,11 +167,20 @@ class _PlaybackScreenState extends State<PlaybackScreen> {
|
||||
),
|
||||
);
|
||||
}),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _handleRefresh() async {
|
||||
final provider = context.read<PlayerProvider>();
|
||||
await Future.wait<void>([
|
||||
provider.fetchStatus(),
|
||||
provider.fetchPlaylist(),
|
||||
]);
|
||||
}
|
||||
|
||||
void _handleGoto() {
|
||||
final index = int.tryParse(_indexController.text.trim());
|
||||
if (index == null) {
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../models/video_item.dart';
|
||||
import '../providers/device_provider.dart';
|
||||
import '../services/http_api_service.dart';
|
||||
import '../theme/app_colors.dart';
|
||||
|
||||
class SettingsScreen extends StatefulWidget {
|
||||
@@ -15,6 +18,7 @@ class SettingsScreen extends StatefulWidget {
|
||||
|
||||
class _SettingsScreenState extends State<SettingsScreen> {
|
||||
final TextEditingController _ipController = TextEditingController();
|
||||
final FocusNode _ipFocusNode = FocusNode();
|
||||
final TextEditingController _titleController = TextEditingController();
|
||||
final TextEditingController _rotationController = TextEditingController();
|
||||
final TextEditingController _widthController = TextEditingController();
|
||||
@@ -22,11 +26,14 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
final TextEditingController _hsvMinController = TextEditingController();
|
||||
final TextEditingController _hsvMaxController = TextEditingController();
|
||||
final TextEditingController _pointsController = TextEditingController();
|
||||
final TextEditingController _jsonController = TextEditingController();
|
||||
|
||||
Map<String, dynamic>? _fullConfig;
|
||||
List<String> _availableConfigs = const <String>[];
|
||||
Future<List<VideoItem>>? _videosFuture;
|
||||
String? _activeConfig;
|
||||
bool _isFullscreen = false;
|
||||
bool _jsonEditMode = false;
|
||||
bool _loading = true;
|
||||
|
||||
@override
|
||||
@@ -38,6 +45,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
@override
|
||||
void dispose() {
|
||||
_ipController.dispose();
|
||||
_ipFocusNode.dispose();
|
||||
_titleController.dispose();
|
||||
_rotationController.dispose();
|
||||
_widthController.dispose();
|
||||
@@ -45,204 +53,425 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
_hsvMinController.dispose();
|
||||
_hsvMaxController.dispose();
|
||||
_pointsController.dispose();
|
||||
_jsonController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final provider = context.watch<DeviceProvider>();
|
||||
final httpApiService = provider.httpApiService;
|
||||
final status = provider.status;
|
||||
_ipController.text = _ipController.text.isEmpty ? provider.deviceIp : _ipController.text;
|
||||
final currentAddress = '${provider.deviceIp}:${provider.devicePort}';
|
||||
if (!_ipFocusNode.hasFocus && _ipController.text != currentAddress) {
|
||||
_ipController.value = TextEditingValue(
|
||||
text: currentAddress,
|
||||
selection: TextSelection.collapsed(offset: currentAddress.length),
|
||||
);
|
||||
}
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('设置')),
|
||||
body: _loading
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: ListView(
|
||||
padding: const EdgeInsets.all(AppSpacing.md),
|
||||
children: [
|
||||
Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(AppSpacing.md),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('设备 IP 配置', style: Theme.of(context).textTheme.titleMedium),
|
||||
const SizedBox(height: AppSpacing.md),
|
||||
TextField(
|
||||
controller: _ipController,
|
||||
decoration: const InputDecoration(labelText: '设备 IP 地址'),
|
||||
),
|
||||
const SizedBox(height: AppSpacing.md),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: FilledButton(
|
||||
onPressed: () async {
|
||||
await context.read<DeviceProvider>().updateDeviceIp(
|
||||
_ipController.text.trim(),
|
||||
);
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
await _loadData();
|
||||
: RefreshIndicator(
|
||||
onRefresh: _loadData,
|
||||
child: ListView(
|
||||
physics: const AlwaysScrollableScrollPhysics(),
|
||||
padding: const EdgeInsets.all(AppSpacing.md),
|
||||
children: [
|
||||
Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(AppSpacing.md),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('设备 IP 配置',
|
||||
style: Theme.of(context).textTheme.titleMedium),
|
||||
const SizedBox(height: AppSpacing.md),
|
||||
TextField(
|
||||
controller: _ipController,
|
||||
focusNode: _ipFocusNode,
|
||||
enabled: !provider.isLoading,
|
||||
decoration: const InputDecoration(
|
||||
labelText: '设备 IP 地址',
|
||||
hintText: '例如 192.168.1.10:5000',
|
||||
),
|
||||
textInputAction: TextInputAction.done,
|
||||
onSubmitted: (_) => _handleSwitchDevice(),
|
||||
onTapOutside: (_) {
|
||||
_ipFocusNode.unfocus();
|
||||
_handleSwitchDevice();
|
||||
},
|
||||
child: const Text('保存并重连'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: AppSpacing.lg),
|
||||
Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(AppSpacing.md),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('可用配置文件', style: Theme.of(context).textTheme.titleMedium),
|
||||
const SizedBox(height: AppSpacing.md),
|
||||
DropdownButtonFormField<String>(
|
||||
initialValue: _activeConfig,
|
||||
items: _availableConfigs
|
||||
.map(
|
||||
(item) => DropdownMenuItem<String>(
|
||||
value: item,
|
||||
child: Text(item),
|
||||
const SizedBox(height: AppSpacing.md),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: FilledButton(
|
||||
onPressed: provider.isLoading
|
||||
? null
|
||||
: _handleSwitchDevice,
|
||||
child: provider.isLoading
|
||||
? const SizedBox(
|
||||
width: 18,
|
||||
height: 18,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
),
|
||||
)
|
||||
: const Text('切换设备'),
|
||||
),
|
||||
)
|
||||
.toList(growable: false),
|
||||
onChanged: (value) => setState(() => _activeConfig = value),
|
||||
decoration: const InputDecoration(labelText: '当前配置'),
|
||||
),
|
||||
const SizedBox(height: AppSpacing.md),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: FilledButton.tonal(
|
||||
onPressed: _activeConfig == null ? null : _handleSwitchConfig,
|
||||
child: const Text('切换配置'),
|
||||
),
|
||||
const SizedBox(width: AppSpacing.md),
|
||||
Expanded(
|
||||
child: FilledButton.tonal(
|
||||
onPressed: provider.isLoading
|
||||
? null
|
||||
: () => _showDeviceListDialog(provider),
|
||||
child: const Text('设备列表'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: AppSpacing.lg),
|
||||
Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(AppSpacing.md),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('显示设置', style: Theme.of(context).textTheme.titleMedium),
|
||||
const SizedBox(height: AppSpacing.md),
|
||||
SwitchListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
value: _isFullscreen,
|
||||
onChanged: (value) => setState(() => _isFullscreen = value),
|
||||
title: const Text('全屏模式'),
|
||||
),
|
||||
TextField(
|
||||
controller: _titleController,
|
||||
decoration: const InputDecoration(labelText: '窗口标题'),
|
||||
),
|
||||
const SizedBox(height: AppSpacing.md),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: TextField(
|
||||
controller: _rotationController,
|
||||
keyboardType: TextInputType.number,
|
||||
decoration: const InputDecoration(labelText: '旋转角度'),
|
||||
const SizedBox(height: AppSpacing.lg),
|
||||
Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(AppSpacing.md),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('可用配置文件',
|
||||
style: Theme.of(context).textTheme.titleMedium),
|
||||
const SizedBox(height: AppSpacing.md),
|
||||
DropdownButtonFormField<String>(
|
||||
initialValue: _activeConfig,
|
||||
items: _availableConfigs
|
||||
.map(
|
||||
(item) => DropdownMenuItem<String>(
|
||||
value: item,
|
||||
child: Text(item),
|
||||
),
|
||||
)
|
||||
.toList(growable: false),
|
||||
onChanged: (value) =>
|
||||
setState(() => _activeConfig = value),
|
||||
decoration:
|
||||
const InputDecoration(labelText: '当前配置'),
|
||||
),
|
||||
const SizedBox(height: AppSpacing.md),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: FilledButton.tonal(
|
||||
onPressed: _activeConfig == null
|
||||
? null
|
||||
: _handleSwitchConfig,
|
||||
child: const Text('切换配置'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: AppSpacing.lg),
|
||||
Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(AppSpacing.md),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
'配置编辑',
|
||||
style:
|
||||
Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: _fullConfig == null
|
||||
? null
|
||||
: _handleCopyJson,
|
||||
child: const Text('复制JSON'),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: AppSpacing.md),
|
||||
SegmentedButton<bool>(
|
||||
segments: const [
|
||||
ButtonSegment<bool>(
|
||||
value: false,
|
||||
label: Text('表单'),
|
||||
icon: Icon(Icons.tune_rounded),
|
||||
),
|
||||
ButtonSegment<bool>(
|
||||
value: true,
|
||||
label: Text('JSON'),
|
||||
icon: Icon(Icons.data_object_rounded),
|
||||
),
|
||||
],
|
||||
selected: <bool>{_jsonEditMode},
|
||||
onSelectionChanged: (selection) {
|
||||
final jsonMode = selection.first;
|
||||
setState(() {
|
||||
_jsonEditMode = jsonMode;
|
||||
if (jsonMode) {
|
||||
_syncJsonControllerFromConfig();
|
||||
}
|
||||
});
|
||||
},
|
||||
),
|
||||
const SizedBox(height: AppSpacing.md),
|
||||
if (_jsonEditMode) ...[
|
||||
TextField(
|
||||
controller: _jsonController,
|
||||
minLines: 15,
|
||||
maxLines: 15,
|
||||
decoration: const InputDecoration(
|
||||
labelText: '完整配置 JSON',
|
||||
alignLabelWithHint: true,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: AppSpacing.md),
|
||||
Expanded(
|
||||
child: TextField(
|
||||
controller: _widthController,
|
||||
keyboardType: TextInputType.number,
|
||||
decoration: const InputDecoration(labelText: '渲染宽度'),
|
||||
const SizedBox(height: AppSpacing.md),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: FilledButton(
|
||||
onPressed: _handleSaveJsonConfig,
|
||||
child: const Text('保存 JSON'),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: AppSpacing.md),
|
||||
Expanded(
|
||||
child: TextField(
|
||||
controller: _heightController,
|
||||
keyboardType: TextInputType.number,
|
||||
decoration: const InputDecoration(labelText: '渲染高度'),
|
||||
] else ...[
|
||||
SwitchListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
value: _isFullscreen,
|
||||
onChanged: (value) =>
|
||||
setState(() => _isFullscreen = value),
|
||||
title: const Text('全屏模式'),
|
||||
),
|
||||
TextField(
|
||||
controller: _titleController,
|
||||
decoration:
|
||||
const InputDecoration(labelText: '窗口标题'),
|
||||
),
|
||||
const SizedBox(height: AppSpacing.md),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: TextField(
|
||||
controller: _rotationController,
|
||||
keyboardType: TextInputType.number,
|
||||
decoration: const InputDecoration(
|
||||
labelText: '旋转角度'),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: AppSpacing.md),
|
||||
Expanded(
|
||||
child: TextField(
|
||||
controller: _widthController,
|
||||
keyboardType: TextInputType.number,
|
||||
decoration: const InputDecoration(
|
||||
labelText: '渲染宽度'),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: AppSpacing.md),
|
||||
Expanded(
|
||||
child: TextField(
|
||||
controller: _heightController,
|
||||
keyboardType: TextInputType.number,
|
||||
decoration: const InputDecoration(
|
||||
labelText: '渲染高度'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: AppSpacing.md),
|
||||
TextField(
|
||||
controller: _hsvMinController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: '色键下限 HSV (逗号分隔)'),
|
||||
),
|
||||
const SizedBox(height: AppSpacing.md),
|
||||
TextField(
|
||||
controller: _hsvMaxController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: '色键上限 HSV (逗号分隔)'),
|
||||
),
|
||||
const SizedBox(height: AppSpacing.md),
|
||||
TextField(
|
||||
controller: _pointsController,
|
||||
minLines: 3,
|
||||
maxLines: 5,
|
||||
decoration:
|
||||
const InputDecoration(labelText: '透视点 JSON'),
|
||||
),
|
||||
const SizedBox(height: AppSpacing.md),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: FilledButton(
|
||||
onPressed: _handleSaveDisplayConfig,
|
||||
child: const Text('保存显示设置'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: AppSpacing.md),
|
||||
TextField(
|
||||
controller: _hsvMinController,
|
||||
decoration: const InputDecoration(labelText: '色键下限 HSV (逗号分隔)'),
|
||||
),
|
||||
const SizedBox(height: AppSpacing.md),
|
||||
TextField(
|
||||
controller: _hsvMaxController,
|
||||
decoration: const InputDecoration(labelText: '色键上限 HSV (逗号分隔)'),
|
||||
),
|
||||
const SizedBox(height: AppSpacing.md),
|
||||
TextField(
|
||||
controller: _pointsController,
|
||||
minLines: 3,
|
||||
maxLines: 5,
|
||||
decoration: const InputDecoration(labelText: '透视点 JSON'),
|
||||
),
|
||||
const SizedBox(height: AppSpacing.md),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: FilledButton(
|
||||
onPressed: _handleSaveDisplayConfig,
|
||||
child: const Text('保存显示设置'),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: AppSpacing.lg),
|
||||
Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(AppSpacing.md),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
'视频管理',
|
||||
style:
|
||||
Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
),
|
||||
FilledButton.icon(
|
||||
onPressed: _handleUploadVideo,
|
||||
icon: const Icon(Icons.upload_file_rounded),
|
||||
label: const Text('上传视频'),
|
||||
),
|
||||
const SizedBox(width: AppSpacing.sm),
|
||||
IconButton(
|
||||
onPressed: _reloadVideos,
|
||||
icon: const Icon(Icons.refresh_rounded),
|
||||
tooltip: '刷新视频列表',
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: AppSpacing.sm),
|
||||
FutureBuilder<List<VideoItem>>(
|
||||
future: _videosFuture,
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.connectionState ==
|
||||
ConnectionState.waiting) {
|
||||
return const Padding(
|
||||
padding: EdgeInsets.symmetric(
|
||||
vertical: AppSpacing.md),
|
||||
child: Center(
|
||||
child: CircularProgressIndicator(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (snapshot.hasError) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
vertical: AppSpacing.sm),
|
||||
child: Text(
|
||||
'视频列表加载失败: ${snapshot.error}',
|
||||
style: TextStyle(
|
||||
color:
|
||||
Theme.of(context).colorScheme.error,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final videos =
|
||||
snapshot.data ?? const <VideoItem>[];
|
||||
if (videos.isEmpty) {
|
||||
return const Padding(
|
||||
padding: EdgeInsets.symmetric(
|
||||
vertical: AppSpacing.sm),
|
||||
child: Text('当前没有视频文件'),
|
||||
);
|
||||
}
|
||||
|
||||
return ListView.separated(
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
itemCount: videos.length,
|
||||
separatorBuilder: (_, __) =>
|
||||
const Divider(height: 1),
|
||||
itemBuilder: (context, index) {
|
||||
final video = videos[index];
|
||||
return ListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
title: Text(video.name),
|
||||
subtitle: Text(video.sizeLabel),
|
||||
trailing: IconButton(
|
||||
onPressed: () => _confirmDeleteVideo(
|
||||
httpApiService, video),
|
||||
icon: const Icon(
|
||||
Icons.delete_outline_rounded),
|
||||
tooltip: '删除视频',
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: AppSpacing.lg),
|
||||
Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(AppSpacing.md),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('关于信息', style: Theme.of(context).textTheme.titleMedium),
|
||||
const SizedBox(height: AppSpacing.md),
|
||||
_InfoRow(label: '设备名称', value: status.deviceName ?? 'ShowenV2'),
|
||||
_InfoRow(label: '连接方式', value: status.connectionType.toUpperCase()),
|
||||
_InfoRow(label: '设备地址', value: status.ipAddress ?? provider.deviceIp),
|
||||
_InfoRow(
|
||||
label: '实时通道',
|
||||
value: provider.webSocketConnected ? '已连接' : '未连接',
|
||||
),
|
||||
],
|
||||
const SizedBox(height: AppSpacing.lg),
|
||||
Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(AppSpacing.md),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('关于信息',
|
||||
style: Theme.of(context).textTheme.titleMedium),
|
||||
const SizedBox(height: AppSpacing.md),
|
||||
_InfoRow(
|
||||
label: '设备名称',
|
||||
value: status.deviceName ?? 'ShowenV2'),
|
||||
_InfoRow(
|
||||
label: '连接方式',
|
||||
value: status.connectionType.toUpperCase()),
|
||||
_InfoRow(
|
||||
label: '设备地址',
|
||||
value: status.ipAddress ?? provider.deviceIp),
|
||||
_InfoRow(
|
||||
label: '实时通道',
|
||||
value: provider.webSocketConnected ? '已连接' : '未连接',
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _loadData() async {
|
||||
final service = context.read<DeviceProvider>().httpApiService;
|
||||
final service = _getHttpApiService();
|
||||
setState(() => _loading = true);
|
||||
try {
|
||||
_videosFuture = service.getVideos();
|
||||
final results = await Future.wait<dynamic>([
|
||||
service.getConfig(),
|
||||
service.getAvailableConfigs(),
|
||||
]);
|
||||
_fullConfig = Map<String, dynamic>.from(results[0] as Map<String, dynamic>);
|
||||
final available = Map<String, dynamic>.from(results[1] as Map<String, dynamic>);
|
||||
_availableConfigs = (available['configs'] as List<dynamic>? ?? const <dynamic>[])
|
||||
.map((item) => item.toString())
|
||||
.toList(growable: false);
|
||||
_fullConfig =
|
||||
Map<String, dynamic>.from(results[0] as Map<String, dynamic>);
|
||||
final available =
|
||||
Map<String, dynamic>.from(results[1] as Map<String, dynamic>);
|
||||
_availableConfigs =
|
||||
(available['configs'] as List<dynamic>? ?? const <dynamic>[])
|
||||
.map((item) => item.toString())
|
||||
.toList(growable: false);
|
||||
_activeConfig = available['active']?.toString();
|
||||
_applyDisplayConfig(Map<String, dynamic>.from(_fullConfig?['display'] as Map? ?? const <String, dynamic>{}));
|
||||
_applyDisplayConfig(Map<String, dynamic>.from(
|
||||
_fullConfig?['display'] as Map? ?? const <String, dynamic>{}));
|
||||
_syncJsonControllerFromConfig();
|
||||
} finally {
|
||||
if (mounted) {
|
||||
setState(() => _loading = false);
|
||||
@@ -250,13 +479,132 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _handleSwitchDevice() async {
|
||||
final input = _ipController.text.trim();
|
||||
if (input.isEmpty) {
|
||||
return;
|
||||
}
|
||||
|
||||
final provider = context.read<DeviceProvider>();
|
||||
try {
|
||||
await provider.switchDevice(input);
|
||||
} catch (error) {
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
_showSnackBar(error.toString(), isError: true);
|
||||
return;
|
||||
}
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
await _loadData();
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
_showSnackBar('设备切换成功');
|
||||
}
|
||||
|
||||
Future<void> _showDeviceListDialog(DeviceProvider provider) async {
|
||||
await showDialog<void>(
|
||||
context: context,
|
||||
builder: (dialogContext) {
|
||||
return AlertDialog(
|
||||
title: const Text('历史设备'),
|
||||
content: SizedBox(
|
||||
width: double.maxFinite,
|
||||
child: provider.deviceList.isEmpty
|
||||
? const Center(child: Text('暂无历史设备'))
|
||||
: ListView.separated(
|
||||
shrinkWrap: true,
|
||||
itemCount: provider.deviceList.length,
|
||||
separatorBuilder: (_, __) =>
|
||||
const SizedBox(height: AppSpacing.sm),
|
||||
itemBuilder: (context, index) {
|
||||
final device = provider.deviceList[index];
|
||||
return Dismissible(
|
||||
key: ValueKey(device.address),
|
||||
direction: DismissDirection.endToStart,
|
||||
background: Container(
|
||||
alignment: Alignment.centerRight,
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: AppSpacing.md),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.errorContainer,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: Icon(
|
||||
Icons.delete_outline,
|
||||
color:
|
||||
Theme.of(context).colorScheme.onErrorContainer,
|
||||
),
|
||||
),
|
||||
onDismissed: (_) async {
|
||||
final deviceProvider = context.read<DeviceProvider>();
|
||||
final navigator = Navigator.of(dialogContext);
|
||||
|
||||
await deviceProvider.removeStoredDevice(device);
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
navigator.pop();
|
||||
await _showDeviceListDialog(deviceProvider);
|
||||
},
|
||||
child: Card(
|
||||
margin: EdgeInsets.zero,
|
||||
child: ListTile(
|
||||
title: Text(device.name),
|
||||
subtitle: Text(device.address),
|
||||
trailing: provider.deviceIp == device.ip &&
|
||||
provider.devicePort == device.port
|
||||
? const Icon(Icons.check_circle_outline)
|
||||
: null,
|
||||
onTap: () async {
|
||||
Navigator.of(dialogContext).pop();
|
||||
try {
|
||||
await context
|
||||
.read<DeviceProvider>()
|
||||
.switchDevice(
|
||||
device.address,
|
||||
name: device.name,
|
||||
);
|
||||
} catch (error) {
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
_showSnackBar(error.toString(), isError: true);
|
||||
return;
|
||||
}
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
await _loadData();
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
_showSnackBar('设备切换成功');
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _handleSwitchConfig() async {
|
||||
final activeConfig = _activeConfig;
|
||||
if (activeConfig == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
await context.read<DeviceProvider>().httpApiService.switchConfig(activeConfig);
|
||||
await context
|
||||
.read<DeviceProvider>()
|
||||
.httpApiService
|
||||
.switchConfig(activeConfig);
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
@@ -271,7 +619,8 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
|
||||
final nextConfig = Map<String, dynamic>.from(config);
|
||||
nextConfig['display'] = <String, dynamic>{
|
||||
...Map<String, dynamic>.from(config['display'] as Map? ?? const <String, dynamic>{}),
|
||||
...Map<String, dynamic>.from(
|
||||
config['display'] as Map? ?? const <String, dynamic>{}),
|
||||
'fullscreen': _isFullscreen,
|
||||
'window_title': _titleController.text.trim(),
|
||||
'rotation': int.tryParse(_rotationController.text.trim()) ?? 0,
|
||||
@@ -282,18 +631,119 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
'hsv_max': _parseIntList(_hsvMaxController.text),
|
||||
},
|
||||
'perspective_correction': <String, dynamic>{
|
||||
'points': jsonDecode(_pointsController.text.trim().isEmpty ? '[]' : _pointsController.text.trim()),
|
||||
'points': jsonDecode(_pointsController.text.trim().isEmpty
|
||||
? '[]'
|
||||
: _pointsController.text.trim()),
|
||||
},
|
||||
};
|
||||
|
||||
await context.read<DeviceProvider>().httpApiService.updateConfig(nextConfig);
|
||||
await context
|
||||
.read<DeviceProvider>()
|
||||
.httpApiService
|
||||
.updateConfig(nextConfig);
|
||||
_fullConfig = nextConfig;
|
||||
_syncJsonControllerFromConfig();
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
_showSnackBar('显示设置已保存');
|
||||
}
|
||||
|
||||
Future<void> _handleSaveJsonConfig() async {
|
||||
final rawJson = _jsonController.text.trim();
|
||||
if (rawJson.isEmpty) {
|
||||
_showSnackBar('JSON 内容不能为空', isError: true);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
final decoded = jsonDecode(rawJson);
|
||||
if (decoded is! Map) {
|
||||
throw const FormatException('根节点必须是 JSON 对象');
|
||||
}
|
||||
|
||||
final nextConfig = Map<String, dynamic>.from(decoded);
|
||||
await context
|
||||
.read<DeviceProvider>()
|
||||
.httpApiService
|
||||
.updateConfig(nextConfig);
|
||||
_fullConfig = nextConfig;
|
||||
_applyDisplayConfig(Map<String, dynamic>.from(
|
||||
nextConfig['display'] as Map? ?? const <String, dynamic>{}));
|
||||
_syncJsonControllerFromConfig();
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
_showSnackBar('JSON 配置已保存');
|
||||
} on FormatException catch (error) {
|
||||
_showSnackBar('JSON 解析失败: ${error.message}', isError: true);
|
||||
} catch (error) {
|
||||
_showSnackBar(error.toString(), isError: true);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _handleCopyJson() async {
|
||||
final config = _fullConfig;
|
||||
if (config == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
await Clipboard.setData(ClipboardData(
|
||||
text: const JsonEncoder.withIndent(' ').convert(config)));
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
_showSnackBar('配置 JSON 已复制到剪贴板');
|
||||
}
|
||||
|
||||
Future<void> _confirmDeleteVideo(
|
||||
HttpApiService httpApiService, VideoItem video) async {
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) {
|
||||
return AlertDialog(
|
||||
title: const Text('确认删除'),
|
||||
content: Text('确定删除视频 `${video.name}` 吗?'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(false),
|
||||
child: const Text('取消'),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.of(context).pop(true),
|
||||
child: const Text('删除'),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
if (confirmed != true) {
|
||||
return;
|
||||
}
|
||||
|
||||
await httpApiService.deleteVideo(video.name);
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('显示设置已保存')),
|
||||
SnackBar(content: Text('已删除 ${video.name}')),
|
||||
);
|
||||
_reloadVideos();
|
||||
}
|
||||
|
||||
Future<void> _handleUploadVideo() async {
|
||||
_showSnackBar('视频上传功能即将推出,请通过 Web UI 上传');
|
||||
}
|
||||
|
||||
void _reloadVideos() {
|
||||
setState(() {
|
||||
_videosFuture = _getHttpApiService().getVideos();
|
||||
});
|
||||
}
|
||||
|
||||
HttpApiService _getHttpApiService() {
|
||||
return context.read<DeviceProvider>().httpApiService;
|
||||
}
|
||||
|
||||
void _applyDisplayConfig(Map<String, dynamic> display) {
|
||||
@@ -302,11 +752,43 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
_rotationController.text = '${display['rotation'] ?? 0}';
|
||||
_widthController.text = '${display['render_width'] ?? 1024}';
|
||||
_heightController.text = '${display['render_height'] ?? 1024}';
|
||||
final chromaKey = Map<String, dynamic>.from(display['chroma_key'] as Map? ?? const <String, dynamic>{});
|
||||
_hsvMinController.text = (chromaKey['hsv_min'] as List<dynamic>? ?? const <dynamic>[0, 0, 200]).join(',');
|
||||
_hsvMaxController.text = (chromaKey['hsv_max'] as List<dynamic>? ?? const <dynamic>[180, 30, 255]).join(',');
|
||||
final perspective = Map<String, dynamic>.from(display['perspective_correction'] as Map? ?? const <String, dynamic>{});
|
||||
_pointsController.text = jsonEncode(perspective['points'] ?? const <dynamic>[]);
|
||||
final chromaKey = Map<String, dynamic>.from(
|
||||
display['chroma_key'] as Map? ?? const <String, dynamic>{});
|
||||
_hsvMinController.text =
|
||||
(chromaKey['hsv_min'] as List<dynamic>? ?? const <dynamic>[0, 0, 200])
|
||||
.join(',');
|
||||
_hsvMaxController.text = (chromaKey['hsv_max'] as List<dynamic>? ??
|
||||
const <dynamic>[180, 30, 255])
|
||||
.join(',');
|
||||
final perspective = Map<String, dynamic>.from(
|
||||
display['perspective_correction'] as Map? ?? const <String, dynamic>{});
|
||||
_pointsController.text =
|
||||
jsonEncode(perspective['points'] ?? const <dynamic>[]);
|
||||
}
|
||||
|
||||
void _syncJsonControllerFromConfig() {
|
||||
final config = _fullConfig;
|
||||
if (config == null) {
|
||||
_jsonController.clear();
|
||||
return;
|
||||
}
|
||||
_jsonController.text = const JsonEncoder.withIndent(' ').convert(config);
|
||||
}
|
||||
|
||||
void _showSnackBar(String message, {bool isError = false}) {
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
|
||||
final messenger = ScaffoldMessenger.of(context);
|
||||
messenger
|
||||
..hideCurrentSnackBar()
|
||||
..showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(message),
|
||||
backgroundColor: isError ? Theme.of(context).colorScheme.error : null,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
List<int> _parseIntList(String raw) {
|
||||
@@ -329,7 +811,9 @@ class _InfoRow extends StatelessWidget {
|
||||
padding: const EdgeInsets.only(bottom: AppSpacing.sm),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(child: Text(label, style: Theme.of(context).textTheme.bodyMedium)),
|
||||
Expanded(
|
||||
child:
|
||||
Text(label, style: Theme.of(context).textTheme.bodyMedium)),
|
||||
Text(value, style: Theme.of(context).textTheme.bodyLarge),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -37,9 +37,12 @@ class _TriggerScreenState extends State<TriggerScreen> {
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('状态机触发')),
|
||||
body: ListView(
|
||||
padding: const EdgeInsets.all(AppSpacing.md),
|
||||
children: [
|
||||
body: RefreshIndicator(
|
||||
onRefresh: _handleRefresh,
|
||||
child: ListView(
|
||||
physics: const AlwaysScrollableScrollPhysics(),
|
||||
padding: const EdgeInsets.all(AppSpacing.md),
|
||||
children: [
|
||||
Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(AppSpacing.md),
|
||||
@@ -159,11 +162,16 @@ class _TriggerScreenState extends State<TriggerScreen> {
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _handleRefresh() {
|
||||
return context.read<PlayerProvider>().bootstrap();
|
||||
}
|
||||
|
||||
void _handleCustomTrigger() {
|
||||
final name = _triggerController.text.trim();
|
||||
final value = _valueController.text.trim();
|
||||
|
||||
@@ -151,6 +151,29 @@ class BleService {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> sendCommand(String command) async {
|
||||
final characteristic = _commandCharacteristic;
|
||||
if (_connectedDevice == null) {
|
||||
throw StateError('未连接 BLE 设备');
|
||||
}
|
||||
if (characteristic == null) {
|
||||
throw StateError('未发现 BLE 命令特征值');
|
||||
}
|
||||
|
||||
await characteristic.write(
|
||||
utf8.encode(command),
|
||||
withoutResponse: characteristic.properties.writeWithoutResponse,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> play() => sendCommand('play');
|
||||
|
||||
Future<void> pause() => sendCommand('pause');
|
||||
|
||||
Future<void> next() => sendCommand('next');
|
||||
|
||||
Future<void> previous() => sendCommand('prev');
|
||||
|
||||
Future<void> disconnect() async {
|
||||
await _scanSubscription?.cancel();
|
||||
_scanSubscription = null;
|
||||
|
||||
153
clients/flutter/lib/services/device_storage_service.dart
Normal file
153
clients/flutter/lib/services/device_storage_service.dart
Normal file
@@ -0,0 +1,153 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
class SavedDevice {
|
||||
const SavedDevice({
|
||||
required this.ip,
|
||||
required this.port,
|
||||
required this.name,
|
||||
required this.lastUsedAt,
|
||||
});
|
||||
|
||||
final String ip;
|
||||
final int port;
|
||||
final String name;
|
||||
final DateTime lastUsedAt;
|
||||
|
||||
String get address => '$ip:$port';
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return <String, dynamic>{
|
||||
'ip': ip,
|
||||
'port': port,
|
||||
'name': name,
|
||||
'lastUsedAt': lastUsedAt.toIso8601String(),
|
||||
};
|
||||
}
|
||||
|
||||
factory SavedDevice.fromJson(Map<String, dynamic> json) {
|
||||
return SavedDevice(
|
||||
ip: _normalizeIp(json['ip']?.toString()),
|
||||
port: _normalizePort(json['port']),
|
||||
name: _normalizeName(json['name']?.toString()),
|
||||
lastUsedAt: DateTime.tryParse(json['lastUsedAt']?.toString() ?? '') ??
|
||||
DateTime.fromMillisecondsSinceEpoch(0),
|
||||
);
|
||||
}
|
||||
|
||||
static String _normalizeIp(String? value) {
|
||||
final normalized = value?.trim() ?? '';
|
||||
return normalized.isEmpty ? '127.0.0.1' : normalized;
|
||||
}
|
||||
|
||||
static int _normalizePort(dynamic value) {
|
||||
final port = int.tryParse(value?.toString() ?? '');
|
||||
if (port == null || port <= 0 || port > 65535) {
|
||||
return 5000;
|
||||
}
|
||||
return port;
|
||||
}
|
||||
|
||||
static String _normalizeName(String? value) {
|
||||
final normalized = value?.trim() ?? '';
|
||||
return normalized.isEmpty ? 'Showen' : normalized;
|
||||
}
|
||||
}
|
||||
|
||||
class DeviceStorageService {
|
||||
static const String _devicesKey = 'showen_device_list';
|
||||
static const int _maxDevices = 10;
|
||||
|
||||
SharedPreferences? _preferences;
|
||||
|
||||
Future<void> saveDevice(String ip, int port, String? name) async {
|
||||
final prefs = await _getPreferences();
|
||||
final devices = await getDevices();
|
||||
final normalizedIp = _normalizeIp(ip);
|
||||
final normalizedPort = _normalizePort(port);
|
||||
final normalizedName = _normalizeName(name);
|
||||
final now = DateTime.now();
|
||||
|
||||
final nextDevices = <SavedDevice>[
|
||||
SavedDevice(
|
||||
ip: normalizedIp,
|
||||
port: normalizedPort,
|
||||
name: normalizedName,
|
||||
lastUsedAt: now,
|
||||
),
|
||||
...devices.where(
|
||||
(device) => !(device.ip == normalizedIp && device.port == normalizedPort),
|
||||
),
|
||||
]
|
||||
..sort((a, b) => b.lastUsedAt.compareTo(a.lastUsedAt));
|
||||
|
||||
await prefs.setString(
|
||||
_devicesKey,
|
||||
jsonEncode(
|
||||
nextDevices
|
||||
.take(_maxDevices)
|
||||
.map((device) => device.toJson())
|
||||
.toList(growable: false),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<List<SavedDevice>> getDevices() async {
|
||||
final prefs = await _getPreferences();
|
||||
final raw = prefs.getString(_devicesKey);
|
||||
if (raw == null || raw.isEmpty) {
|
||||
return const <SavedDevice>[];
|
||||
}
|
||||
|
||||
try {
|
||||
final decoded = jsonDecode(raw);
|
||||
if (decoded is! List) {
|
||||
return const <SavedDevice>[];
|
||||
}
|
||||
|
||||
final devices = decoded
|
||||
.whereType<Map>()
|
||||
.map((item) => SavedDevice.fromJson(Map<String, dynamic>.from(item)))
|
||||
.toList(growable: false)
|
||||
..sort((a, b) => b.lastUsedAt.compareTo(a.lastUsedAt));
|
||||
|
||||
return devices.take(_maxDevices).toList(growable: false);
|
||||
} on FormatException {
|
||||
return const <SavedDevice>[];
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> removeDevice(String ip, [int? port]) async {
|
||||
final prefs = await _getPreferences();
|
||||
final normalizedIp = _normalizeIp(ip);
|
||||
final nextDevices = (await getDevices())
|
||||
.where(
|
||||
(device) =>
|
||||
device.ip != normalizedIp ||
|
||||
(port != null && device.port != _normalizePort(port)),
|
||||
)
|
||||
.map((device) => device.toJson())
|
||||
.toList(growable: false);
|
||||
|
||||
await prefs.setString(_devicesKey, jsonEncode(nextDevices));
|
||||
}
|
||||
|
||||
Future<SavedDevice?> getLastDevice() async {
|
||||
final devices = await getDevices();
|
||||
if (devices.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
return devices.first;
|
||||
}
|
||||
|
||||
Future<SharedPreferences> _getPreferences() async {
|
||||
return _preferences ??= await SharedPreferences.getInstance();
|
||||
}
|
||||
|
||||
String _normalizeIp(String value) => SavedDevice._normalizeIp(value);
|
||||
|
||||
int _normalizePort(dynamic value) => SavedDevice._normalizePort(value);
|
||||
|
||||
String _normalizeName(String? value) => SavedDevice._normalizeName(value);
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'dart:typed_data';
|
||||
@@ -11,9 +12,11 @@ import '../models/video_item.dart';
|
||||
import '../models/wifi_network.dart';
|
||||
import '../models/wifi_status.dart';
|
||||
|
||||
typedef UploadProgressCallback = void Function(double progress);
|
||||
|
||||
class HttpApiService {
|
||||
HttpApiService({required String baseUrl, http.Client? client})
|
||||
: _baseUrl = _normalizeBaseUrl(baseUrl),
|
||||
: _baseUrl = normalizeBaseUrl(baseUrl),
|
||||
_client = client ?? http.Client();
|
||||
|
||||
final http.Client _client;
|
||||
@@ -22,7 +25,7 @@ class HttpApiService {
|
||||
String get baseUrl => _baseUrl;
|
||||
|
||||
set baseUrl(String value) {
|
||||
_baseUrl = _normalizeBaseUrl(value);
|
||||
_baseUrl = normalizeBaseUrl(value);
|
||||
}
|
||||
|
||||
Uri _uri(String path, [Map<String, String>? queryParameters]) {
|
||||
@@ -229,6 +232,17 @@ class HttpApiService {
|
||||
return _uploadSingleFile('/api/videos/upload', file);
|
||||
}
|
||||
|
||||
Future<ApiResponse> uploadVideoWithProgress(
|
||||
File file, {
|
||||
UploadProgressCallback? onProgress,
|
||||
}) {
|
||||
return _uploadSingleFile(
|
||||
'/api/videos/upload',
|
||||
file,
|
||||
onProgress: onProgress,
|
||||
);
|
||||
}
|
||||
|
||||
Future<ApiResponse> uploadVideos(List<String> filePaths) async {
|
||||
if (filePaths.isEmpty) {
|
||||
throw const ApiException('未选择上传文件');
|
||||
@@ -377,17 +391,63 @@ class HttpApiService {
|
||||
String endpoint,
|
||||
File file, {
|
||||
String? directoryPath,
|
||||
UploadProgressCallback? onProgress,
|
||||
}) async {
|
||||
final request = http.MultipartRequest(
|
||||
'POST',
|
||||
_uri(endpoint, _pathQuery(directoryPath)),
|
||||
);
|
||||
request.files.add(await http.MultipartFile.fromPath('file', file.path));
|
||||
request.files.add(
|
||||
await _createMultipartFile(
|
||||
file,
|
||||
fieldName: 'file',
|
||||
onProgress: onProgress,
|
||||
),
|
||||
);
|
||||
final response = await http.Response.fromStream(await request.send());
|
||||
onProgress?.call(1);
|
||||
_ensureSuccess(response);
|
||||
return ApiResponse.fromJson(_decodeMap(response.body));
|
||||
}
|
||||
|
||||
Future<http.MultipartFile> _createMultipartFile(
|
||||
File file, {
|
||||
required String fieldName,
|
||||
UploadProgressCallback? onProgress,
|
||||
}) async {
|
||||
final length = await file.length();
|
||||
final filename = file.uri.pathSegments.isNotEmpty
|
||||
? file.uri.pathSegments.last
|
||||
: file.path;
|
||||
|
||||
if (onProgress == null) {
|
||||
return http.MultipartFile.fromPath(fieldName, file.path, filename: filename);
|
||||
}
|
||||
|
||||
var uploaded = 0;
|
||||
final stream = http.ByteStream(
|
||||
file.openRead().transform(
|
||||
StreamTransformer<List<int>, List<int>>.fromHandlers(
|
||||
handleData: (chunk, sink) {
|
||||
uploaded += chunk.length;
|
||||
if (length > 0) {
|
||||
final progress = (uploaded / length).clamp(0.0, 1.0);
|
||||
onProgress(progress);
|
||||
}
|
||||
sink.add(chunk);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
return http.MultipartFile(
|
||||
fieldName,
|
||||
stream,
|
||||
length,
|
||||
filename: filename,
|
||||
);
|
||||
}
|
||||
|
||||
Future<ApiResponse> _postCommand(String path) async {
|
||||
final response = await _client.post(_uri(path));
|
||||
_ensureSuccess(response);
|
||||
@@ -477,7 +537,7 @@ class HttpApiService {
|
||||
_client.close();
|
||||
}
|
||||
|
||||
static String _normalizeBaseUrl(String raw) {
|
||||
static String normalizeBaseUrl(String raw) {
|
||||
final trimmed = raw.trim();
|
||||
if (trimmed.isEmpty) {
|
||||
throw const ApiException('baseUrl 不能为空');
|
||||
|
||||
@@ -5,15 +5,24 @@ import 'package:web_socket_channel/web_socket_channel.dart';
|
||||
|
||||
import '../models/app_event.dart';
|
||||
|
||||
enum WsConnectionState { connected, connecting, disconnected }
|
||||
|
||||
@Deprecated('Use WsConnectionState instead')
|
||||
enum SocketConnectionStatus { disconnected, connecting, connected }
|
||||
|
||||
class WebSocketService {
|
||||
static const Duration _initialReconnectDelay = Duration(seconds: 2);
|
||||
static const Duration _maxReconnectDelay = Duration(seconds: 60);
|
||||
|
||||
WebSocketChannel? _channel;
|
||||
StreamSubscription<dynamic>? _subscription;
|
||||
Timer? _reconnectTimer;
|
||||
String? _deviceIp;
|
||||
int _devicePort = 5000;
|
||||
bool _manualDisconnect = false;
|
||||
SocketConnectionStatus _connectionStatus = SocketConnectionStatus.disconnected;
|
||||
WsConnectionState _connectionState = WsConnectionState.disconnected;
|
||||
int _retryCount = 0;
|
||||
Duration _nextReconnectDelay = _initialReconnectDelay;
|
||||
|
||||
final StreamController<AppEvent> _eventController =
|
||||
StreamController<AppEvent>.broadcast();
|
||||
@@ -27,8 +36,8 @@ class WebSocketService {
|
||||
StreamController<Map<String, dynamic>>.broadcast();
|
||||
final StreamController<Map<String, dynamic>> _bleController =
|
||||
StreamController<Map<String, dynamic>>.broadcast();
|
||||
final StreamController<SocketConnectionStatus> _connectionController =
|
||||
StreamController<SocketConnectionStatus>.broadcast();
|
||||
final StreamController<WsConnectionState> _connectionStateController =
|
||||
StreamController<WsConnectionState>.broadcast();
|
||||
|
||||
Stream<AppEvent> get events => _eventController.stream;
|
||||
Stream<Map<String, dynamic>> get onStatusUpdate => _statusController.stream;
|
||||
@@ -36,32 +45,76 @@ class WebSocketService {
|
||||
Stream<Map<String, dynamic>> get onConfigUpdate => _configController.stream;
|
||||
Stream<Map<String, dynamic>> get onWifiUpdate => _wifiController.stream;
|
||||
Stream<Map<String, dynamic>> get onBleUpdate => _bleController.stream;
|
||||
Stream<SocketConnectionStatus> get connectionState =>
|
||||
_connectionStateController.stream.map(_toLegacyConnectionStatus);
|
||||
Stream<WsConnectionState> get connectionStateStream =>
|
||||
_connectionStateController.stream;
|
||||
Stream<SocketConnectionStatus> get onConnectionChanged =>
|
||||
_connectionController.stream;
|
||||
_connectionStateController.stream.map(_toLegacyConnectionStatus);
|
||||
|
||||
SocketConnectionStatus get connectionStatus => _connectionStatus;
|
||||
bool get isConnected => _connectionStatus == SocketConnectionStatus.connected;
|
||||
WsConnectionState get wsConnectionState => _connectionState;
|
||||
SocketConnectionStatus get connectionStatus =>
|
||||
_toLegacyConnectionStatus(_connectionState);
|
||||
bool get isConnected => _connectionState == WsConnectionState.connected;
|
||||
int get retryCount => _retryCount;
|
||||
|
||||
Future<void> connect(String deviceIp) async {
|
||||
Future<void> connect(String deviceIp, {int port = 5000}) async {
|
||||
_manualDisconnect = false;
|
||||
_deviceIp = _normalizeDeviceIp(deviceIp);
|
||||
_devicePort = _normalizePort(port);
|
||||
_reconnectTimer?.cancel();
|
||||
|
||||
await _establishConnection(resetBackoff: true);
|
||||
}
|
||||
|
||||
Future<void> manualReconnect() async {
|
||||
final deviceIp = _deviceIp;
|
||||
if (deviceIp == null || deviceIp.isEmpty) {
|
||||
return;
|
||||
}
|
||||
|
||||
_manualDisconnect = false;
|
||||
_reconnectTimer?.cancel();
|
||||
await _establishConnection(resetBackoff: true);
|
||||
}
|
||||
|
||||
Future<void> _establishConnection({bool resetBackoff = false}) async {
|
||||
if (resetBackoff) {
|
||||
_retryCount = 0;
|
||||
_nextReconnectDelay = _initialReconnectDelay;
|
||||
}
|
||||
|
||||
await _subscription?.cancel();
|
||||
_subscription = null;
|
||||
await _channel?.sink.close();
|
||||
_channel = null;
|
||||
|
||||
_setConnectionStatus(SocketConnectionStatus.connecting);
|
||||
_setConnectionState(WsConnectionState.connecting);
|
||||
|
||||
final url = Uri.parse('ws://$_deviceIp:8080/ws');
|
||||
_channel = WebSocketChannel.connect(url);
|
||||
_subscription = _channel!.stream.listen(
|
||||
_handleMessage,
|
||||
onDone: _handleSocketClosed,
|
||||
onError: (_) => _handleSocketClosed(),
|
||||
cancelOnError: true,
|
||||
);
|
||||
try {
|
||||
final url = Uri.parse('ws://$_deviceIp:$_devicePort/ws');
|
||||
_channel = WebSocketChannel.connect(url);
|
||||
await _channel!.ready;
|
||||
_subscription = _channel!.stream.listen(
|
||||
_handleMessage,
|
||||
onDone: _handleSocketClosed,
|
||||
onError: (_) => _handleSocketClosed(),
|
||||
cancelOnError: true,
|
||||
);
|
||||
|
||||
_setConnectionStatus(SocketConnectionStatus.connected);
|
||||
_retryCount = 0;
|
||||
_nextReconnectDelay = _initialReconnectDelay;
|
||||
_setConnectionState(WsConnectionState.connected);
|
||||
} catch (_) {
|
||||
_channel = null;
|
||||
_subscription = null;
|
||||
if (_manualDisconnect) {
|
||||
_setConnectionState(WsConnectionState.disconnected);
|
||||
return;
|
||||
}
|
||||
|
||||
await reconnect();
|
||||
}
|
||||
}
|
||||
|
||||
void sendCommand(Map<String, dynamic> command) {
|
||||
@@ -77,20 +130,38 @@ class WebSocketService {
|
||||
return;
|
||||
}
|
||||
|
||||
_setConnectionState(WsConnectionState.connecting);
|
||||
_scheduleReconnect();
|
||||
}
|
||||
|
||||
void _scheduleReconnect() {
|
||||
if (_manualDisconnect) {
|
||||
return;
|
||||
}
|
||||
|
||||
_reconnectTimer?.cancel();
|
||||
_reconnectTimer = Timer(const Duration(seconds: 2), () {
|
||||
unawaited(connect(deviceIp));
|
||||
final delay = _nextReconnectDelay;
|
||||
_retryCount += 1;
|
||||
_nextReconnectDelay = _nextReconnectDelay * 2;
|
||||
if (_nextReconnectDelay > _maxReconnectDelay) {
|
||||
_nextReconnectDelay = _maxReconnectDelay;
|
||||
}
|
||||
|
||||
_reconnectTimer = Timer(delay, () {
|
||||
unawaited(_establishConnection());
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> disconnect() async {
|
||||
_manualDisconnect = true;
|
||||
_reconnectTimer?.cancel();
|
||||
_retryCount = 0;
|
||||
_nextReconnectDelay = _initialReconnectDelay;
|
||||
await _subscription?.cancel();
|
||||
_subscription = null;
|
||||
await _channel?.sink.close();
|
||||
_channel = null;
|
||||
_setConnectionStatus(SocketConnectionStatus.disconnected);
|
||||
_setConnectionState(WsConnectionState.disconnected);
|
||||
}
|
||||
|
||||
Future<void> dispose() async {
|
||||
@@ -101,7 +172,7 @@ class WebSocketService {
|
||||
await _configController.close();
|
||||
await _wifiController.close();
|
||||
await _bleController.close();
|
||||
await _connectionController.close();
|
||||
await _connectionStateController.close();
|
||||
}
|
||||
|
||||
void _handleMessage(dynamic data) {
|
||||
@@ -136,13 +207,30 @@ class WebSocketService {
|
||||
void _handleSocketClosed() {
|
||||
_channel = null;
|
||||
_subscription = null;
|
||||
_setConnectionStatus(SocketConnectionStatus.disconnected);
|
||||
if (_manualDisconnect) {
|
||||
_retryCount = 0;
|
||||
_nextReconnectDelay = _initialReconnectDelay;
|
||||
_setConnectionState(WsConnectionState.disconnected);
|
||||
return;
|
||||
}
|
||||
|
||||
unawaited(reconnect());
|
||||
}
|
||||
|
||||
void _setConnectionStatus(SocketConnectionStatus status) {
|
||||
_connectionStatus = status;
|
||||
_connectionController.add(status);
|
||||
void _setConnectionState(WsConnectionState state) {
|
||||
_connectionState = state;
|
||||
_connectionStateController.add(state);
|
||||
}
|
||||
|
||||
SocketConnectionStatus _toLegacyConnectionStatus(WsConnectionState state) {
|
||||
switch (state) {
|
||||
case WsConnectionState.connected:
|
||||
return SocketConnectionStatus.connected;
|
||||
case WsConnectionState.connecting:
|
||||
return SocketConnectionStatus.connecting;
|
||||
case WsConnectionState.disconnected:
|
||||
return SocketConnectionStatus.disconnected;
|
||||
}
|
||||
}
|
||||
|
||||
String _normalizeDeviceIp(String raw) {
|
||||
@@ -169,4 +257,11 @@ class WebSocketService {
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
int _normalizePort(int value) {
|
||||
if (value <= 0 || value > 65535) {
|
||||
return 5000;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
77
clients/flutter/lib/widgets/connection_status_banner.dart
Normal file
77
clients/flutter/lib/widgets/connection_status_banner.dart
Normal file
@@ -0,0 +1,77 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../services/web_socket_service.dart';
|
||||
|
||||
class ConnectionStatusBanner extends StatelessWidget {
|
||||
const ConnectionStatusBanner({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final webSocketService = context.read<WebSocketService>();
|
||||
|
||||
return StreamBuilder<SocketConnectionStatus>(
|
||||
stream: webSocketService.connectionState,
|
||||
initialData: webSocketService.connectionStatus,
|
||||
builder: (context, snapshot) {
|
||||
final connectionStatus =
|
||||
snapshot.data ?? SocketConnectionStatus.disconnected;
|
||||
final isVisible = connectionStatus != SocketConnectionStatus.connected;
|
||||
final isConnecting =
|
||||
connectionStatus == SocketConnectionStatus.connecting;
|
||||
|
||||
return AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 250),
|
||||
curve: Curves.easeInOut,
|
||||
height: isVisible ? 52 : 0,
|
||||
color:
|
||||
isConnecting ? Colors.amber.shade700 : Colors.redAccent.shade700,
|
||||
child: isVisible
|
||||
? SafeArea(
|
||||
bottom: false,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 10,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
isConnecting
|
||||
? '正在重连...(第${webSocketService.retryCount.clamp(1, 999)}次)'
|
||||
: '连接断开',
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.bodyMedium
|
||||
?.copyWith(
|
||||
color: Colors.black,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (!isConnecting)
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
webSocketService.manualReconnect();
|
||||
},
|
||||
style: TextButton.styleFrom(
|
||||
foregroundColor: Colors.white,
|
||||
backgroundColor: Colors.black26,
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 12,
|
||||
vertical: 8,
|
||||
),
|
||||
),
|
||||
child: const Text('重试'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
)
|
||||
: null,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -65,6 +65,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.0.7"
|
||||
cupertino_icons:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: cupertino_icons
|
||||
sha256: ba631d1c7f7bef6b729a622b7b752645a2d076dba9976925b8f25725a30e1ee6
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.8"
|
||||
dbus:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -89,6 +97,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.2.0"
|
||||
file:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: file
|
||||
sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "7.0.1"
|
||||
flutter:
|
||||
dependency: "direct main"
|
||||
description: flutter
|
||||
@@ -264,6 +280,30 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.9.1"
|
||||
path_provider_linux:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: path_provider_linux
|
||||
sha256: f7a1fe3a634fe7734c8d3f2766ad746ae2a2884abe22e241a8b301bf5cac3279
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.2.1"
|
||||
path_provider_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: path_provider_platform_interface
|
||||
sha256: "88f5779f72ba699763fa3a3b06aa4bf6de76c8e5de842cf6f29e2e06476c2334"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.2"
|
||||
path_provider_windows:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: path_provider_windows
|
||||
sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.3.0"
|
||||
petitparser:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -272,6 +312,22 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "7.0.2"
|
||||
platform:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: platform
|
||||
sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.1.6"
|
||||
plugin_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: plugin_platform_interface
|
||||
sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.8"
|
||||
provider:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -288,6 +344,62 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.28.0"
|
||||
shared_preferences:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: shared_preferences
|
||||
sha256: "2939ae520c9024cb197fc20dee269cd8cdbf564c8b5746374ec6cacdc5169e64"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.5.4"
|
||||
shared_preferences_android:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: shared_preferences_android
|
||||
sha256: "8374d6200ab33ac99031a852eba4c8eb2170c4bf20778b3e2c9eccb45384fb41"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.4.21"
|
||||
shared_preferences_foundation:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: shared_preferences_foundation
|
||||
sha256: "4e7eaffc2b17ba398759f1151415869a34771ba11ebbccd1b0145472a619a64f"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.5.6"
|
||||
shared_preferences_linux:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: shared_preferences_linux
|
||||
sha256: "580abfd40f415611503cae30adf626e6656dfb2f0cee8f465ece7b6defb40f2f"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.4.1"
|
||||
shared_preferences_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: shared_preferences_platform_interface
|
||||
sha256: "57cbf196c486bc2cf1f02b85784932c6094376284b3ad5779d1b1c6c6a816b80"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.4.1"
|
||||
shared_preferences_web:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: shared_preferences_web
|
||||
sha256: c49bd060261c9a3f0ff445892695d6212ff603ef3115edbb448509d407600019
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.4.3"
|
||||
shared_preferences_windows:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: shared_preferences_windows
|
||||
sha256: "94ef0f72b2d71bc3e700e025db3710911bd51a71cefb65cc609dd0d9a982e3c1"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.4.1"
|
||||
sky_engine:
|
||||
dependency: transitive
|
||||
description: flutter
|
||||
@@ -389,6 +501,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.0.3"
|
||||
xdg_directories:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: xdg_directories
|
||||
sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.0"
|
||||
xml:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -398,5 +518,5 @@ packages:
|
||||
source: hosted
|
||||
version: "6.6.1"
|
||||
sdks:
|
||||
dart: ">=3.9.0-0 <4.0.0"
|
||||
flutter: ">=3.22.0"
|
||||
dart: ">=3.9.0 <4.0.0"
|
||||
flutter: ">=3.35.0"
|
||||
|
||||
@@ -10,11 +10,13 @@ environment:
|
||||
dependencies:
|
||||
flutter:
|
||||
sdk: flutter
|
||||
cupertino_icons: ^1.0.8
|
||||
flutter_blue_plus: ^1.35.3
|
||||
go_router: ^14.8.1
|
||||
http: ^1.2.1
|
||||
provider: ^6.1.2
|
||||
web_socket_channel: ^3.0.1
|
||||
shared_preferences: ^2.3.0
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
|
||||
183
clients/flutter/test/models/models_test.dart
Normal file
183
clients/flutter/test/models/models_test.dart
Normal file
@@ -0,0 +1,183 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:showen_v2_flutter/models/api_response.dart';
|
||||
import 'package:showen_v2_flutter/models/app_event.dart';
|
||||
import 'package:showen_v2_flutter/models/ble_models.dart';
|
||||
import 'package:showen_v2_flutter/models/ble_status.dart';
|
||||
import 'package:showen_v2_flutter/models/device_status.dart';
|
||||
import 'package:showen_v2_flutter/models/player_status.dart';
|
||||
import 'package:showen_v2_flutter/models/video_item.dart';
|
||||
import 'package:showen_v2_flutter/models/wifi_network.dart';
|
||||
import 'package:showen_v2_flutter/models/wifi_status.dart';
|
||||
|
||||
void main() {
|
||||
group('ApiResponse', () {
|
||||
test('fromJson and toJson round trip', () {
|
||||
final response = ApiResponse.fromJson(const <String, dynamic>{
|
||||
'status': 'ok',
|
||||
'message': 'done',
|
||||
});
|
||||
|
||||
expect(response.isOk, isTrue);
|
||||
expect(response.toJson(), <String, dynamic>{
|
||||
'status': 'ok',
|
||||
'message': 'done',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
group('AppEvent', () {
|
||||
test('fromJson prefers data payload map', () {
|
||||
final event = AppEvent.fromJson(const <String, dynamic>{
|
||||
'type': 'status',
|
||||
'data': <String, dynamic>{'connected': true},
|
||||
});
|
||||
|
||||
expect(event.type, 'status');
|
||||
expect(event.payload, <String, dynamic>{'connected': true});
|
||||
});
|
||||
|
||||
test('fromJson normalizes scalar payload', () {
|
||||
final event = AppEvent.fromJson(const <String, dynamic>{
|
||||
'type': 'progress',
|
||||
'payload': 42,
|
||||
});
|
||||
|
||||
expect(event.payload, <String, dynamic>{'value': 42});
|
||||
});
|
||||
});
|
||||
|
||||
group('Ble models', () {
|
||||
test('BleDevice stores constructor fields', () {
|
||||
const device = BleDevice(name: 'Showen', id: 'dev-1', rssi: -48);
|
||||
|
||||
expect(device.name, 'Showen');
|
||||
expect(device.id, 'dev-1');
|
||||
expect(device.rssi, -48);
|
||||
});
|
||||
|
||||
test('BleStatus parses json and raw json', () {
|
||||
final status = BleStatus.fromJson(const <String, dynamic>{
|
||||
'ok': true,
|
||||
'action': 'provision',
|
||||
'state': 'queued',
|
||||
});
|
||||
final raw = BleStatus.fromRawJson(
|
||||
'{"ok":false,"action":"scan","error":"failed"}',
|
||||
);
|
||||
|
||||
expect(status.isQueued, isTrue);
|
||||
expect(status.message, 'queued');
|
||||
expect(raw.isSuccess, isFalse);
|
||||
expect(raw.message, 'failed');
|
||||
});
|
||||
});
|
||||
|
||||
group('BleServiceStatus', () {
|
||||
test('initial and fromJson', () {
|
||||
final initial = BleServiceStatus.initial();
|
||||
final status = BleServiceStatus.fromJson(const <String, dynamic>{
|
||||
'running': true,
|
||||
'embedded': true,
|
||||
'device_name': 'Showen BLE',
|
||||
});
|
||||
|
||||
expect(initial.running, isFalse);
|
||||
expect(initial.embedded, isFalse);
|
||||
expect(status.running, isTrue);
|
||||
expect(status.embedded, isTrue);
|
||||
expect(status.deviceName, 'Showen BLE');
|
||||
});
|
||||
});
|
||||
|
||||
group('DeviceStatus', () {
|
||||
test('initial and copyWith preserve nested models', () {
|
||||
final updated = DeviceStatus.initial().copyWith(
|
||||
connected: true,
|
||||
connectionType: 'wifi',
|
||||
deviceName: 'Showen Box',
|
||||
ipAddress: '192.168.1.20',
|
||||
playerStatus: PlayerStatus.initial().copyWith(running: true),
|
||||
wifiStatus: WifiStatus.fromJson(
|
||||
const <String, dynamic>{'connected': true, 'ssid': 'Office'},
|
||||
),
|
||||
bleStatus: BleServiceStatus.fromJson(
|
||||
const <String, dynamic>{'running': true, 'embedded': false},
|
||||
),
|
||||
);
|
||||
|
||||
expect(updated.connected, isTrue);
|
||||
expect(updated.connectionType, 'wifi');
|
||||
expect(updated.deviceName, 'Showen Box');
|
||||
expect(updated.ipAddress, '192.168.1.20');
|
||||
expect(updated.playerStatus?.running, isTrue);
|
||||
expect(updated.wifiStatus?.ssid, 'Office');
|
||||
expect(updated.bleStatus?.running, isTrue);
|
||||
});
|
||||
});
|
||||
|
||||
group('PlayerStatus', () {
|
||||
test('fromJson and toJson round trip', () {
|
||||
final status = PlayerStatus.fromJson(const <String, dynamic>{
|
||||
'running': true,
|
||||
'paused': false,
|
||||
'in_transition': true,
|
||||
'current_index': 3,
|
||||
'playlist_length': 9,
|
||||
'current_video': 'intro.mp4',
|
||||
});
|
||||
|
||||
expect(status.toJson(), <String, dynamic>{
|
||||
'running': true,
|
||||
'paused': false,
|
||||
'in_transition': true,
|
||||
'current_index': 3,
|
||||
'playlist_length': 9,
|
||||
'current_video': 'intro.mp4',
|
||||
});
|
||||
expect(status.copyWith(paused: true).paused, isTrue);
|
||||
});
|
||||
});
|
||||
|
||||
group('VideoItem', () {
|
||||
test('fromJson parses file metadata', () {
|
||||
final video = VideoItem.fromJson(const <String, dynamic>{
|
||||
'name': 'demo.mp4',
|
||||
'size': 3145728,
|
||||
});
|
||||
|
||||
expect(video.name, 'demo.mp4');
|
||||
expect(video.size, 3145728);
|
||||
expect(video.sizeLabel, '3.0 MB');
|
||||
});
|
||||
});
|
||||
|
||||
group('WifiNetwork', () {
|
||||
test('fromJson parses network metadata', () {
|
||||
final network = WifiNetwork.fromJson(const <String, dynamic>{
|
||||
'ssid': 'ShowenLab',
|
||||
'signal': -51,
|
||||
'security': 'WPA2',
|
||||
});
|
||||
|
||||
expect(network.ssid, 'ShowenLab');
|
||||
expect(network.signalLabel, '-51 dBm');
|
||||
expect(network.security, 'WPA2');
|
||||
});
|
||||
});
|
||||
|
||||
group('WifiStatus', () {
|
||||
test('disconnected and fromJson', () {
|
||||
final disconnected = WifiStatus.disconnected();
|
||||
final status = WifiStatus.fromJson(const <String, dynamic>{
|
||||
'connected': true,
|
||||
'ssid': 'ShowenLab',
|
||||
'ip': '192.168.1.10',
|
||||
});
|
||||
|
||||
expect(disconnected.connected, isFalse);
|
||||
expect(status.connected, isTrue);
|
||||
expect(status.ssid, 'ShowenLab');
|
||||
expect(status.ip, '192.168.1.10');
|
||||
});
|
||||
});
|
||||
}
|
||||
43
clients/flutter/test/services/http_api_service_test.dart
Normal file
43
clients/flutter/test/services/http_api_service_test.dart
Normal file
@@ -0,0 +1,43 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:showen_v2_flutter/services/http_api_service.dart';
|
||||
|
||||
void main() {
|
||||
group('HttpApiService.normalizeBaseUrl', () {
|
||||
test('trims whitespace, adds scheme, and removes trailing slash', () {
|
||||
expect(
|
||||
HttpApiService.normalizeBaseUrl(' 192.168.1.8:5000/ '),
|
||||
'http://192.168.1.8:5000',
|
||||
);
|
||||
});
|
||||
|
||||
test('preserves explicit https scheme', () {
|
||||
expect(
|
||||
HttpApiService.normalizeBaseUrl('https://showen.local/'),
|
||||
'https://showen.local',
|
||||
);
|
||||
});
|
||||
|
||||
test('throws for empty baseUrl', () {
|
||||
expect(
|
||||
() => HttpApiService.normalizeBaseUrl(' '),
|
||||
throwsA(
|
||||
isA<ApiException>().having(
|
||||
(error) => error.message,
|
||||
'message',
|
||||
'baseUrl 不能为空',
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
group('ApiException', () {
|
||||
test('stores message and optional status code', () {
|
||||
const exception = ApiException('upload failed', statusCode: 413);
|
||||
|
||||
expect(exception.message, 'upload failed');
|
||||
expect(exception.statusCode, 413);
|
||||
expect(exception.toString(), 'upload failed');
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user