feat: Flutter 客户端 App + Web UI APK 下载入口

- 新增 Flutter 跨平台客户端项目 (clients/flutter/)
  - 29 个 Dart 文件: 服务层/状态管理/5个页面/BLE配网
  - BLE 蓝牙配网: 扫描设备、写入WiFi凭据、配网状态监听
  - HTTP API 客户端: 覆盖全部端点 (播放/场景/WiFi/视频/配置/文件/插件)
  - WebSocket 实时通信: 事件流 + 自动重连
  - 暗色主题 Material 3 UI, 中文界面
  - Android 配置: minSdkVersion 21, BLE/网络权限
  - PRD 产品需求文档 + 开发任务看板
- Web UI 添加 APK 下载入口 (routes.rs)
  - 下载弹窗 + 二维码 + /download/{filename} 静态文件路由
- BLE 插件增加自动重连循环 (ble/mod.rs)
- BLE 默认设备名修正为 'Showen' (config.rs)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
showen
2026-03-14 02:09:52 +08:00
parent d4f0eb7eca
commit bff9ec535d
45 changed files with 5903 additions and 75 deletions

View File

@@ -0,0 +1,130 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:provider/provider.dart';
import 'providers/device_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/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/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');
final webSocketService = WebSocketService();
runApp(
MultiProvider(
providers: [
ChangeNotifierProvider<DeviceProvider>(
create: (_) => DeviceProvider(
httpApiService: httpApiService,
webSocketService: webSocketService,
)..initialize(),
),
ChangeNotifierProvider<PlayerProvider>(
create: (_) => PlayerProvider(
httpApiService: httpApiService,
webSocketService: webSocketService,
)
..bootstrap(),
),
ChangeNotifierProvider<WifiProvider>(
create: (_) => WifiProvider(
httpApiService: httpApiService,
webSocketService: webSocketService,
)
..bootstrap(),
),
],
child: const ShowenApp(),
),
);
}
final GoRouter _router = GoRouter(
initialLocation: '/',
routes: [
StatefulShellRoute.indexedStack(
builder: (context, state, navigationShell) =>
AppShell(navigationShell: navigationShell),
branches: [
StatefulShellBranch(
routes: [
GoRoute(
path: '/',
name: 'home',
builder: (context, state) => const HomeScreen(),
),
],
),
StatefulShellBranch(
routes: [
GoRoute(
path: '/playback',
name: 'playback',
builder: (context, state) => const PlaybackScreen(),
),
],
),
StatefulShellBranch(
routes: [
GoRoute(
path: '/trigger',
name: 'trigger',
builder: (context, state) => const TriggerScreen(),
),
],
),
StatefulShellBranch(
routes: [
GoRoute(
path: '/network',
name: 'network',
builder: (context, state) => const NetworkScreen(),
routes: [
GoRoute(
path: 'ble-provision',
name: 'ble-provision',
builder: (context, state) => const BleProvisionScreen(),
),
],
),
],
),
StatefulShellBranch(
routes: [
GoRoute(
path: '/settings',
name: 'settings',
builder: (context, state) => const SettingsScreen(),
),
],
),
],
),
],
);
class ShowenApp extends StatelessWidget {
const ShowenApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp.router(
title: 'ShowenV2',
debugShowCheckedModeBanner: false,
themeMode: ThemeMode.dark,
darkTheme: AppTheme.dark(),
theme: AppTheme.dark(),
routerConfig: _router,
);
}
}

View File

@@ -0,0 +1,22 @@
class ApiResponse {
const ApiResponse({required this.status, required this.message});
final String status;
final String message;
bool get isOk => status == 'ok';
factory ApiResponse.fromJson(Map<String, dynamic> json) {
return ApiResponse(
status: json['status'] as String? ?? 'error',
message: json['message'] as String? ?? '',
);
}
Map<String, dynamic> toJson() {
return <String, dynamic>{
'status': status,
'message': message,
};
}
}

View File

@@ -0,0 +1,24 @@
class AppEvent {
const AppEvent({required this.type, required this.payload});
final String type;
final Map<String, dynamic> payload;
factory AppEvent.fromJson(Map<String, dynamic> json) {
final dynamic rawPayload = json['data'] ?? json['payload'] ?? const <String, dynamic>{};
return AppEvent(
type: json['type'] as String? ?? 'unknown',
payload: _normalizePayload(rawPayload),
);
}
static Map<String, dynamic> _normalizePayload(dynamic payload) {
if (payload is Map<String, dynamic>) {
return payload;
}
if (payload is Map) {
return Map<String, dynamic>.from(payload);
}
return <String, dynamic>{'value': payload};
}
}

View File

@@ -0,0 +1,69 @@
import 'dart:convert';
class BleDevice {
const BleDevice({
required this.name,
required this.id,
required this.rssi,
});
final String name;
final String id;
final int rssi;
}
class BleStatus {
const BleStatus({
required this.ok,
required this.action,
this.state,
this.error,
});
factory BleStatus.fromJson(Map<String, dynamic> json) {
return BleStatus(
ok: json['ok'] == true,
action: (json['action'] ?? '').toString(),
state: json['state']?.toString(),
error: json['error']?.toString(),
);
}
factory BleStatus.fromRawJson(String source) {
final dynamic decoded = jsonDecode(source);
if (decoded is! Map<String, dynamic>) {
throw const FormatException('BLE status payload is not a JSON object');
}
return BleStatus.fromJson(decoded);
}
static const BleStatus idle = BleStatus(ok: true, action: 'idle');
final bool ok;
final String action;
final String? state;
final String? error;
bool get isQueued => state == 'queued';
bool get isSuccess => ok && !isQueued;
String get message {
if ((error ?? '').isNotEmpty) {
return error!;
}
if ((state ?? '').isNotEmpty) {
return state!;
}
return action;
}
}
enum ProvisioningState {
scanning,
connecting,
writingCredentials,
connectingWifi,
success,
failed,
}

View File

@@ -0,0 +1,23 @@
class BleServiceStatus {
const BleServiceStatus({
required this.running,
required this.embedded,
this.deviceName,
});
final bool running;
final bool embedded;
final String? deviceName;
factory BleServiceStatus.initial() {
return const BleServiceStatus(running: false, embedded: false);
}
factory BleServiceStatus.fromJson(Map<String, dynamic> json) {
return BleServiceStatus(
running: json['running'] as bool? ?? false,
embedded: json['embedded'] as bool? ?? false,
deviceName: json['device_name'] as String?,
);
}
}

View File

@@ -0,0 +1,54 @@
import 'ble_status.dart';
import 'player_status.dart';
import 'wifi_status.dart';
class DeviceStatus {
const DeviceStatus({
required this.connected,
required this.connectionType,
this.deviceName,
this.ipAddress,
this.playerStatus,
this.wifiStatus,
this.bleStatus,
this.updatedAt,
});
final bool connected;
final String connectionType;
final String? deviceName;
final String? ipAddress;
final PlayerStatus? playerStatus;
final WifiStatus? wifiStatus;
final BleServiceStatus? bleStatus;
final DateTime? updatedAt;
factory DeviceStatus.initial() {
return const DeviceStatus(
connected: false,
connectionType: 'offline',
);
}
DeviceStatus copyWith({
bool? connected,
String? connectionType,
String? deviceName,
String? ipAddress,
PlayerStatus? playerStatus,
WifiStatus? wifiStatus,
BleServiceStatus? bleStatus,
DateTime? updatedAt,
}) {
return DeviceStatus(
connected: connected ?? this.connected,
connectionType: connectionType ?? this.connectionType,
deviceName: deviceName ?? this.deviceName,
ipAddress: ipAddress ?? this.ipAddress,
playerStatus: playerStatus ?? this.playerStatus,
wifiStatus: wifiStatus ?? this.wifiStatus,
bleStatus: bleStatus ?? this.bleStatus,
updatedAt: updatedAt ?? this.updatedAt,
);
}
}

View File

@@ -0,0 +1,68 @@
class PlayerStatus {
const PlayerStatus({
required this.running,
required this.paused,
required this.inTransition,
required this.currentIndex,
required this.playlistLength,
this.currentVideo,
});
final bool running;
final bool paused;
final bool inTransition;
final int currentIndex;
final int playlistLength;
final String? currentVideo;
factory PlayerStatus.initial() {
return const PlayerStatus(
running: false,
paused: false,
inTransition: false,
currentIndex: 0,
playlistLength: 0,
currentVideo: null,
);
}
factory PlayerStatus.fromJson(Map<String, dynamic> json) {
return PlayerStatus(
running: json['running'] as bool? ?? false,
paused: json['paused'] as bool? ?? false,
inTransition: json['in_transition'] as bool? ?? false,
currentIndex: json['current_index'] as int? ?? 0,
playlistLength: json['playlist_length'] as int? ?? 0,
currentVideo: json['current_video'] as String?,
);
}
Map<String, dynamic> toJson() {
return <String, dynamic>{
'running': running,
'paused': paused,
'in_transition': inTransition,
'current_index': currentIndex,
'playlist_length': playlistLength,
'current_video': currentVideo,
};
}
PlayerStatus copyWith({
bool? running,
bool? paused,
bool? inTransition,
int? currentIndex,
int? playlistLength,
String? currentVideo,
}) {
return PlayerStatus(
running: running ?? this.running,
paused: paused ?? this.paused,
inTransition: inTransition ?? this.inTransition,
currentIndex: currentIndex ?? this.currentIndex,
playlistLength: playlistLength ?? this.playlistLength,
currentVideo: currentVideo ?? this.currentVideo,
);
}
}

View File

@@ -0,0 +1,23 @@
class VideoItem {
const VideoItem({required this.name, required this.size});
final String name;
final int size;
factory VideoItem.fromJson(Map<String, dynamic> json) {
return VideoItem(
name: json['name'] as String? ?? '',
size: json['size'] as int? ?? 0,
);
}
String get sizeLabel {
if (size >= 1024 * 1024) {
return '${(size / (1024 * 1024)).toStringAsFixed(1)} MB';
}
if (size >= 1024) {
return '${(size / 1024).toStringAsFixed(1)} KB';
}
return '$size B';
}
}

View File

@@ -0,0 +1,21 @@
class WifiNetwork {
const WifiNetwork({
required this.ssid,
required this.signal,
required this.security,
});
final String ssid;
final int signal;
final String security;
factory WifiNetwork.fromJson(Map<String, dynamic> json) {
return WifiNetwork(
ssid: json['ssid'] as String? ?? '',
signal: json['signal'] as int? ?? 0,
security: json['security'] as String? ?? 'Unknown',
);
}
String get signalLabel => '$signal dBm';
}

View File

@@ -0,0 +1,23 @@
class WifiStatus {
const WifiStatus({
required this.connected,
this.ssid,
this.ip,
});
final bool connected;
final String? ssid;
final String? ip;
factory WifiStatus.disconnected() {
return const WifiStatus(connected: false);
}
factory WifiStatus.fromJson(Map<String, dynamic> json) {
return WifiStatus(
connected: json['connected'] as bool? ?? false,
ssid: json['ssid'] as String?,
ip: json['ip'] as String?,
);
}
}

View File

