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:
22
clients/flutter/lib/models/api_response.dart
Normal file
22
clients/flutter/lib/models/api_response.dart
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
24
clients/flutter/lib/models/app_event.dart
Normal file
24
clients/flutter/lib/models/app_event.dart
Normal 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};
|
||||
}
|
||||
}
|
||||
69
clients/flutter/lib/models/ble_models.dart
Normal file
69
clients/flutter/lib/models/ble_models.dart
Normal 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,
|
||||
}
|
||||
23
clients/flutter/lib/models/ble_status.dart
Normal file
23
clients/flutter/lib/models/ble_status.dart
Normal 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?,
|
||||
);
|
||||
}
|
||||
}
|
||||
54
clients/flutter/lib/models/device_status.dart
Normal file
54
clients/flutter/lib/models/device_status.dart
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
68
clients/flutter/lib/models/player_status.dart
Normal file
68
clients/flutter/lib/models/player_status.dart
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
23
clients/flutter/lib/models/video_item.dart
Normal file
23
clients/flutter/lib/models/video_item.dart
Normal 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';
|
||||
}
|
||||
}
|
||||
21
clients/flutter/lib/models/wifi_network.dart
Normal file
21
clients/flutter/lib/models/wifi_network.dart
Normal 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';
|
||||
}
|
||||
23
clients/flutter/lib/models/wifi_status.dart
Normal file
23
clients/flutter/lib/models/wifi_status.dart
Normal 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?,
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user