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:
showen
2026-03-14 18:12:42 +08:00
parent 8ed9cb2d9d
commit d30c111c71
68 changed files with 8115 additions and 1201 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -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 条上限
## 已知技术债

View File

@@ -0,0 +1,5 @@
include: package:flutter_lints/flutter.yaml
analyzer:
exclude:
- build/**

View File

@@ -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);
}
}
}

View 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.
```

View File

@@ -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(),
),
],
),
],
),
],

View File

@@ -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();
});
}

View 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();
}
}

View File

@@ -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());

View File

@@ -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);

View File

@@ -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);

View File

@@ -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: '调试',
),
],
),
);

View File

@@ -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();
});

View 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,
),
),
],
),
),
);
}
}

View File

@@ -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')),
);
}
}
}

View File

@@ -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) {

View File

@@ -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),
],
),

View File

@@ -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();

View File

@@ -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;

View 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);
}

View File

@@ -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 不能为空');

View File

@@ -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;
}
}

View 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,
);
},
);
}
}

View File

@@ -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"

View File

@@ -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:

View 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');
});
});
}

View 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');
});
});
}