@@ -0,0 +1,196 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import '../models/ble_models.dart';
import '../services/ble_service.dart';
class BleProvider extends ChangeNotifier {
BleProvider({BleService? bleService}) : _bleService = bleService ?? BleService();
final BleService _bleService;
StreamSubscription<List<BleDevice>>? _scanSubscription;
StreamSubscription<BleStatus>? _statusSubscription;
List<BleDevice> _devices = const <BleDevice>[];
BleDevice? _selectedDevice;
BleStatus? _latestStatus;
ProvisioningState _provisioningState = ProvisioningState.scanning;
String? _errorMessage;
bool _isScanning = false;
bool _isConnecting = false;
bool _isProvisioning = false;
bool _isConnected = false;
bool _isDisposed = false;
List<BleDevice> get devices => _devices;
BleDevice? get selectedDevice => _selectedDevice;
BleStatus? get latestStatus => _latestStatus;
ProvisioningState get provisioningState => _provisioningState;
String? get errorMessage => _errorMessage;
bool get isScanning => _isScanning;
bool get isConnecting => _isConnecting;
bool get isProvisioning => _isProvisioning;
bool get isConnected => _isConnected;
Future<void> startScan() async {
_errorMessage = null;
_selectedDevice = null;
_isConnected = false;
_provisioningState = ProvisioningState.scanning;
_isScanning = true;
_notifySafely();
await _scanSubscription?.cancel();
_scanSubscription = _bleService
.scanForShowenDevices()
.listen((List<BleDevice> scannedDevices) {
_devices = scannedDevices;
_notifySafely();
}, onError: (Object error, StackTrace stackTrace) {
_errorMessage = error.toString();
_isScanning = false;
_provisioningState = ProvisioningState.failed;
_notifySafely();
});
Future<void>.delayed(const Duration(seconds: 6), () {
if (_isScanning) {
_isScanning = false;
_notifySafely();
}
});
}
Future<void> connectToDevice(BleDevice device) async {
_selectedDevice = device;
_errorMessage = null;
_isConnecting = true;
_isScanning = false;
_provisioningState = ProvisioningState.connecting;
_notifySafely();
try {
await _bleService.connectToDevice(device);
await _subscribeToStatus();
_isConnected = true;
} catch (error) {
_isConnected = false;
_errorMessage = error.toString();
_provisioningState = ProvisioningState.failed;
rethrow;
} finally {
_isConnecting = false;
_notifySafely();
}
}
Future<void> provisionWifi(String ssid, String password) async {
_errorMessage = null;
_latestStatus = null;
_isProvisioning = true;
_provisioningState = ProvisioningState.writingCredentials;
_notifySafely();
try {
final Future<BleStatus> operation = _bleService.provisionWifi(
ssid,
password,
timeout: const Duration(seconds: 30),
);
Future<void>.delayed(const Duration(milliseconds: 400), () {
if (_isProvisioning &&
_provisioningState == ProvisioningState.writingCredentials) {
_provisioningState = ProvisioningState.connectingWifi;
_notifySafely();
}
});
final BleStatus result = await operation;
_latestStatus = result;
_provisioningState = result.ok
? ProvisioningState.success
: ProvisioningState.failed;
if (!result.ok) {
_errorMessage = result.error ?? 'WiFi provisioning failed';
}
} on TimeoutException {
_errorMessage = 'BLE 配网超时30 秒)';
_provisioningState = ProvisioningState.failed;
rethrow;
} catch (error) {
_errorMessage = error.toString();
_provisioningState = ProvisioningState.failed;
rethrow;
} finally {
_isProvisioning = false;
_notifySafely();
}
}
Future<void> disconnect() async {
await _scanSubscription?.cancel();
await _statusSubscription?.cancel();
_scanSubscription = null;
_statusSubscription = null;
await _bleService.disconnect();
_isConnected = false;
_isConnecting = false;
_isProvisioning = false;
_selectedDevice = null;
_notifySafely();
}
Future<void> retryScan() async {
await disconnect();
_devices = const <BleDevice>[];
_latestStatus = null;
_errorMessage = null;
_provisioningState = ProvisioningState.scanning;
_notifySafely();
await startScan();
}
Future<void> _subscribeToStatus() async {
await _statusSubscription?.cancel();
final Stream<BleStatus> stream = await _bleService.subscribeToStatus();
_statusSubscription = stream.listen((BleStatus status) {
_latestStatus = status;
if (!status.ok) {
_errorMessage = status.error ?? 'BLE status returned an error';
}
if (status.action == 'connect') {
if (!status.ok) {
_provisioningState = ProvisioningState.failed;
} else if (!status.isQueued) {
_provisioningState = ProvisioningState.success;
} else if (_isProvisioning) {
_provisioningState = ProvisioningState.connectingWifi;
}
}
_notifySafely();
}, onError: (Object error, StackTrace stackTrace) {
_errorMessage = error.toString();
_provisioningState = ProvisioningState.failed;
_notifySafely();
});
}
@override
void dispose() {
_isDisposed = true;
unawaited(_scanSubscription?.cancel());
unawaited(_statusSubscription?.cancel());
unawaited(_bleService.dispose());
super.dispose();
}
void _notifySafely() {
if (_isDisposed) {
return;
}
notifyListeners();
}
}

View File

@@ -0,0 +1,233 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import '../models/ble_status.dart';
import '../models/device_status.dart';
import '../models/player_status.dart';
import '../models/wifi_status.dart';
import '../services/http_api_service.dart';
import '../services/web_socket_service.dart';
class DeviceProvider extends ChangeNotifier {
DeviceProvider({
required HttpApiService httpApiService,
required WebSocketService webSocketService,
String initialDeviceIp = '127.0.0.1',
}) : _httpApiService = httpApiService,
_webSocketService = webSocketService,
_deviceIp = _normalizeDeviceIp(initialDeviceIp) {
_httpApiService.baseUrl = 'http://$_deviceIp:8080';
_connectionSubscription = _webSocketService.onConnectionChanged.listen(
_handleConnectionChanged,
);
_statusSubscription = _webSocketService.onStatusUpdate.listen(_handleStatusUpdate);
_wifiSubscription = _webSocketService.onWifiUpdate.listen(_handleWifiUpdate);
_bleSubscription = _webSocketService.onBleUpdate.listen(_handleBleUpdate);
}
final HttpApiService _httpApiService;
final WebSocketService _webSocketService;
late final StreamSubscription<SocketConnectionStatus> _connectionSubscription;
late final StreamSubscription<Map<String, dynamic>> _statusSubscription;
late final StreamSubscription<Map<String, dynamic>> _wifiSubscription;
late final StreamSubscription<Map<String, dynamic>> _bleSubscription;
DeviceStatus _status = DeviceStatus.initial();
bool _isLoading = false;
String? _errorMessage;
bool _webSocketConnected = false;
String _deviceIp;
DeviceStatus get status => _status;
bool get isLoading => _isLoading;
String? get errorMessage => _errorMessage;
bool get webSocketConnected => _webSocketConnected;
String get deviceIp => _deviceIp;
HttpApiService get httpApiService => _httpApiService;
Future<void> initialize() async {
await refresh();
await connect();
}
Future<void> refresh() async {
_setLoading(true);
try {
final results = await Future.wait<dynamic>([
_httpApiService.getPlaybackStatus(),
_httpApiService.getWifiStatus(),
_httpApiService.getBleStatus(),
]);
_status = _buildStatus(
playerStatus: results[0] as PlayerStatus,
wifiStatus: results[1] as WifiStatus,
bleStatus: results[2] as BleServiceStatus,
);
_errorMessage = null;
} catch (error) {
_errorMessage = error.toString();
_status = _status.copyWith(
connected: false,
connectionType: 'offline',
ipAddress: _deviceIp,
);
} finally {
_setLoading(false);
}
}
Future<void> loadDeviceOverview() => refresh();
Future<void> connect() async {
try {
await _webSocketService.connect(_deviceIp);
_webSocketConnected = _webSocketService.isConnected;
_errorMessage = null;
notifyListeners();
} catch (error) {
_webSocketConnected = false;
_errorMessage = error.toString();
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();
await _webSocketService.disconnect();
await initialize();
}
Future<void> startBle({String? deviceName}) async {
_setLoading(true);
try {
await _httpApiService.startBle(deviceName);
await refresh();
} catch (error) {
_errorMessage = error.toString();
notifyListeners();
} finally {
_setLoading(false);
}
}
Future<void> stopBle() async {
_setLoading(true);
try {
await _httpApiService.stopBle();
await refresh();
} catch (error) {
_errorMessage = error.toString();
notifyListeners();
} finally {
_setLoading(false);
}
}
void _handleConnectionChanged(SocketConnectionStatus connectionStatus) {
_webSocketConnected = connectionStatus == SocketConnectionStatus.connected;
if (!_webSocketConnected) {
_status = _status.copyWith(connectionType: _status.connectionType);
}
notifyListeners();
}
void _handleStatusUpdate(Map<String, dynamic> payload) {
final playerStatus = PlayerStatus.fromJson(payload);
_status = _buildStatus(
playerStatus: playerStatus,
wifiStatus: _status.wifiStatus ?? WifiStatus.disconnected(),
bleStatus: _status.bleStatus ?? BleServiceStatus.initial(),
);
notifyListeners();
}
void _handleWifiUpdate(Map<String, dynamic> payload) {
final wifiStatus = WifiStatus.fromJson(payload);
_status = _buildStatus(
playerStatus: _status.playerStatus ?? PlayerStatus.initial(),
wifiStatus: wifiStatus,
bleStatus: _status.bleStatus ?? BleServiceStatus.initial(),
);
notifyListeners();
}
void _handleBleUpdate(Map<String, dynamic> payload) {
final normalized = <String, dynamic>{
'running': payload['running'] ?? payload['ready'] ?? false,
'embedded': payload['embedded'] ?? false,
'device_name': payload['device_name'] ?? payload['name'],
};
final bleStatus = BleServiceStatus.fromJson(normalized);
_status = _buildStatus(
playerStatus: _status.playerStatus ?? PlayerStatus.initial(),
wifiStatus: _status.wifiStatus ?? WifiStatus.disconnected(),
bleStatus: bleStatus,
);
notifyListeners();
}
DeviceStatus _buildStatus({
required PlayerStatus playerStatus,
required WifiStatus wifiStatus,
required BleServiceStatus bleStatus,
}) {
final connectionType = wifiStatus.connected
? 'wifi'
: bleStatus.running
? 'ble'
: 'offline';
return DeviceStatus(
connected: wifiStatus.connected || bleStatus.running || _webSocketConnected,
connectionType: connectionType,
deviceName: bleStatus.deviceName ?? 'ShowenV2',
ipAddress: wifiStatus.ip ?? _deviceIp,
playerStatus: playerStatus,
wifiStatus: wifiStatus,
bleStatus: bleStatus,
updatedAt: DateTime.now(),
);
}
void _setLoading(bool value) {
_isLoading = value;
notifyListeners();
}
static String _normalizeDeviceIp(String input) {
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);
}
final slashIndex = value.indexOf('/');
if (slashIndex >= 0) {
value = value.substring(0, slashIndex);
}
final colonIndex = value.indexOf(':');
if (colonIndex >= 0) {
value = value.substring(0, colonIndex);
}
return value.isEmpty ? '127.0.0.1' : value;
}
@override
void dispose() {
unawaited(_connectionSubscription.cancel());
unawaited(_statusSubscription.cancel());
unawaited(_wifiSubscription.cancel());
unawaited(_bleSubscription.cancel());
super.dispose();
}
}

View File

@@ -0,0 +1,184 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import '../models/player_status.dart';
import '../services/http_api_service.dart';
import '../services/web_socket_service.dart';
class PlayerProvider extends ChangeNotifier {
PlayerProvider({
required HttpApiService httpApiService,
required WebSocketService webSocketService,
}) : _httpApiService = httpApiService,
_webSocketService = webSocketService {
_statusSubscription = _webSocketService.onStatusUpdate.listen((payload) {
_status = PlayerStatus.fromJson(payload);
notifyListeners();
});
_stateSubscription = _webSocketService.onStateUpdate.listen((payload) {
_currentState = payload['new_state']?.toString() ?? _currentState;
notifyListeners();
});
_configSubscription = _webSocketService.onConfigUpdate.listen((payload) {
_updateSceneOptions(payload);
notifyListeners();
});
}
final HttpApiService _httpApiService;
final WebSocketService _webSocketService;
late final StreamSubscription<Map<String, dynamic>> _statusSubscription;
late final StreamSubscription<Map<String, dynamic>> _stateSubscription;
late final StreamSubscription<Map<String, dynamic>> _configSubscription;
PlayerStatus _status = PlayerStatus.initial();
List<String> _playlist = const <String>[];
List<String> _sceneOptions = const <String>['idle', 'intro', 'loop'];
bool _isLoading = false;
String? _errorMessage;
String? _currentState;
PlayerStatus get status => _status;
List<String> get playlist => _playlist;
List<String> get sceneOptions => _sceneOptions;
bool get isLoading => _isLoading;
String? get errorMessage => _errorMessage;
String get currentState => _currentState ?? _status.currentVideo ?? 'idle';
Future<void> bootstrap() async {
_setLoading(true);
try {
final results = await Future.wait<dynamic>([
_httpApiService.getPlaybackStatus(),
_httpApiService.getPlaylist(),
_httpApiService.getConfig(),
]);
_status = results[0] as PlayerStatus;
_playlist = results[1] as List<String>;
_updateSceneOptions(results[2] as Map<String, dynamic>);
_errorMessage = null;
} catch (error) {
_errorMessage = error.toString();
} finally {
_setLoading(false);
}
}
Future<void> fetchStatus() async {
try {
_status = await _httpApiService.getPlaybackStatus();
_errorMessage = null;
notifyListeners();
} catch (error) {
_errorMessage = error.toString();
notifyListeners();
}
}
Future<void> fetchPlaylist() async {
try {
_playlist = await _httpApiService.getPlaylist();
_errorMessage = null;
notifyListeners();
} catch (error) {
_errorMessage = error.toString();
notifyListeners();
}
}
Future<void> play() => _runCommand(_httpApiService.play);
Future<void> pause() => _runCommand(_httpApiService.pause);
Future<void> next() => _runCommand(_httpApiService.next);
Future<void> previous() => _runCommand(_httpApiService.previous);
Future<void> gotoIndex(int index) async {
await _runCommand(() => _httpApiService.goto(index));
}
Future<void> switchScene(String name) async {
await _runCommand(() => _httpApiService.changeScene(name));
_currentState = name;
notifyListeners();
}
Future<void> triggerEvent(String name, {String? value}) {
return _runCommand(() => _httpApiService.trigger(name, value));
}
Future<void> togglePlayPause() async {
if (_status.running && !_status.paused) {
await pause();
return;
}
await play();
}
Future<void> _runCommand(Future<dynamic> Function() action) async {
_setLoading(true);
try {
await action();
await Future.wait<void>([
fetchStatus(),
fetchPlaylist(),
]);
_errorMessage = null;
} catch (error) {
_errorMessage = error.toString();
notifyListeners();
} finally {
_setLoading(false);
}
}
void _updateSceneOptions(Map<String, dynamic> config) {
final candidates = <String>{};
final scenes = config['scenes'];
if (scenes is List) {
for (final scene in scenes) {
final value = scene.toString();
if (value.isNotEmpty) {
candidates.add(value);
}
}
}
final stateMachine = config['state_machine'];
if (stateMachine is Map) {
final states = stateMachine['states'];
if (states is Map) {
for (final entry in states.keys) {
final value = entry.toString();
if (value.isNotEmpty) {
candidates.add(value);
}
}
}
final initialState = stateMachine['initial_state']?.toString();
if (initialState != null && initialState.isNotEmpty) {
candidates.add(initialState);
_currentState ??= initialState;
}
}
if (candidates.isNotEmpty) {
_sceneOptions = candidates.toList(growable: false)..sort();
}
}
void _setLoading(bool value) {
_isLoading = value;
notifyListeners();
}
@override
void dispose() {
unawaited(_statusSubscription.cancel());
unawaited(_stateSubscription.cancel());
unawaited(_configSubscription.cancel());
super.dispose();
}
}

View File

@@ -0,0 +1,132 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import '../models/wifi_network.dart';
import '../models/wifi_status.dart';
import '../services/http_api_service.dart';
import '../services/web_socket_service.dart';
class WifiProvider extends ChangeNotifier {
WifiProvider({
required HttpApiService httpApiService,
required WebSocketService webSocketService,
}) : _httpApiService = httpApiService,
_webSocketService = webSocketService {
_wifiSubscription = _webSocketService.onWifiUpdate.listen((payload) {
_status = WifiStatus.fromJson(payload);
notifyListeners();
});
}
final HttpApiService _httpApiService;
final WebSocketService _webSocketService;
late final StreamSubscription<Map<String, dynamic>> _wifiSubscription;
WifiStatus _status = WifiStatus.disconnected();
List<WifiNetwork> _networks = const <WifiNetwork>[];
bool _isLoading = false;
String? _errorMessage;
bool _hotspotEnabled = false;
WifiStatus get status => _status;
List<WifiNetwork> get networks => _networks;
bool get isLoading => _isLoading;
String? get errorMessage => _errorMessage;
bool get hotspotEnabled => _hotspotEnabled;
Future<void> bootstrap() async {
_setLoading(true);
try {
final results = await Future.wait<dynamic>([
_httpApiService.getWifiStatus(),
_httpApiService.scanWifi(),
]);
_status = results[0] as WifiStatus;
_networks = results[1] as List<WifiNetwork>;
_errorMessage = null;
} catch (error) {
_errorMessage = error.toString();
} finally {
_setLoading(false);
}
}
Future<void> refreshStatus() async {
try {
_status = await _httpApiService.getWifiStatus();
notifyListeners();
} catch (error) {
_errorMessage = error.toString();
notifyListeners();
}
}
Future<void> scanNetworks() async {
_setLoading(true);
try {
_networks = await _httpApiService.scanWifi();
_errorMessage = null;
} catch (error) {
_errorMessage = error.toString();
} finally {
_setLoading(false);
}
}
Future<void> connect({required String ssid, required String password}) async {
_setLoading(true);
try {
await _httpApiService.connectWifi(ssid, password);
await refreshStatus();
_hotspotEnabled = false;
_errorMessage = null;
} catch (error) {
_errorMessage = error.toString();
notifyListeners();
} finally {
_setLoading(false);
}
}
Future<void> startHotspot({String? ssid, String? password}) async {
_setLoading(true);
try {
await _httpApiService.startAP(ssid, password);
_hotspotEnabled = true;
_errorMessage = null;
notifyListeners();
} catch (error) {
_errorMessage = error.toString();
notifyListeners();
} finally {
_setLoading(false);
}
}
Future<void> stopHotspot() async {
_setLoading(true);
try {
await _httpApiService.stopAP();
_hotspotEnabled = false;
_errorMessage = null;
notifyListeners();
} catch (error) {
_errorMessage = error.toString();
notifyListeners();
} finally {
_setLoading(false);
}
}
void _setLoading(bool value) {
_isLoading = value;
notifyListeners();
}
@override
void dispose() {
unawaited(_wifiSubscription.cancel());
super.dispose();
}
}

View File

@@ -0,0 +1,51 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
class AppShell extends StatelessWidget {
const AppShell({required this.navigationShell, super.key});
final StatefulNavigationShell navigationShell;
@override
Widget build(BuildContext context) {
return Scaffold(
body: navigationShell,
bottomNavigationBar: NavigationBar(
selectedIndex: navigationShell.currentIndex,
onDestinationSelected: (index) {
navigationShell.goBranch(
index,
initialLocation: index == navigationShell.currentIndex,
);
},
destinations: const [
NavigationDestination(
icon: Icon(Icons.home_outlined),
selectedIcon: Icon(Icons.home),
label: '首页',
),
NavigationDestination(
icon: Icon(Icons.play_circle_outline),
selectedIcon: Icon(Icons.play_circle),
label: '播放',
),
NavigationDestination(
icon: Icon(Icons.bolt_outlined),
selectedIcon: Icon(Icons.bolt),
label: '触发',
),
NavigationDestination(
icon: Icon(Icons.wifi_outlined),
selectedIcon: Icon(Icons.wifi),
label: '网络',
),
NavigationDestination(
icon: Icon(Icons.settings_outlined),
selectedIcon: Icon(Icons.settings),
label: '设置',
),
],
),
);
}
}

View File

@@ -0,0 +1,597 @@
import 'package:flutter/material.dart';
import '../models/ble_models.dart';
import '../providers/ble_provider.dart';
class BleProvisionScreen extends StatefulWidget {
const BleProvisionScreen({super.key, this.provider});
final BleProvider? provider;
@override
State<BleProvisionScreen> createState() => _BleProvisionScreenState();
}
class _BleProvisionScreenState extends State<BleProvisionScreen> {
late final BleProvider _provider;
late final bool _ownsProvider;
final TextEditingController _ssidController = TextEditingController();
final TextEditingController _passwordController = TextEditingController();
final GlobalKey<FormState> _formKey = GlobalKey<FormState>();
@override
void initState() {
super.initState();
_ownsProvider = widget.provider == null;
_provider = widget.provider ?? BleProvider();
WidgetsBinding.instance.addPostFrameCallback((_) {
_provider.startScan();
});
}
@override
void dispose() {
_ssidController.dispose();
_passwordController.dispose();
if (_ownsProvider) {
_provider.dispose();
}
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Showen BLE 配网')),
body: AnimatedBuilder(
animation: _provider,
builder: (BuildContext context, _) {
return Container(
decoration: const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: <Color>[Color(0xFF0F172A), Color(0xFF111827)],
),
),
child: SafeArea(
child: ListView(
padding: const EdgeInsets.all(16),
children: <Widget>[
_StatusBanner(provider: _provider),
const SizedBox(height: 16),
_ProgressCard(state: _provider.provisioningState),
const SizedBox(height: 16),
_buildDevicesCard(),
const SizedBox(height: 16),
_buildWifiCard(),
const SizedBox(height: 16),
_buildResultCard(),
],
),
),
);
},
),
);
}
Widget _buildDevicesCard() {
return _GlassCard(
title: '1. 扫描 Showen 设备',
trailing: TextButton.icon(
onPressed: _provider.isProvisioning ? null : _provider.retryScan,
icon: _provider.isScanning
? const SizedBox(
width: 14,
height: 14,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.refresh),
label: Text(_provider.isScanning ? '扫描中' : '重新扫描'),
),
child: Column(
children: <Widget>[
if (_provider.devices.isEmpty)
const _EmptyState(
icon: Icons.bluetooth_searching,
title: '未发现 Showen 设备',
subtitle: '请确认设备已开机,且蓝牙广播名称包含 Showen。',
)
else
..._provider.devices.map((BleDevice device) {
final bool selected = _provider.selectedDevice?.id == device.id;
return Padding(
padding: const EdgeInsets.only(bottom: 12),
child: InkWell(
borderRadius: BorderRadius.circular(14),
onTap: _provider.isConnecting || _provider.isProvisioning
? null
: () => _handleDeviceSelected(device),
child: Ink(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(14),
border: Border.all(
color: selected
? const Color(0xFF22C55E)
: Colors.white24,
),
color: selected
? const Color(0x1A22C55E)
: const Color(0x141E293B),
),
padding: const EdgeInsets.all(14),
child: Row(
children: <Widget>[
Icon(
selected ? Icons.bluetooth_connected : Icons.bluetooth,
color: Colors.white,
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text(
device.name,
style: const TextStyle(
color: Colors.white,
fontSize: 16,
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 4),
Text(
device.id,
style: const TextStyle(
color: Color(0xFF94A3B8),
fontSize: 12,
),
),
],
),
),
_SignalBadge(rssi: device.rssi),
],
),
),
),
);
}),
],
),
);
}
Widget _buildWifiCard() {
return _GlassCard(
title: '2. 输入 WiFi 凭据',
child: Form(
key: _formKey,
child: Column(
children: <Widget>[
TextFormField(
controller: _ssidController,
enabled: !_provider.isProvisioning,
style: const TextStyle(color: Colors.white),
decoration: _inputDecoration('WiFi SSID', Icons.wifi),
validator: (String? value) {
if ((value ?? '').trim().isEmpty) {
return '请输入 WiFi 名称';
}
return null;
},
),
const SizedBox(height: 12),
TextFormField(
controller: _passwordController,
enabled: !_provider.isProvisioning,
obscureText: true,
style: const TextStyle(color: Colors.white),
decoration: _inputDecoration('WiFi 密码', Icons.lock_outline),
),
const SizedBox(height: 16),
SizedBox(
width: double.infinity,
child: FilledButton.icon(
onPressed: (_provider.selectedDevice == null ||
!_provider.isConnected ||
_provider.isProvisioning)
? null
: _handleProvisioning,
icon: _provider.isProvisioning
? const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.router_outlined),
label: Text(_provider.isProvisioning ? '配网中...' : '开始配网'),
),
),
],
),
),
);
}
Widget _buildResultCard() {
final BleStatus? status = _provider.latestStatus;
final bool success = _provider.provisioningState == ProvisioningState.success;
final bool failed = _provider.provisioningState == ProvisioningState.failed;
return _GlassCard(
title: '3. 配网结果',
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Row(
children: <Widget>[
Icon(
success
? Icons.check_circle_outline
: failed
? Icons.error_outline
: Icons.info_outline,
color: success
? const Color(0xFF22C55E)
: failed
? const Color(0xFFF87171)
: const Color(0xFF38BDF8),
),
const SizedBox(width: 8),
Expanded(
child: Text(
success
? 'WiFi 已连接成功'
: failed
? (_provider.errorMessage ?? '配网失败')
: '等待设备返回状态',
style: const TextStyle(
color: Colors.white,
fontSize: 15,
fontWeight: FontWeight.w600,
),
),
),
],
),
if (status != null) ...<Widget>[
const SizedBox(height: 12),
Container(
width: double.infinity,
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.black.withOpacity(0.18),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: Colors.white12),
),
child: Text(
'action: ${status.action}\nstate: ${status.state ?? '-'}\nerror: ${status.error ?? '-'}',
style: const TextStyle(
color: Color(0xFFCBD5E1),
height: 1.5,
),
),
),
],
if (failed) ...<Widget>[
const SizedBox(height: 16),
OutlinedButton.icon(
onPressed: _provider.retryScan,
icon: const Icon(Icons.replay),
label: const Text('重试'),
),
],
],
),
);
}
Future<void> _handleDeviceSelected(BleDevice device) async {
try {
await _provider.connectToDevice(device);
if (!mounted) {
return;
}
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('已连接 ${device.name}')),
);
} catch (error) {
if (!mounted) {
return;
}
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('连接失败: $error')),
);
}
}
Future<void> _handleProvisioning() async {
if (!_formKey.currentState!.validate()) {
return;
}
try {
await _provider.provisionWifi(
_ssidController.text.trim(),
_passwordController.text,
);
if (!mounted) {
return;
}
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('设备已完成 WiFi 配网')),
);
} catch (error) {
if (!mounted) {
return;
}
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('配网失败: $error')),
);
}
}
InputDecoration _inputDecoration(String label, IconData icon) {
return InputDecoration(
labelText: label,
labelStyle: const TextStyle(color: Color(0xFFCBD5E1)),
prefixIcon: Icon(icon, color: const Color(0xFF38BDF8)),
filled: true,
fillColor: const Color(0x141E293B),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: const BorderSide(color: Colors.white24),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: const BorderSide(color: Color(0xFF38BDF8), width: 1.2),
),
errorBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: const BorderSide(color: Color(0xFFF87171)),
),
focusedErrorBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: const BorderSide(color: Color(0xFFF87171), width: 1.2),
),
);
}
}
class _StatusBanner extends StatelessWidget {
const _StatusBanner({required this.provider});
final BleProvider provider;
@override
Widget build(BuildContext context) {
final bool connected = provider.isConnected;
final String headline = connected
? '已连接 ${provider.selectedDevice?.name ?? 'Showen 设备'}'
: provider.isScanning
? '正在扫描附近 Showen 设备'
: '选择设备后即可开始 BLE 配网';
return Container(
padding: const EdgeInsets.all(18),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(20),
gradient: const LinearGradient(
colors: <Color>[Color(0xFF0EA5E9), Color(0xFF22C55E)],
),
boxShadow: const <BoxShadow>[
BoxShadow(
color: Color(0x330EA5E9),
blurRadius: 24,
offset: Offset(0, 10),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
const Text(
'ShowenV2 BLE Provisioning',
style: TextStyle(
color: Colors.white,
fontSize: 22,
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 8),
Text(
headline,
style: const TextStyle(
color: Colors.white,
height: 1.5,
),
),
],
),
);
}
}
class _ProgressCard extends StatelessWidget {
const _ProgressCard({required this.state});
final ProvisioningState state;
@override
Widget build(BuildContext context) {
final List<_StepMeta> steps = <_StepMeta>[
_StepMeta('扫描设备', state.index >= ProvisioningState.scanning.index),
_StepMeta('连接设备', state.index >= ProvisioningState.connecting.index),
_StepMeta('写入凭据', state.index >= ProvisioningState.writingCredentials.index),
_StepMeta('连接 WiFi', state.index >= ProvisioningState.connectingWifi.index),
_StepMeta('完成', state == ProvisioningState.success),
];
return _GlassCard(
title: '当前进度',
child: Wrap(
runSpacing: 12,
spacing: 12,
children: steps.map(( _StepMeta step) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
decoration: BoxDecoration(
color: step.active
? const Color(0x1A22C55E)
: Colors.black.withOpacity(0.14),
borderRadius: BorderRadius.circular(999),
border: Border.all(
color: step.active ? const Color(0xFF22C55E) : Colors.white24,
),
),
child: Text(
step.label,
style: TextStyle(
color:
step.active ? const Color(0xFFF8FAFC) : const Color(0xFF94A3B8),
fontWeight: FontWeight.w600,
),
),
);
}).toList(),
),
);
}
}
class _StepMeta {
const _StepMeta(this.label, this.active);
final String label;
final bool active;
}
class _GlassCard extends StatelessWidget {
const _GlassCard({
required this.title,
required this.child,
this.trailing,
});
final String title;
final Widget child;
final Widget? trailing;
@override
Widget build(BuildContext context) {
return Container(
decoration: BoxDecoration(
color: const Color(0xB31E293B),
borderRadius: BorderRadius.circular(20),
border: Border.all(color: Colors.white10),
),
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Row(
children: <Widget>[
Expanded(
child: Text(
title,
style: const TextStyle(
color: Colors.white,
fontSize: 18,
fontWeight: FontWeight.w700,
),
),
),
if (trailing != null) trailing!,
],
),
const SizedBox(height: 16),
child,
],
),
);
}
}
class _SignalBadge extends StatelessWidget {
const _SignalBadge({required this.rssi});
final int rssi;
@override
Widget build(BuildContext context) {
final IconData icon = switch (rssi) {
>= -60 => Icons.network_wifi,
>= -75 => Icons.network_wifi_3_bar,
>= -90 => Icons.network_wifi_2_bar,
_ => Icons.network_wifi_1_bar,
};
return Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8),
decoration: BoxDecoration(
color: Colors.black.withOpacity(0.2),
borderRadius: BorderRadius.circular(12),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: <Widget>[
Icon(icon, color: const Color(0xFF38BDF8), size: 18),
const SizedBox(width: 6),
Text(
'$rssi dBm',
style: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.w600,
),
),
],
),
);
}
}
class _EmptyState extends StatelessWidget {
const _EmptyState({
required this.icon,
required this.title,
required this.subtitle,
});
final IconData icon;
final String title;
final String subtitle;
@override
Widget build(BuildContext context) {
return Container(
width: double.infinity,
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: Colors.black.withOpacity(0.15),
borderRadius: BorderRadius.circular(16),
border: Border.all(color: Colors.white10),
),
child: Column(
children: <Widget>[
Icon(icon, color: const Color(0xFF38BDF8), size: 32),
const SizedBox(height: 10),
Text(
title,
style: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 6),
Text(
subtitle,
textAlign: TextAlign.center,
style: const TextStyle(color: Color(0xFF94A3B8), height: 1.5),
),
],
),
);
}
}

View File

@@ -0,0 +1,146 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../providers/device_provider.dart';
import '../providers/player_provider.dart';
import '../providers/wifi_provider.dart';
import '../theme/app_colors.dart';
import '../widgets/control_button.dart';
import '../widgets/status_card.dart';
class HomeScreen extends StatelessWidget {
const HomeScreen({super.key});
@override
Widget build(BuildContext context) {
final deviceProvider = context.watch<DeviceProvider>();
final playerProvider = context.watch<PlayerProvider>();
final wifiProvider = context.watch<WifiProvider>();
final device = deviceProvider.status;
final player = playerProvider.status;
final wifi = wifiProvider.status;
return Scaffold(
appBar: AppBar(title: const Text('ShowenV2 控制台')),
body: RefreshIndicator(
onRefresh: () async {
await Future.wait<void>([
context.read<DeviceProvider>().refresh(),
context.read<PlayerProvider>().bootstrap(),
context.read<WifiProvider>().bootstrap(),
]);
},
child: ListView(
padding: const EdgeInsets.all(AppSpacing.md),
children: [
StatusCard(
title: '设备连接',
value: device.connected ? '已连接' : '未连接',
subtitle: '${device.ipAddress ?? deviceProvider.deviceIp} · ${device.connectionType.toUpperCase()}',
icon: Icons.devices_rounded,
accentColor: device.connected ? AppColors.success : AppColors.warning,
),
const SizedBox(height: AppSpacing.md),
StatusCard(
title: '当前播放状态',
value: player.currentVideo ?? '暂无播放视频',
subtitle: player.running
? (player.paused ? '已暂停' : '播放中')
: '等待播放',
icon: Icons.play_circle_outline_rounded,
accentColor: player.paused ? AppColors.warning : AppColors.primary,
),
const SizedBox(height: AppSpacing.md),
StatusCard(
title: 'WiFi 摘要',
value: wifi.connected ? (wifi.ssid ?? '已连接') : '未连接网络',
subtitle: wifi.ip ?? '可通过热点或 BLE 配网',
icon: Icons.wifi_rounded,
accentColor: wifi.connected ? AppColors.info : AppColors.border,
),
const SizedBox(height: AppSpacing.lg),
Text('快捷控制', style: Theme.of(context).textTheme.headlineSmall),
const SizedBox(height: AppSpacing.md),
Row(
children: [
Expanded(
child: ControlButton(
label: player.running && !player.paused ? '暂停' : '播放',
icon: player.running && !player.paused
? Icons.pause_rounded
: Icons.play_arrow_rounded,
onPressed: playerProvider.isLoading
? null
: () => context.read<PlayerProvider>().togglePlayPause(),
),
),
const SizedBox(width: AppSpacing.md),
Expanded(
child: ControlButton(
label: '上一个',
icon: Icons.skip_previous_rounded,
isFilled: false,
onPressed: playerProvider.isLoading
? null
: () => context.read<PlayerProvider>().previous(),
),
),
const SizedBox(width: AppSpacing.md),
Expanded(
child: ControlButton(
label: '下一个',
icon: Icons.skip_next_rounded,
isFilled: false,
onPressed: playerProvider.isLoading
? null
: () => context.read<PlayerProvider>().next(),
),
),
],
),
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: '设备 IP', value: device.ipAddress ?? deviceProvider.deviceIp),
_InfoRow(label: '连接方式', value: device.connectionType.toUpperCase()),
_InfoRow(
label: '实时通道',
value: deviceProvider.webSocketConnected ? 'WebSocket 已连接' : 'WebSocket 重连中',
),
_InfoRow(label: '播放索引', value: '${player.currentIndex + 1}/${player.playlistLength}'),
],
),
),
),
],
),
),
);
}
}
class _InfoRow extends StatelessWidget {
const _InfoRow({required this.label, required this.value});
final String label;
final String value;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.only(bottom: AppSpacing.sm),
child: Row(
children: [
Expanded(child: Text(label, style: Theme.of(context).textTheme.bodyMedium)),
Text(value, style: Theme.of(context).textTheme.bodyLarge),
],
),
);
}
}

View File

@@ -0,0 +1,201 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:provider/provider.dart';
import '../providers/device_provider.dart';
import '../providers/wifi_provider.dart';
import '../theme/app_colors.dart';
import '../widgets/control_button.dart';
import '../widgets/status_card.dart';
import '../widgets/wifi_list_tile.dart';
class NetworkScreen extends StatefulWidget {
const NetworkScreen({super.key});
@override
State<NetworkScreen> createState() => _NetworkScreenState();
}
class _NetworkScreenState extends State<NetworkScreen> {
final TextEditingController _ssidController = TextEditingController();
final TextEditingController _passwordController = TextEditingController();
final TextEditingController _apSsidController = TextEditingController(text: 'showen');
final TextEditingController _apPasswordController = TextEditingController(text: '12345678');
@override
void dispose() {
_ssidController.dispose();
_passwordController.dispose();
_apSsidController.dispose();
_apPasswordController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final wifiProvider = context.watch<WifiProvider>();
final deviceProvider = context.watch<DeviceProvider>();
final wifiStatus = wifiProvider.status;
final bleStatus = deviceProvider.status.bleStatus;
return Scaffold(
appBar: AppBar(title: const Text('网络设置')),
body: ListView(
padding: const EdgeInsets.all(AppSpacing.md),
children: [
StatusCard(
title: 'WiFi 状态',
value: wifiStatus.connected ? (wifiStatus.ssid ?? '已连接') : '未连接',
subtitle: wifiStatus.ip ?? '尚未获取 IP 地址',
icon: Icons.router_rounded,
accentColor: wifiStatus.connected ? AppColors.success : AppColors.warning,
),
const SizedBox(height: AppSpacing.md),
Card(
child: Padding(
padding: const EdgeInsets.all(AppSpacing.md),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('连接 WiFi', style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: AppSpacing.md),
TextField(
controller: _ssidController,
decoration: const InputDecoration(labelText: 'WiFi 名称'),
),
const SizedBox(height: AppSpacing.md),
TextField(
controller: _passwordController,
obscureText: true,
decoration: const InputDecoration(labelText: 'WiFi 密码'),
),
const SizedBox(height: AppSpacing.md),
SizedBox(
width: double.infinity,
child: FilledButton.icon(
onPressed: wifiProvider.isLoading ? null : _handleConnectWifi,
icon: const Icon(Icons.wifi_password_rounded),
label: const Text('连接当前 WiFi'),
),
),
],
),
),
),
const SizedBox(height: AppSpacing.lg),
Row(
children: [
Expanded(
child: ControlButton(
label: '扫描 WiFi',
icon: Icons.wifi_find_rounded,
onPressed: wifiProvider.isLoading
? null
: () => context.read<WifiProvider>().scanNetworks(),
),
),
const SizedBox(width: AppSpacing.md),
Expanded(
child: ControlButton(
label: bleStatus?.running == true ? 'BLE 已就绪' : '启动 BLE',
icon: Icons.bluetooth_rounded,
isFilled: false,
onPressed: deviceProvider.isLoading
? null
: () => context.read<DeviceProvider>().startBle(deviceName: 'showen'),
),
),
],
),
const SizedBox(height: AppSpacing.lg),
Text('扫描结果', style: Theme.of(context).textTheme.headlineSmall),
const SizedBox(height: AppSpacing.md),
if (wifiProvider.networks.isEmpty)
const Card(
child: Padding(
padding: EdgeInsets.all(AppSpacing.md),
child: Text('暂无扫描结果'),
),
),
...wifiProvider.networks.map(
(network) => Padding(
padding: const EdgeInsets.only(bottom: AppSpacing.sm),
child: WifiListTile(
network: network,
onTap: () {
_ssidController.text = network.ssid;
setState(() {});
},
),
),
),
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),
),
Switch(
value: wifiProvider.hotspotEnabled,
onChanged: (enabled) {
if (enabled) {
context.read<WifiProvider>().startHotspot(
ssid: _apSsidController.text.trim(),
password: _apPasswordController.text,
);
return;
}
context.read<WifiProvider>().stopHotspot();
},
),
],
),
const SizedBox(height: AppSpacing.sm),
TextField(
controller: _apSsidController,
decoration: const InputDecoration(labelText: '热点 SSID'),
),
const SizedBox(height: AppSpacing.md),
TextField(
controller: _apPasswordController,
obscureText: true,
decoration: const InputDecoration(labelText: '热点密码'),
),
],
),
),
),
const SizedBox(height: AppSpacing.md),
SizedBox(
width: double.infinity,
child: FilledButton.tonal(
onPressed: () => context.push('/network/ble-provision'),
child: const Text('进入 BLE 配网页面'),
),
),
],
),
);
}
void _handleConnectWifi() {
final ssid = _ssidController.text.trim();
if (ssid.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('请输入 WiFi 名称')),
);
return;
}
context.read<WifiProvider>().connect(
ssid: ssid,
password: _passwordController.text,
);
}
}

View File

@@ -0,0 +1,182 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../providers/player_provider.dart';
import '../theme/app_colors.dart';
import '../widgets/control_button.dart';
class PlaybackScreen extends StatefulWidget {
const PlaybackScreen({super.key});
@override
State<PlaybackScreen> createState() => _PlaybackScreenState();
}
class _PlaybackScreenState extends State<PlaybackScreen> {
final TextEditingController _indexController = TextEditingController();
@override
void dispose() {
_indexController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final provider = context.watch<PlayerProvider>();
final status = provider.status;
final playlist = provider.playlist;
return Scaffold(
appBar: AppBar(title: const Text('播放控制')),
body: ListView(
padding: const EdgeInsets.all(AppSpacing.md),
children: [
Card(
child: Padding(
padding: const EdgeInsets.all(AppSpacing.lg),
child: Column(
children: [
Text(
status.currentVideo ?? '暂无播放内容',
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.headlineSmall,
),
const SizedBox(height: AppSpacing.lg),
SizedBox(
width: 132,
height: 132,
child: DecoratedBox(
decoration: const BoxDecoration(
shape: BoxShape.circle,
gradient: AppColors.primaryGradient,
),
child: IconButton(
onPressed: provider.isLoading
? null
: () => context.read<PlayerProvider>().togglePlayPause(),
iconSize: 56,
color: Colors.white,
icon: Icon(
status.running && !status.paused
? Icons.pause_rounded
: Icons.play_arrow_rounded,
),
),
),
),
const SizedBox(height: AppSpacing.md),
Text(
status.running ? (status.paused ? '已暂停' : '播放中') : '未开始播放',
style: Theme.of(context).textTheme.bodyMedium,
),
],
),
),
),
const SizedBox(height: AppSpacing.md),
Row(
children: [
Expanded(
child: ControlButton(
label: '上一个',
icon: Icons.skip_previous_rounded,
isFilled: false,
onPressed: provider.isLoading
? null
: () => context.read<PlayerProvider>().previous(),
),
),
const SizedBox(width: AppSpacing.md),
Expanded(
child: ControlButton(
label: '下一个',
icon: Icons.skip_next_rounded,
isFilled: false,
onPressed: provider.isLoading
? null
: () => context.read<PlayerProvider>().next(),
),
),
],
),
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),
Row(
children: [
Expanded(
child: TextField(
controller: _indexController,
keyboardType: TextInputType.number,
decoration: const InputDecoration(
labelText: '输入 0 开始的索引',
),
),
),
const SizedBox(width: AppSpacing.md),
FilledButton(
onPressed: provider.isLoading ? null : _handleGoto,
child: const Text('跳转'),
),
],
),
],
),
),
),
const SizedBox(height: AppSpacing.lg),
Text('播放列表', style: Theme.of(context).textTheme.headlineSmall),
const SizedBox(height: AppSpacing.md),
if (playlist.isEmpty)
const Card(
child: Padding(
padding: EdgeInsets.all(AppSpacing.md),
child: Text('当前没有可播放视频'),
),
),
...playlist.asMap().entries.map((entry) {
final selected = entry.key == status.currentIndex;
return Padding(
padding: const EdgeInsets.only(bottom: AppSpacing.sm),
child: Card(
color: selected ? AppColors.primary.withOpacity(0.16) : null,
child: ListTile(
onTap: provider.isLoading
? null
: () => context.read<PlayerProvider>().gotoIndex(entry.key),
leading: CircleAvatar(
backgroundColor: selected ? AppColors.primary : AppColors.border,
child: Text('${entry.key + 1}'),
),
title: Text(entry.value),
subtitle: Text(selected ? '当前播放' : '点击跳转'),
trailing: Icon(
selected ? Icons.equalizer_rounded : Icons.chevron_right_rounded,
),
),
),
);
}),
],
),
);
}
void _handleGoto() {
final index = int.tryParse(_indexController.text.trim());
if (index == null) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('请输入有效索引')),
);
return;
}
context.read<PlayerProvider>().gotoIndex(index);
}
}

View File

@@ -0,0 +1,338 @@
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../providers/device_provider.dart';
import '../theme/app_colors.dart';
class SettingsScreen extends StatefulWidget {
const SettingsScreen({super.key});
@override
State<SettingsScreen> createState() => _SettingsScreenState();
}
class _SettingsScreenState extends State<SettingsScreen> {
final TextEditingController _ipController = TextEditingController();
final TextEditingController _titleController = TextEditingController();
final TextEditingController _rotationController = TextEditingController();
final TextEditingController _widthController = TextEditingController();
final TextEditingController _heightController = TextEditingController();
final TextEditingController _hsvMinController = TextEditingController();
final TextEditingController _hsvMaxController = TextEditingController();
final TextEditingController _pointsController = TextEditingController();
Map<String, dynamic>? _fullConfig;
List<String> _availableConfigs = const <String>[];
String? _activeConfig;
bool _isFullscreen = false;
bool _loading = true;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) => _loadData());
}
@override
void dispose() {
_ipController.dispose();
_titleController.dispose();
_rotationController.dispose();
_widthController.dispose();
_heightController.dispose();
_hsvMinController.dispose();
_hsvMaxController.dispose();
_pointsController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final provider = context.watch<DeviceProvider>();
final status = provider.status;
_ipController.text = _ipController.text.isEmpty ? provider.deviceIp : _ipController.text;
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();
},
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>(
value: _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: [
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(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.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;
setState(() => _loading = true);
try {
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);
_activeConfig = available['active']?.toString();
_applyDisplayConfig(Map<String, dynamic>.from(_fullConfig?['display'] as Map? ?? const <String, dynamic>{}));
} finally {
if (mounted) {
setState(() => _loading = false);
}
}
}
Future<void> _handleSwitchConfig() async {
final activeConfig = _activeConfig;
if (activeConfig == null) {
return;
}
await context.read<DeviceProvider>().httpApiService.switchConfig(activeConfig);
if (!mounted) {
return;
}
await _loadData();
}
Future<void> _handleSaveDisplayConfig() async {
final config = _fullConfig;
if (config == null) {
return;
}
final nextConfig = Map<String, dynamic>.from(config);
nextConfig['display'] = <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,
'render_width': int.tryParse(_widthController.text.trim()) ?? 1024,
'render_height': int.tryParse(_heightController.text.trim()) ?? 1024,
'chroma_key': <String, dynamic>{
'hsv_min': _parseIntList(_hsvMinController.text),
'hsv_max': _parseIntList(_hsvMaxController.text),
},
'perspective_correction': <String, dynamic>{
'points': jsonDecode(_pointsController.text.trim().isEmpty ? '[]' : _pointsController.text.trim()),
},
};
await context.read<DeviceProvider>().httpApiService.updateConfig(nextConfig);
_fullConfig = nextConfig;
if (!mounted) {
return;
}
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('显示设置已保存')),
);
}
void _applyDisplayConfig(Map<String, dynamic> display) {
_isFullscreen = display['fullscreen'] as bool? ?? false;
_titleController.text = display['window_title']?.toString() ?? '';
_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>[]);
}
List<int> _parseIntList(String raw) {
return raw
.split(',')
.map((item) => int.tryParse(item.trim()) ?? 0)
.toList(growable: false);
}
}
class _InfoRow extends StatelessWidget {
const _InfoRow({required this.label, required this.value});
final String label;
final String value;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.only(bottom: AppSpacing.sm),
child: Row(
children: [
Expanded(child: Text(label, style: Theme.of(context).textTheme.bodyMedium)),
Text(value, style: Theme.of(context).textTheme.bodyLarge),
],
),
);
}
}

View File

@@ -0,0 +1,194 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../providers/player_provider.dart';
import '../theme/app_colors.dart';
class TriggerScreen extends StatefulWidget {
const TriggerScreen({super.key});
@override
State<TriggerScreen> createState() => _TriggerScreenState();
}
class _TriggerScreenState extends State<TriggerScreen> {
final TextEditingController _triggerController = TextEditingController();
final TextEditingController _valueController = TextEditingController();
String? _selectedScene;
static const List<_PresetTrigger> _presets = <_PresetTrigger>[
_PresetTrigger(label: '语音唤醒', name: 'wake', icon: Icons.mic_rounded),
_PresetTrigger(label: '按钮 1', name: 'button1', icon: Icons.filter_1_rounded),
_PresetTrigger(label: '按钮 2', name: 'button2', icon: Icons.filter_2_rounded),
_PresetTrigger(label: '触摸传感器', name: 'touch', icon: Icons.touch_app_rounded),
];
@override
void dispose() {
_triggerController.dispose();
_valueController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final provider = context.watch<PlayerProvider>();
_selectedScene ??= provider.sceneOptions.isNotEmpty ? provider.sceneOptions.first : null;
return Scaffold(
appBar: AppBar(title: const Text('状态机触发')),
body: ListView(
padding: const EdgeInsets.all(AppSpacing.md),
children: [
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.sm),
Text(
provider.currentState,
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
color: AppColors.primary,
),
),
],
),
),
),
const SizedBox(height: AppSpacing.lg),
Text('预设触发器', style: Theme.of(context).textTheme.headlineSmall),
const SizedBox(height: AppSpacing.md),
GridView.count(
crossAxisCount: 2,
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
crossAxisSpacing: AppSpacing.md,
mainAxisSpacing: AppSpacing.md,
childAspectRatio: 1.35,
children: _presets.map((preset) {
return InkWell(
borderRadius: BorderRadius.circular(AppRadius.large),
onTap: provider.isLoading
? null
: () => context.read<PlayerProvider>().triggerEvent(preset.name),
child: Ink(
decoration: BoxDecoration(
color: AppColors.card,
borderRadius: BorderRadius.circular(AppRadius.large),
border: Border.all(color: AppColors.border),
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(preset.icon, color: AppColors.accent, size: 32),
const SizedBox(height: AppSpacing.sm),
Text(preset.label),
],
),
),
);
}).toList(growable: false),
),
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),
TextField(
controller: _triggerController,
decoration: const InputDecoration(labelText: '触发器名称'),
),
const SizedBox(height: AppSpacing.md),
TextField(
controller: _valueController,
decoration: const InputDecoration(labelText: '可选参数值'),
),
const SizedBox(height: AppSpacing.md),
SizedBox(
width: double.infinity,
child: FilledButton.icon(
onPressed: provider.isLoading ? null : _handleCustomTrigger,
icon: const Icon(Icons.send_rounded),
label: 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>(
value: provider.sceneOptions.contains(_selectedScene) ? _selectedScene : null,
items: provider.sceneOptions
.map(
(scene) => DropdownMenuItem<String>(
value: scene,
child: Text(scene),
),
)
.toList(growable: false),
onChanged: (value) => setState(() => _selectedScene = value),
decoration: const InputDecoration(labelText: '选择场景'),
),
const SizedBox(height: AppSpacing.md),
SizedBox(
width: double.infinity,
child: FilledButton.tonal(
onPressed: provider.isLoading || _selectedScene == null
? null
: () => context.read<PlayerProvider>().switchScene(_selectedScene!),
child: const Text('切换场景'),
),
),
],
),
),
),
],
),
);
}
void _handleCustomTrigger() {
final name = _triggerController.text.trim();
final value = _valueController.text.trim();
if (name.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('请输入触发器名称')),
);
return;
}
context.read<PlayerProvider>().triggerEvent(
name,
value: value.isEmpty ? null : value,
);
}
}
class _PresetTrigger {
const _PresetTrigger({
required this.label,
required this.name,
required this.icon,
});
final String label;
final String name;
final IconData icon;
}

View File

@@ -0,0 +1,215 @@
import 'dart:async';
import 'dart:convert';
import 'package:flutter_blue_plus/flutter_blue_plus.dart';
import '../models/ble_models.dart';
class BleService {
static final Guid _provisionServiceUuid =
Guid('12345678-1234-5678-1234-56789abcdef0');
static final Guid _ssidCharacteristicUuid =
Guid('12345678-1234-5678-1234-56789abcdef1');
static final Guid _passwordCharacteristicUuid =
Guid('12345678-1234-5678-1234-56789abcdef2');
static final Guid _commandCharacteristicUuid =
Guid('12345678-1234-5678-1234-56789abcdef3');
static final Guid _statusCharacteristicUuid =
Guid('12345678-1234-5678-1234-56789abcdef4');
BluetoothDevice? _connectedDevice;
BluetoothCharacteristic? _ssidCharacteristic;
BluetoothCharacteristic? _passwordCharacteristic;
BluetoothCharacteristic? _commandCharacteristic;
BluetoothCharacteristic? _statusCharacteristic;
StreamSubscription<List<ScanResult>>? _scanSubscription;
Stream<List<BleDevice>> scanForShowenDevices({
Duration timeout = const Duration(seconds: 6),
}) {
final controller = StreamController<List<BleDevice>>();
final seen = <String, BleDevice>{};
unawaited(_scanSubscription?.cancel());
_scanSubscription = FlutterBluePlus.scanResults.listen((results) {
for (final result in results) {
final name = _deviceName(result);
if (name.isEmpty || !name.toLowerCase().contains('showen')) {
continue;
}
final id = result.device.remoteId.str;
seen[id] = BleDevice(name: name, id: id, rssi: result.rssi);
}
final devices = seen.values.toList(growable: false)
..sort((a, b) => b.rssi.compareTo(a.rssi));
if (!controller.isClosed) {
controller.add(devices);
}
});
unawaited(FlutterBluePlus.startScan(timeout: timeout));
Future<void>.delayed(timeout, () async {
await FlutterBluePlus.stopScan();
if (!controller.isClosed) {
await controller.close();
}
});
controller.onCancel = () async {
await FlutterBluePlus.stopScan();
await _scanSubscription?.cancel();
_scanSubscription = null;
};
return controller.stream;
}
Future<void> connectToDevice(BleDevice device) async {
await disconnect();
final bluetoothDevice = BluetoothDevice.fromId(device.id);
await bluetoothDevice.connect(timeout: const Duration(seconds: 12));
_connectedDevice = bluetoothDevice;
await _discoverCharacteristics(bluetoothDevice);
}
Future<Stream<BleStatus>> subscribeToStatus() async {
final characteristic = _statusCharacteristic;
if (characteristic == null) {
return Stream<BleStatus>.value(
const BleStatus(
ok: false,
action: 'status',
error: '未发现 BLE 状态特征值',
),
);
}
await characteristic.setNotifyValue(true);
return characteristic.lastValueStream
.where((value) => value.isNotEmpty)
.map((value) {
final raw = utf8.decode(value, allowMalformed: true);
try {
return BleStatus.fromRawJson(raw);
} catch (_) {
return BleStatus(
ok: true,
action: 'status',
state: raw,
);
}
});
}
Future<BleStatus> provisionWifi(
String ssid,
String password, {
Duration timeout = const Duration(seconds: 30),
}) async {
final ssidChar = _ssidCharacteristic;
final passwordChar = _passwordCharacteristic;
final command = _commandCharacteristic;
if (_connectedDevice == null) {
throw StateError('未连接 BLE 设备');
}
if (ssidChar == null || passwordChar == null || command == null) {
throw StateError('未发现 BLE 配网特征值');
}
final statusStream = await subscribeToStatus();
final completer = Completer<BleStatus>();
late final StreamSubscription<BleStatus> subscription;
subscription = statusStream.listen((status) {
if (!status.isQueued && !completer.isCompleted) {
completer.complete(status);
}
}, onError: (Object error, StackTrace stackTrace) {
if (!completer.isCompleted) {
completer.completeError(error, stackTrace);
}
});
await ssidChar.write(
utf8.encode(ssid),
withoutResponse: ssidChar.properties.writeWithoutResponse,
);
await passwordChar.write(
utf8.encode(password),
withoutResponse: passwordChar.properties.writeWithoutResponse,
);
await command.write(
utf8.encode('connect'),
withoutResponse: command.properties.writeWithoutResponse,
);
try {
return await completer.future.timeout(timeout);
} finally {
await subscription.cancel();
}
}
Future<void> disconnect() async {
await _scanSubscription?.cancel();
_scanSubscription = null;
final device = _connectedDevice;
_connectedDevice = null;
_ssidCharacteristic = null;
_passwordCharacteristic = null;
_commandCharacteristic = null;
_statusCharacteristic = null;
if (device != null) {
await device.disconnect();
}
}
Future<void> dispose() => disconnect();
Future<void> _discoverCharacteristics(BluetoothDevice device) async {
final services = await device.discoverServices();
BluetoothCharacteristic? ssid;
BluetoothCharacteristic? password;
BluetoothCharacteristic? command;
BluetoothCharacteristic? status;
for (final service in services) {
final shouldInspect = service.uuid == _provisionServiceUuid ||
services.length == 1 ||
service.characteristics.isNotEmpty;
if (!shouldInspect) {
continue;
}
for (final characteristic in service.characteristics) {
if (characteristic.uuid == _ssidCharacteristicUuid) {
ssid = characteristic;
} else if (characteristic.uuid == _passwordCharacteristicUuid) {
password = characteristic;
} else if (characteristic.uuid == _commandCharacteristicUuid) {
command = characteristic;
} else if (characteristic.uuid == _statusCharacteristicUuid) {
status = characteristic;
}
}
}
_ssidCharacteristic = ssid;
_passwordCharacteristic = password;
_commandCharacteristic = command;
_statusCharacteristic = status;
}
String _deviceName(ScanResult result) {
final platformName = result.device.platformName.trim();
if (platformName.isNotEmpty) {
return platformName;
}
final advertisedName = result.advertisementData.advName.trim();
return advertisedName;
}
}

View File

@@ -0,0 +1,503 @@
import 'dart:convert';
import 'dart:io';
import 'dart:typed_data';
import 'package:http/http.dart' as http;
import '../models/api_response.dart';
import '../models/ble_status.dart';
import '../models/player_status.dart';
import '../models/video_item.dart';
import '../models/wifi_network.dart';
import '../models/wifi_status.dart';
class HttpApiService {
HttpApiService({required String baseUrl, http.Client? client})
: _baseUrl = _normalizeBaseUrl(baseUrl),
_client = client ?? http.Client();
final http.Client _client;
String _baseUrl;
String get baseUrl => _baseUrl;
set baseUrl(String value) {
_baseUrl = _normalizeBaseUrl(value);
}
Uri _uri(String path, [Map<String, String>? queryParameters]) {
final normalizedPath = path.startsWith('/') ? path : '/$path';
return Uri.parse('$_baseUrl$normalizedPath').replace(
queryParameters: queryParameters == null || queryParameters.isEmpty
? null
: queryParameters,
);
}
Future<String> fetchConsolePage() async {
final response = await _client.get(_uri('/'));
_ensureSuccess(response);
return response.body;
}
Future<String> fetchConsoleIndexHtml() async {
final response = await _client.get(_uri('/index.html'));
_ensureSuccess(response);
return response.body;
}
Future<PlayerStatus> getStatus() => getPlaybackStatus();
Future<PlayerStatus> getPlaybackStatus() async {
final response = await _client.get(_uri('/api/status'));
_ensureSuccess(response);
return PlayerStatus.fromJson(_decodeMap(response.body));
}
Future<ApiResponse> play() => _postCommand('/api/play');
Future<ApiResponse> pause() => _postCommand('/api/pause');
Future<ApiResponse> next() => _postCommand('/api/next');
Future<ApiResponse> previous() => _postCommand('/api/previous');
Future<ApiResponse> goto(int index) => _postCommand('/api/goto/$index');
Future<ApiResponse> gotoIndex(int index) => goto(index);
Future<List<String>> getPlaylist() async {
final response = await _client.get(_uri('/api/playlist'));
_ensureSuccess(response);
final decoded = _decodeJson(response.body);
if (decoded is! List) {
return const <String>[];
}
return decoded.map<String>((dynamic item) {
if (item is String) {
return item;
}
if (item is Map<String, dynamic>) {
return item['name']?.toString() ?? jsonEncode(item);
}
if (item is Map) {
return item['name']?.toString() ?? jsonEncode(item);
}
return item.toString();
}).toList(growable: false);
}
Future<ApiResponse> changeScene(String name) {
return _postCommand('/api/scene/${Uri.encodeComponent(name)}');
}
Future<ApiResponse> switchScene(String name) => changeScene(name);
Future<ApiResponse> trigger(String name, [String? value]) {
final suffix = value == null || value.isEmpty
? ''
: '/${Uri.encodeComponent(value)}';
return _postCommand('/api/trigger/${Uri.encodeComponent(name)}$suffix');
}
Future<ApiResponse> triggerEvent(String name, {String? value}) {
return trigger(name, value);
}
Future<WifiStatus> getWifiStatus() async {
final response = await _client.get(_uri('/api/wifi/status'));
_ensureSuccess(response);
return WifiStatus.fromJson(_decodeMap(response.body));
}
Future<List<WifiNetwork>> scanWifi() async {
try {
final response = await _client.get(_uri('/api/wifi/scan'));
_ensureSuccess(response);
return _decodeWifiNetworks(response.body);
} on ApiException {
final response = await _client.post(_uri('/api/wifi/scan'));
_ensureSuccess(response);
return _decodeWifiNetworks(response.body);
}
}
Future<List<WifiNetwork>> scanWifiViaGet() => scanWifi();
Future<List<WifiNetwork>> scanWifiViaPost() => scanWifi();
Future<ApiResponse> connectWifi(String ssid, String password) async {
final response = await _client.post(
_uri('/api/wifi/connect'),
headers: _jsonHeaders,
body: jsonEncode(<String, dynamic>{
'ssid': ssid,
'password': password,
}),
);
_ensureSuccess(response);
return ApiResponse.fromJson(_decodeMap(response.body));
}
Future<ApiResponse> startAP([String? ssid, String? password]) async {
final body = <String, dynamic>{
if (ssid != null && ssid.isNotEmpty) 'ssid': ssid,
if (password != null && password.isNotEmpty) 'password': password,
};
try {
final response = await _client.post(
_uri('/api/wifi/ap/start'),
headers: _jsonHeaders,
body: jsonEncode(body),
);
_ensureSuccess(response);
return ApiResponse.fromJson(_decodeMap(response.body));
} on ApiException {
final response = await _client.post(
_uri('/api/wifi/hotspot/start'),
headers: _jsonHeaders,
body: jsonEncode(body),
);
_ensureSuccess(response);
return ApiResponse.fromJson(_decodeMap(response.body));
}
}
Future<ApiResponse> startAccessPoint({String? ssid, String? password}) {
return startAP(ssid, password);
}
Future<ApiResponse> startHotspot({String? ssid, String? password}) {
return startAP(ssid, password);
}
Future<ApiResponse> stopAP() async {
try {
return await _postCommand('/api/wifi/ap/stop');
} on ApiException {
return _postCommand('/api/wifi/hotspot/stop');
}
}
Future<ApiResponse> stopAccessPoint() => stopAP();
Future<ApiResponse> stopHotspot() => stopAP();
Future<BleServiceStatus> getBleStatus() async {
final response = await _client.get(_uri('/api/ble/status'));
_ensureSuccess(response);
return BleServiceStatus.fromJson(_decodeMap(response.body));
}
Future<ApiResponse> startBle([String? deviceName]) async {
final response = await _client.post(
_uri('/api/ble/start'),
headers: _jsonHeaders,
body: jsonEncode(<String, dynamic>{
if (deviceName != null && deviceName.isNotEmpty)
'device_name': deviceName,
}),
);
_ensureSuccess(response);
return ApiResponse.fromJson(_decodeMap(response.body));
}
Future<ApiResponse> stopBle() => _postCommand('/api/ble/stop');
Future<List<VideoItem>> getVideos() async {
final response = await _client.get(_uri('/api/videos'));
_ensureSuccess(response);
final decoded = _decodeJson(response.body);
if (decoded is! List) {
return const <VideoItem>[];
}
return decoded.map<VideoItem>((dynamic item) {
if (item is Map<String, dynamic>) {
return VideoItem.fromJson(item);
}
if (item is Map) {
return VideoItem.fromJson(Map<String, dynamic>.from(item));
}
return const VideoItem(name: '', size: 0);
}).where((item) => item.name.isNotEmpty).toList(growable: false);
}
Future<ApiResponse> uploadVideo(File file) {
return _uploadSingleFile('/api/videos/upload', file);
}
Future<ApiResponse> uploadVideos(List<String> filePaths) async {
if (filePaths.isEmpty) {
throw const ApiException('未选择上传文件');
}
final request = http.MultipartRequest('POST', _uri('/api/videos/upload'));
for (final filePath in filePaths) {
request.files.add(await http.MultipartFile.fromPath('file', filePath));
}
final response = await http.Response.fromStream(await request.send());
_ensureSuccess(response);
return ApiResponse.fromJson(_decodeMap(response.body));
}
Future<ApiResponse> deleteVideo(String name) {
return _deleteCommand('/api/videos/${Uri.encodeComponent(name)}');
}
Future<Map<String, dynamic>> getConfig() async {
final response = await _client.get(_uri('/api/config'));
_ensureSuccess(response);
return _decodeMap(response.body);
}
Future<ApiResponse> updateConfig(Map<String, dynamic> config) async {
final response = await _client.post(
_uri('/api/config'),
headers: _jsonHeaders,
body: jsonEncode(config),
);
_ensureSuccess(response);
return ApiResponse.fromJson(_decodeMap(response.body));
}
Future<Map<String, dynamic>> getDisplayConfig() async {
final response = await _client.get(_uri('/api/config/display'));
_ensureSuccess(response);
return _decodeMap(response.body);
}
Future<Map<String, dynamic>> getAvailableConfigs() async {
final response = await _client.get(_uri('/api/config/available'));
_ensureSuccess(response);
return _decodeMap(response.body);
}
Future<ApiResponse> switchConfig(String filename) async {
final response = await _client.post(
_uri('/api/config/switch'),
headers: _jsonHeaders,
body: jsonEncode(<String, dynamic>{'filename': filename}),
);
_ensureSuccess(response);
return ApiResponse.fromJson(_decodeMap(response.body));
}
Future<List<Map<String, dynamic>>> listFiles(String dirKey, [String? path]) async {
final response = await _client.get(
_uri('/api/files/$dirKey', _pathQuery(path)),
);
_ensureSuccess(response);
final decoded = _decodeJson(response.body);
if (decoded is! List) {
return const <Map<String, dynamic>>[];
}
return decoded
.whereType<Map>()
.map((entry) => Map<String, dynamic>.from(entry))
.toList(growable: false);
}
Future<ApiResponse> uploadFile(String dirKey, File file, [String? path]) {
return _uploadSingleFile(
'/api/files/$dirKey/upload',
file,
directoryPath: path,
);
}
Future<Uint8List> downloadFile(String dirKey, String path) async {
final response = await _client.get(
_uri('/api/files/$dirKey/download', <String, String>{'path': path}),
);
_ensureSuccess(response, expectJson: false);
return response.bodyBytes;
}
Future<ApiResponse> deleteFile(String dirKey, String path) async {
final response = await _client.post(
_uri('/api/files/$dirKey/delete'),
headers: _jsonHeaders,
body: jsonEncode(<String, dynamic>{'path': path}),
);
_ensureSuccess(response);
return ApiResponse.fromJson(_decodeMap(response.body));
}
Future<ApiResponse> mkdir(String dirKey, String path) async {
final response = await _client.post(
_uri('/api/files/$dirKey/mkdir'),
headers: _jsonHeaders,
body: jsonEncode(<String, dynamic>{'path': path}),
);
_ensureSuccess(response);
return ApiResponse.fromJson(_decodeMap(response.body));
}
Future<List<Map<String, dynamic>>> getPlugins() async {
final response = await _client.get(_uri('/api/plugins'));
_ensureSuccess(response);
final decoded = _decodeJson(response.body);
if (decoded is! List) {
return const <Map<String, dynamic>>[];
}
return decoded
.whereType<Map>()
.map((plugin) => Map<String, dynamic>.from(plugin))
.toList(growable: false);
}
Future<ApiResponse> enablePlugin(String id) {
return _postCommand('/api/plugins/${Uri.encodeComponent(id)}/enable');
}
Future<ApiResponse> disablePlugin(String id) {
return _postCommand('/api/plugins/${Uri.encodeComponent(id)}/disable');
}
Future<ApiResponse> installPlugin(String id, [String? version]) async {
final response = await _client.post(
_uri('/api/plugins/install'),
headers: _jsonHeaders,
body: jsonEncode(<String, dynamic>{
'id': id,
if (version != null && version.isNotEmpty) 'version': version,
}),
);
_ensureSuccess(response);
return ApiResponse.fromJson(_decodeMap(response.body));
}
Future<ApiResponse> _uploadSingleFile(
String endpoint,
File file, {
String? directoryPath,
}) async {
final request = http.MultipartRequest(
'POST',
_uri(endpoint, _pathQuery(directoryPath)),
);
request.files.add(await http.MultipartFile.fromPath('file', file.path));
final response = await http.Response.fromStream(await request.send());
_ensureSuccess(response);
return ApiResponse.fromJson(_decodeMap(response.body));
}
Future<ApiResponse> _postCommand(String path) async {
final response = await _client.post(_uri(path));
_ensureSuccess(response);
return ApiResponse.fromJson(_decodeMap(response.body));
}
Future<ApiResponse> _deleteCommand(String path) async {
final response = await _client.delete(_uri(path));
_ensureSuccess(response);
return ApiResponse.fromJson(_decodeMap(response.body));
}
List<WifiNetwork> _decodeWifiNetworks(String body) {
final decoded = _decodeJson(body);
if (decoded is! List) {
return const <WifiNetwork>[];
}
return decoded.map<WifiNetwork>((dynamic item) {
if (item is Map<String, dynamic>) {
return WifiNetwork.fromJson(item);
}
if (item is Map) {
return WifiNetwork.fromJson(Map<String, dynamic>.from(item));
}
return const WifiNetwork(ssid: '', signal: 0, security: 'Unknown');
}).where((network) => network.ssid.isNotEmpty).toList(growable: false);
}
dynamic _decodeJson(String body) {
try {
return jsonDecode(body);
} on FormatException catch (error) {
throw ApiException('JSON 解析失败: ${error.message}');
}
}
Map<String, dynamic> _decodeMap(String body) {
final decoded = _decodeJson(body);
if (decoded is Map<String, dynamic>) {
return decoded;
}
if (decoded is Map) {
return Map<String, dynamic>.from(decoded);
}
throw const ApiException('期望返回 JSON 对象');
}
Map<String, String>? _pathQuery(String? path) {
if (path == null || path.isEmpty) {
return null;
}
return <String, String>{'path': path};
}
void _ensureSuccess(http.Response response, {bool expectJson = true}) {
if (response.statusCode >= 200 && response.statusCode < 300) {
return;
}
if (!expectJson) {
throw ApiException(
'请求失败 (${response.statusCode}): ${response.body}',
statusCode: response.statusCode,
);
}
String message = response.body;
try {
final decoded = _decodeJson(response.body);
if (decoded is Map) {
message = decoded['message']?.toString() ?? response.body;
}
} on ApiException {
message = response.body;
}
throw ApiException(message, statusCode: response.statusCode);
}
Map<String, String> get _jsonHeaders => const <String, String>{
'Content-Type': 'application/json',
'Accept': 'application/json',
};
void dispose() {
_client.close();
}
static String _normalizeBaseUrl(String raw) {
final trimmed = raw.trim();
if (trimmed.isEmpty) {
throw const ApiException('baseUrl 不能为空');
}
final withScheme = trimmed.startsWith('http://') || trimmed.startsWith('https://')
? trimmed
: 'http://$trimmed';
return withScheme.endsWith('/')
? withScheme.substring(0, withScheme.length - 1)
: withScheme;
}
}
class ApiException implements Exception {
const ApiException(this.message, {this.statusCode});
final String message;
final int? statusCode;
@override
String toString() => message;
}

View File

@@ -0,0 +1,172 @@
import 'dart:async';
import 'dart:convert';
import 'package:web_socket_channel/web_socket_channel.dart';
import '../models/app_event.dart';
enum SocketConnectionStatus { disconnected, connecting, connected }
class WebSocketService {
WebSocketChannel? _channel;
StreamSubscription<dynamic>? _subscription;
Timer? _reconnectTimer;
String? _deviceIp;
bool _manualDisconnect = false;
SocketConnectionStatus _connectionStatus = SocketConnectionStatus.disconnected;
final StreamController<AppEvent> _eventController =
StreamController<AppEvent>.broadcast();
final StreamController<Map<String, dynamic>> _statusController =
StreamController<Map<String, dynamic>>.broadcast();
final StreamController<Map<String, dynamic>> _stateController =
StreamController<Map<String, dynamic>>.broadcast();
final StreamController<Map<String, dynamic>> _configController =
StreamController<Map<String, dynamic>>.broadcast();
final StreamController<Map<String, dynamic>> _wifiController =
StreamController<Map<String, dynamic>>.broadcast();
final StreamController<Map<String, dynamic>> _bleController =
StreamController<Map<String, dynamic>>.broadcast();
final StreamController<SocketConnectionStatus> _connectionController =
StreamController<SocketConnectionStatus>.broadcast();
Stream<AppEvent> get events => _eventController.stream;
Stream<Map<String, dynamic>> get onStatusUpdate => _statusController.stream;
Stream<Map<String, dynamic>> get onStateUpdate => _stateController.stream;
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 onConnectionChanged =>
_connectionController.stream;
SocketConnectionStatus get connectionStatus => _connectionStatus;
bool get isConnected => _connectionStatus == SocketConnectionStatus.connected;
Future<void> connect(String deviceIp) async {
_manualDisconnect = false;
_deviceIp = _normalizeDeviceIp(deviceIp);
_reconnectTimer?.cancel();
await _subscription?.cancel();
await _channel?.sink.close();
_setConnectionStatus(SocketConnectionStatus.connecting);
final url = Uri.parse('ws://$_deviceIp:8080/ws');
_channel = WebSocketChannel.connect(url);
_subscription = _channel!.stream.listen(
_handleMessage,
onDone: _handleSocketClosed,
onError: (_) => _handleSocketClosed(),
cancelOnError: true,
);
_setConnectionStatus(SocketConnectionStatus.connected);
}
void sendCommand(Map<String, dynamic> command) {
if (!isConnected || _channel == null) {
throw StateError('WebSocket 未连接');
}
_channel!.sink.add(jsonEncode(command));
}
Future<void> reconnect() async {
final deviceIp = _deviceIp;
if (deviceIp == null || deviceIp.isEmpty || _manualDisconnect) {
return;
}
_reconnectTimer?.cancel();
_reconnectTimer = Timer(const Duration(seconds: 2), () {
unawaited(connect(deviceIp));
});
}
Future<void> disconnect() async {
_manualDisconnect = true;
_reconnectTimer?.cancel();
await _subscription?.cancel();
_subscription = null;
await _channel?.sink.close();
_channel = null;
_setConnectionStatus(SocketConnectionStatus.disconnected);
}
Future<void> dispose() async {
await disconnect();
await _eventController.close();
await _statusController.close();
await _stateController.close();
await _configController.close();
await _wifiController.close();
await _bleController.close();
await _connectionController.close();
}
void _handleMessage(dynamic data) {
final raw = data is String ? data : utf8.decode(data as List<int>);
final decoded = jsonDecode(raw);
if (decoded is! Map) {
return;
}
final event = AppEvent.fromJson(Map<String, dynamic>.from(decoded));
_eventController.add(event);
switch (event.type) {
case 'status_update':
_statusController.add(event.payload);
break;
case 'state_update':
_stateController.add(event.payload);
break;
case 'config_update':
_configController.add(event.payload);
break;
case 'wifi_update':
_wifiController.add(event.payload);
break;
case 'ble_update':
_bleController.add(event.payload);
break;
}
}
void _handleSocketClosed() {
_channel = null;
_subscription = null;
_setConnectionStatus(SocketConnectionStatus.disconnected);
unawaited(reconnect());
}
void _setConnectionStatus(SocketConnectionStatus status) {
_connectionStatus = status;
_connectionController.add(status);
}
String _normalizeDeviceIp(String raw) {
var value = raw.trim();
if (value.startsWith('ws://')) {
value = value.substring(5);
} else if (value.startsWith('wss://')) {
value = value.substring(6);
} else if (value.startsWith('http://')) {
value = value.substring(7);
} else if (value.startsWith('https://')) {
value = value.substring(8);
}
final slashIndex = value.indexOf('/');
if (slashIndex >= 0) {
value = value.substring(0, slashIndex);
}
final colonIndex = value.indexOf(':');
if (colonIndex >= 0) {
value = value.substring(0, colonIndex);
}
return value;
}
}

View File

@@ -0,0 +1,39 @@
import 'package:flutter/material.dart';
class AppColors {
static const Color primary = Color(0xFF6366F1);
static const Color secondary = Color(0xFF8B5CF6);
static const Color accent = Color(0xFFEC4899);
static const Color success = Color(0xFF10B981);
static const Color warning = Color(0xFFF59E0B);
static const Color error = Color(0xFFEF4444);
static const Color info = Color(0xFF3B82F6);
static const Color background = Color(0xFF0F172A);
static const Color card = Color(0xFF1E293B);
static const Color border = Color(0xFF334155);
static const Color textPrimary = Color(0xFFF1F5F9);
static const Color textSecondary = Color(0xFF94A3B8);
static const LinearGradient primaryGradient = LinearGradient(
colors: [primary, secondary],
begin: Alignment.centerLeft,
end: Alignment.centerRight,
);
}
class AppRadius {
static const double small = 4;
static const double medium = 8;
static const double large = 16;
}
class AppSpacing {
static const double xs = 4;
static const double sm = 8;
static const double md = 16;
static const double lg = 24;
static const double xl = 32;
static const double xxl = 48;
}

View File

@@ -0,0 +1,192 @@
import 'package:flutter/material.dart';
import 'app_colors.dart';
class AppTheme {
static ThemeData dark() {
const colorScheme = ColorScheme.dark(
primary: AppColors.primary,
secondary: AppColors.secondary,
surface: AppColors.card,
error: AppColors.error,
onPrimary: Colors.white,
onSecondary: Colors.white,
onSurface: AppColors.textPrimary,
onError: Colors.white,
);
final base = ThemeData(
useMaterial3: true,
brightness: Brightness.dark,
colorScheme: colorScheme,
scaffoldBackgroundColor: AppColors.background,
canvasColor: AppColors.background,
splashColor: AppColors.primary.withOpacity(0.12),
highlightColor: AppColors.primary.withOpacity(0.08),
dividerColor: AppColors.border,
cardColor: AppColors.card,
fontFamily: 'Noto Sans SC',
textTheme: _textTheme,
appBarTheme: const AppBarTheme(
centerTitle: false,
elevation: 0,
backgroundColor: AppColors.background,
foregroundColor: AppColors.textPrimary,
titleTextStyle: TextStyle(
fontSize: 20,
fontWeight: FontWeight.w600,
color: AppColors.textPrimary,
fontFamily: 'Noto Sans SC',
fontFamilyFallback: ['Inter'],
),
),
cardTheme: CardThemeData(
color: AppColors.card,
elevation: 6,
shadowColor: Colors.black.withOpacity(0.20),
margin: EdgeInsets.zero,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(AppRadius.large),
side: const BorderSide(color: AppColors.border),
),
),
inputDecorationTheme: InputDecorationTheme(
filled: true,
fillColor: AppColors.background,
contentPadding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 12,
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(AppRadius.medium),
borderSide: const BorderSide(color: AppColors.border),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(AppRadius.medium),
borderSide: const BorderSide(color: AppColors.primary),
),
hintStyle: const TextStyle(color: AppColors.textSecondary),
),
navigationBarTheme: NavigationBarThemeData(
backgroundColor: AppColors.card,
height: 64,
indicatorColor: AppColors.primary.withOpacity(0.18),
labelTextStyle: WidgetStateProperty.all(
const TextStyle(
fontSize: 12,
fontWeight: FontWeight.w500,
fontFamily: 'Inter',
fontFamilyFallback: ['Noto Sans SC'],
),
),
iconTheme: WidgetStateProperty.resolveWith(
(states) => IconThemeData(
size: 24,
color: states.contains(WidgetState.selected)
? AppColors.textPrimary
: AppColors.textSecondary,
),
),
),
filledButtonTheme: FilledButtonThemeData(
style: FilledButton.styleFrom(
minimumSize: const Size.fromHeight(48),
backgroundColor: AppColors.primary,
foregroundColor: Colors.white,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(AppRadius.medium),
),
textStyle: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
fontFamily: 'Inter',
fontFamilyFallback: ['Noto Sans SC'],
),
),
),
outlinedButtonTheme: OutlinedButtonThemeData(
style: OutlinedButton.styleFrom(
minimumSize: const Size.fromHeight(48),
foregroundColor: AppColors.primary,
side: const BorderSide(color: AppColors.primary),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(AppRadius.medium),
),
),
),
switchTheme: SwitchThemeData(
trackOutlineColor: WidgetStateProperty.all(Colors.transparent),
thumbColor: WidgetStateProperty.resolveWith(
(states) => states.contains(WidgetState.selected)
? AppColors.primary
: AppColors.textSecondary,
),
trackColor: WidgetStateProperty.resolveWith(
(states) => states.contains(WidgetState.selected)
? AppColors.primary.withOpacity(0.4)
: AppColors.border,
),
),
sliderTheme: const SliderThemeData(
activeTrackColor: AppColors.primary,
inactiveTrackColor: AppColors.border,
thumbColor: AppColors.primary,
trackHeight: 4,
thumbShape: RoundSliderThumbShape(enabledThumbRadius: 10),
),
);
return base.copyWith(
textSelectionTheme: const TextSelectionThemeData(
cursorColor: AppColors.primary,
selectionColor: Color(0x446366F1),
selectionHandleColor: AppColors.primary,
),
);
}
static const TextTheme _textTheme = TextTheme(
headlineLarge: TextStyle(
fontSize: 32,
fontWeight: FontWeight.w700,
color: AppColors.textPrimary,
fontFamily: 'Inter',
fontFamilyFallback: ['Noto Sans SC'],
),
headlineMedium: TextStyle(
fontSize: 24,
fontWeight: FontWeight.w700,
color: AppColors.textPrimary,
fontFamily: 'Inter',
fontFamilyFallback: ['Noto Sans SC'],
),
headlineSmall: TextStyle(
fontSize: 20,
fontWeight: FontWeight.w600,
color: AppColors.textPrimary,
fontFamily: 'Inter',
fontFamilyFallback: ['Noto Sans SC'],
),
bodyLarge: TextStyle(
fontSize: 16,
height: 1.5,
color: AppColors.textPrimary,
fontFamily: 'Noto Sans SC',
fontFamilyFallback: ['Inter'],
),
bodyMedium: TextStyle(
fontSize: 14,
height: 1.4,
color: AppColors.textSecondary,
fontFamily: 'Noto Sans SC',
fontFamilyFallback: ['Inter'],
),
bodySmall: TextStyle(
fontSize: 12,
height: 1.3,
color: AppColors.textSecondary,
fontFamily: 'Noto Sans SC',
fontFamilyFallback: ['Inter'],
),
);
}

View File

@@ -0,0 +1,46 @@
import 'package:flutter/material.dart';
import '../theme/app_colors.dart';
class ControlButton extends StatelessWidget {
const ControlButton({
required this.label,
required this.icon,
required this.onPressed,
this.isFilled = true,
super.key,
});
final String label;
final IconData icon;
final VoidCallback? onPressed;
final bool isFilled;
@override
Widget build(BuildContext context) {
if (isFilled) {
return DecoratedBox(
decoration: BoxDecoration(
gradient: AppColors.primaryGradient,
borderRadius: BorderRadius.circular(AppRadius.medium),
),
child: FilledButton.icon(
style: FilledButton.styleFrom(
backgroundColor: Colors.transparent,
shadowColor: Colors.transparent,
minimumSize: const Size.fromHeight(48),
),
onPressed: onPressed,
icon: Icon(icon),
label: Text(label),
),
);
}
return OutlinedButton.icon(
onPressed: onPressed,
icon: Icon(icon),
label: Text(label),
);
}
}

View File

@@ -0,0 +1,55 @@
import 'package:flutter/material.dart';
import '../theme/app_colors.dart';
class StatusCard extends StatelessWidget {
const StatusCard({
required this.title,
required this.value,
required this.subtitle,
required this.icon,
required this.accentColor,
super.key,
});
final String title;
final String value;
final String subtitle;
final IconData icon;
final Color accentColor;
@override
Widget build(BuildContext context) {
return Card(
child: Padding(
padding: const EdgeInsets.all(AppSpacing.md),
child: Row(
children: [
Container(
width: 48,
height: 48,
decoration: BoxDecoration(
color: accentColor.withOpacity(0.16),
borderRadius: BorderRadius.circular(AppRadius.medium),
),
child: Icon(icon, color: accentColor),
),
const SizedBox(width: AppSpacing.md),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(title, style: Theme.of(context).textTheme.bodyMedium),
const SizedBox(height: AppSpacing.xs),
Text(value, style: Theme.of(context).textTheme.headlineSmall),
const SizedBox(height: AppSpacing.xs),
Text(subtitle, style: Theme.of(context).textTheme.bodySmall),
],
),
),
],
),
),
);
}
}

View File

@@ -0,0 +1,40 @@
import 'package:flutter/material.dart';
import '../models/wifi_network.dart';
import '../theme/app_colors.dart';
class WifiListTile extends StatelessWidget {
const WifiListTile({
required this.network,
required this.onTap,
super.key,
});
final WifiNetwork network;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return Card(
child: ListTile(
onTap: onTap,
contentPadding: const EdgeInsets.symmetric(
horizontal: AppSpacing.md,
vertical: AppSpacing.xs,
),
leading: Container(
width: 48,
height: 48,
decoration: BoxDecoration(
color: AppColors.info.withOpacity(0.14),
borderRadius: BorderRadius.circular(AppRadius.medium),
),
child: const Icon(Icons.wifi_rounded, color: AppColors.info),
),
title: Text(network.ssid),
subtitle: Text('${network.security} · ${network.signalLabel}'),
trailing: const Icon(Icons.chevron_right_rounded),
),
);
}
}