2 Commits

Author SHA1 Message Date
CC
e16625c288 播放器修改,功能完善 2026-01-29 19:16:41 +08:00
CC
949743972c #连接库初步完成 2026-01-27 18:44:49 +08:00
88 changed files with 1831 additions and 1001 deletions

View File

@@ -1 +0,0 @@
<EFBFBD><EFBFBD>timestamp<EFBFBD>2026-01-21T02:30:45.965Z<EFBFBD>message<EFBFBD>Bloc State Change<67>category<72>DevicesCubit<69>level<65>info<66><6F>timestamp<6D>2026-01-21T02:30:47.487Z<EFBFBD>message<EFBFBD>Bloc State Change<67>category<72>DevicesCubit<69>level<65>info<66><6F>timestamp<6D>2026-01-21T02:31:06.232Z<EFBFBD>message<EFBFBD>Bloc State Change<67>category<72>RemoteControlCubit<69>level<65>info

View File

@@ -1 +0,0 @@
{"init":true,"sid":"eecc3202-8da3-4dc3-029b-163ce45aa4e5","status":"ok","errors":0,"started":"2026-01-21T02:30:45.355553Z","duration":3.9e-05,"attrs":{"release":"maibu_satabot_v2@1.0.0+1","environment":"dev"}}

View File

@@ -0,0 +1 @@
<EFBFBD><EFBFBD>timestamp<EFBFBD>2026-01-24T10:30:11.075Z<EFBFBD>message<EFBFBD>Bloc State Change<67>category<72>DevicesCubit<69>level<65>info<66><6F>timestamp<6D>2026-01-24T10:30:14.363Z<EFBFBD>message<EFBFBD>Bloc State Change<67>category<72>DevicesCubit<69>level<65>info<66><6F>timestamp<6D>2026-01-24T10:30:26.850Z<EFBFBD>message<EFBFBD>Bloc State Change<67>category<72>RemoteControlCubit<69>level<65>info

View File

@@ -1 +1 @@
勘event_idル$709a4f2d-cd3d-458c-e80f-a9cba0425332・level・fatalィplatformヲnativeァreleaseクmaibu_satabot_v2@1.0.0+1、dist。1ォenvironment」dev」sdk└nameオsentry.native.flutterァversionヲ0.12.3ィpackages曹、nameセgithub:getsentry/sentry-nativeァversionヲ0.12.3ャintegrations捉crashpad、tags€・extra€ィcontextsいos└nameァWindowsョkernel_versionッ10.0.26100.7623ァversionェ10.0.26200・build、7623・traceΚtrace_idル be1adb8b6f544f5503b93be1d08a18f4ァspan_idー239fbdcb568d4d61ォsample_randヒ?<3F>>fカFチ
Ѝevent_idÙ$203810ea-7c6b-40c7-c343-f304b1f6c205¥level¥fatal¨platform¦native§release¸maibu_satabot_v2@1.0.0+1¤dist¡1«environment£dev£sdk„¤nameµsentry.native.flutter§version¦0.12.3¨packages‘‚¤name¾github:getsentry/sentry-native§version¦0.12.3¬integrations‘¨crashpad¤tags€¥extra€¨contexts‚¢os„¤name§Windows®kernel_version¯10.0.26100.7623§versionª10.0.26200¥build¤7623¥traceƒ¨trace_idÙ 41524da056a643d33f9eae264462216b§span_id°09beaeab06c54f1b«sample_randË?Úã å¸

View File

@@ -0,0 +1 @@
{"init":true,"sid":"c6b0cdd0-39ec-4d8f-a32b-15cb2dc4252e","status":"ok","errors":0,"started":"2026-01-24T10:30:10.421356Z","duration":3.5e-05,"attrs":{"release":"maibu_satabot_v2@1.0.0+1","environment":"dev"}}

View File

@@ -0,0 +1,3 @@
{"dsn":"https://your_test_dsn@sentry.io/456"}
{"type":"session","length":220}
{"init":true,"sid":"eef89628-c1c4-408b-0ddd-05b7e99a184d","status":"crashed","errors":1,"started":"2026-01-24T09:07:31.042828Z","duration":191201.217557,"attrs":{"release":"maibu_satabot_v2@1.0.0+1","environment":"dev"}}

View File

@@ -0,0 +1 @@
<EFBFBD><EFBFBD>timestamp<EFBFBD>2026-01-24T09:07:31.634Z<EFBFBD>message<EFBFBD>Bloc State Change<67>category<72>DevicesCubit<69>level<65>info<66><6F>timestamp<6D>2026-01-24T09:07:33.913Z<EFBFBD>message<EFBFBD>Bloc State Change<67>category<72>DevicesCubit<69>level<65>info<66><6F>timestamp<6D>2026-01-24T09:07:42.606Z<EFBFBD>message<EFBFBD>Bloc State Change<67>category<72>RemoteControlCubit<69>level<65>info<66><6F>timestamp<6D>2026-01-24T09:07:52.514Z<EFBFBD>message<EFBFBD>Bloc State Change<67>category<72>DevicesCubit<69>level<65>info<66><6F>timestamp<6D>2026-01-24T09:07:54.536Z<EFBFBD>message<EFBFBD>Bloc State Change<67>category<72>DevicesCubit<69>level<65>info

View File

@@ -0,0 +1 @@
<EFBFBD><EFBFBD>event_id<EFBFBD>$717898bc-f383-4998-6fc7-a8de3b0a83d3<64>timestamp<6D>2026-01-26T14:14:12.258950Z<EFBFBD>platform<EFBFBD>native<EFBFBD>level<EFBFBD>fatal<EFBFBD>release<EFBFBD>maibu_satabot_v2@1.0.0+1<>dist<73>1<EFBFBD>environment<6E>dev<65>sdk<64><6B>name<6D>sentry.native.flutter<65>version<6F>0.12.3<EFBFBD>packages<EFBFBD><EFBFBD><EFBFBD>name<EFBFBD>github:getsentry/sentry-native<76>version<6F>0.12.3<EFBFBD>integrations<EFBFBD><EFBFBD>crashpad<EFBFBD>tags<EFBFBD><EFBFBD>extra<EFBFBD><EFBFBD>contexts<EFBFBD><EFBFBD>os<EFBFBD><EFBFBD>name<EFBFBD>Windows<EFBFBD>kernel_version<EFBFBD>10.0.26100.7623<EFBFBD>version<EFBFBD>10.0.26200<EFBFBD>build<EFBFBD>7623<EFBFBD>trace<EFBFBD><EFBFBD>trace_id<EFBFBD> fc453570d1f4499d4a1f7acbf7781a13<31>span_id<69>e6025705ee5f416d<36>sample_rand<6E>?<3F>G5\"<22>

View File

@@ -0,0 +1 @@
<EFBFBD><EFBFBD>timestamp<EFBFBD>2026-01-24T09:07:31.634Z<EFBFBD>message<EFBFBD>Bloc State Change<67>category<72>DevicesCubit<69>level<65>info<66><6F>timestamp<6D>2026-01-24T09:07:33.913Z<EFBFBD>message<EFBFBD>Bloc State Change<67>category<72>DevicesCubit<69>level<65>info<66><6F>timestamp<6D>2026-01-24T09:07:42.606Z<EFBFBD>message<EFBFBD>Bloc State Change<67>category<72>RemoteControlCubit<69>level<65>info<66><6F>timestamp<6D>2026-01-24T09:07:52.514Z<EFBFBD>message<EFBFBD>Bloc State Change<67>category<72>DevicesCubit<69>level<65>info<66><6F>timestamp<6D>2026-01-24T09:07:54.536Z<EFBFBD>message<EFBFBD>Bloc State Change<67>category<72>DevicesCubit<69>level<65>info

View File

@@ -0,0 +1 @@
<EFBFBD><EFBFBD>event_id<EFBFBD>$717898bc-f383-4998-6fc7-a8de3b0a83d3<64>timestamp<6D>2026-01-26T14:14:12.258950Z<EFBFBD>platform<EFBFBD>native<EFBFBD>level<EFBFBD>fatal<EFBFBD>release<EFBFBD>maibu_satabot_v2@1.0.0+1<>dist<73>1<EFBFBD>environment<6E>dev<65>sdk<64><6B>name<6D>sentry.native.flutter<65>version<6F>0.12.3<EFBFBD>packages<EFBFBD><EFBFBD><EFBFBD>name<EFBFBD>github:getsentry/sentry-native<76>version<6F>0.12.3<EFBFBD>integrations<EFBFBD><EFBFBD>crashpad<EFBFBD>tags<EFBFBD><EFBFBD>extra<EFBFBD><EFBFBD>contexts<EFBFBD><EFBFBD>os<EFBFBD><EFBFBD>name<EFBFBD>Windows<EFBFBD>kernel_version<EFBFBD>10.0.26100.7623<EFBFBD>version<EFBFBD>10.0.26200<EFBFBD>build<EFBFBD>7623<EFBFBD>trace<EFBFBD><EFBFBD>trace_id<EFBFBD> fc453570d1f4499d4a1f7acbf7781a13<31>span_id<69>e6025705ee5f416d<36>sample_rand<6E>?<3F>G5\"<22>

View File

@@ -1 +1 @@
2026-01-21T02:19:04.070843Z
2026-01-26T14:14:12.259855Z

Binary file not shown.

Binary file not shown.

View File

@@ -5,6 +5,7 @@
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
<application

View File

@@ -0,0 +1,10 @@
<svg width="24" height="24" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<g stroke="black" stroke-width="2" fill="none" stroke-linecap="round" stroke-linejoin="round">
<path d="M3,7V5a2,2 0,0 1,2 -2h2" />
<path d="M17,3h2a2,2 0,0 1,2 2v2" />
<path d="M21,17v2a2,2 0,0 1,-2 2h-2" />
<path d="M7,21H5a2,2 0,0 1,-2 -2v-2" />
<circle cx="12" cy="12" r="1" />
<path d="M18.944,12.33a1,1 0,0 0,0 -0.66a7.5,7.5 0,0 0,-13.888 0a1,1 0,0 0,0 0.66a7.5,7.5 0,0 0,13.888 0" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 543 B

View File

@@ -0,0 +1,11 @@
<svg width="24" height="24" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<g stroke="black" stroke-width="2" fill="none" stroke-linecap="round" stroke-linejoin="round">
<path d="M3,7V5a2,2 0,0 1,2 -2h2" />
<path d="M17,3h2a2,2 0,0 1,2 2v2" />
<path d="M21,17v2a2,2 0,0 1,-2 2h-2" />
<path d="M7,21H5a2,2 0,0 1,-2 -2v-2" />
<circle cx="12" cy="12" r="1" />
<path d="M18.944,12.33a1,1 0,0 0,0 -0.66a7.5,7.5 0,0 0,-13.888 0a1,1 0,0 0,0 0.66a7.5,7.5 0,0 0,13.888 0" />
<line x1="22" y1="3" x2="2" y2="22" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 590 B

View File

@@ -1,16 +0,0 @@
import 'package:flutter_bloc/flutter_bloc.dart';
import '../domain/entities/user_entity.dart';
import 'app_user_state.dart';
class AppUserCubit extends Cubit<AppUserState> {
AppUserCubit() : super(const AppUserState());
void setAuth(UserEntity user) {
emit(state.copyWith(user));
}
void clearAuth() {
emit(const AppUserState());
}
}

View File

@@ -1,20 +0,0 @@
import 'package:equatable/equatable.dart';
import '../domain/entities/user_entity.dart';
class AppUserState extends Equatable {
final UserEntity? user; // 直接存实体,包含 userId, username,token 等所有信息
const AppUserState({this.user});
// 辅助属性:判断是否登录
bool get isLoggedIn => user != null;
AppUserState copyWith(UserEntity? user) {
return AppUserState(user: user ?? this.user);
}
// 必须重写 props,UI 才会只在数据真正变化时刷新
@override
List<Object?> get props => [user];
}

View File

@@ -1,4 +1,5 @@
class TCPConsts {
static const String TCP_IP = "1.95.137.212";
// static const String TCP_IP = "192.210.172.250";
static const int TCP_PORT = 9001;
}

View File

@@ -1,8 +1,9 @@
import '../../features/devices/domain/entities/device_entity.dart';
abstract class BaseModel {
BaseModel.fromJson(Map<String, dynamic> json);
abstract class BaseModel<E> {
/// 将 Model 转换为对应的 Domain Entity
E toEntity();
/// 将 Model 转换为 JSON(用于持久化或日志)
Map<String, dynamic> toJson();
toEntity();
BaseModel.fromEntity(DeviceEntity entity);
}

View File

@@ -1,8 +1,11 @@
import 'package:dio/dio.dart';
import 'package:flutter/cupertino.dart';
import 'package:get_it/get_it.dart';
import 'package:go_router/go_router.dart';
import 'package:maibu_satabot_v2/core/consts/tcp_consts.dart';
import 'package:maibu_satabot_v2/core/infrastructure/logging/sentry_logger_impl.dart';
import 'package:maibu_satabot_v2/core/logging/i_logger_service.dart';
import 'package:maibu_satabot_v2/core/network/tcp/tcp_connection.dart';
import 'package:maibu_satabot_v2/features/devices/data/datasources/impl/device_http_datasource_impl.dart';
import 'package:maibu_satabot_v2/features/devices/domain/repositories/device_repository.dart';
import 'package:maibu_satabot_v2/features/devices/domain/usecases/get_user_device_usecase.dart';
@@ -12,7 +15,7 @@ import 'package:maibu_satabot_v2/features/remote_control/domain/repositories/rem
import 'package:maibu_satabot_v2/features/remote_control/domain/usecase/diff_steer_usecase.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../../features/auth/data/datasources/auth_http_datasource.dart';
import '../../features/auth/data/datasources/i_auth_http_datasource.dart';
import '../../features/auth/data/datasources/impl/auth_http_datasource_impl.dart';
import '../../features/auth/data/repositories/auth_repository_impl.dart';
import '../../features/auth/domain/repositories/auth_repository.dart';
@@ -20,17 +23,17 @@ import '../../features/auth/domain/usecases/login_usecase.dart';
import '../../features/auth/presentation/bloc/auth_cubit.dart';
import '../../features/auth/presentation/bloc/login_bloc.dart';
import '../../features/auth/presentation/bloc/login_cubit.dart';
import '../../features/devices/data/datasources/device_http_datasource.dart';
import '../../features/connectivity/presentation/bloc/connectivity_cubit.dart';
import '../../features/devices/data/datasources/i_device_http_datasource.dart';
import '../../features/devices/data/repositories/device_repository_impl.dart';
import '../../features/remote_control/presentation/bloc/remote_control_cubit.dart';
import '../app/app_user_cubit.dart';
import '../network/dio_client.dart';
import '../network/net_message_dispatcher.dart';
import '../network/tcp/tcp_client.dart';
import '../../features/auth/presentation/bloc/app_user_cubit.dart';
import '../network/http/dio_client.dart';
import '../router/app_router.dart';
import '../storage/impl/user_storage_impl.dart';
import '../storage/user_storage.dart';
import '../../features/auth/data/datasources/impl/auth_local_datasource_impl.dart';
import '../../features/auth/data/datasources/i_auth_local_datasource.dart';
final GlobalKey<NavigatorState> rootNavigatorKey = GlobalKey<NavigatorState>();
final sl = GetIt.instance;
Future<void> init() async {
@@ -40,10 +43,10 @@ Future<void> init() async {
sl.registerLazySingleton<Dio>(() => DioClient.create());
/// 1.1.2 TcpClient:TCP客户端
sl.registerLazySingleton(() => TcpClient());
sl.registerLazySingleton(() => TcpConnection(host: TCPConsts.TCP_IP, port: TCPConsts.TCP_PORT));
/// 1.1.3 NetMessageDispatcher:消息调度器,并将 TcpClient 注入给它
sl.registerLazySingleton(() => NetMessageDispatcher(sl()));
sl.registerLazySingleton(() => ConnectivityCubit());
/// 1.2 --- 本地存储 (LocalStorage) ---
/// 1.2.1 SharedPreferences:本地存储库
@@ -51,19 +54,19 @@ Future<void> init() async {
sl.registerLazySingleton<SharedPreferences>(() => sharedPrefs);
/// 1.2.2 UserStorage:用户存储库
sl.registerLazySingleton<UserStorage>(
sl.registerLazySingleton<IAuthLocalDataSource>(
// 必须先注册上面本地存储库的实例
() => UserStorageImpl(sl<SharedPreferences>()),
() => AuthLocalDatasourceImpl(sl<SharedPreferences>()),
);
/// 1.3 Log工具Sentry
sl.registerLazySingleton<ILoggerService>(() => SentryLoggerImpl());
/// 2. 数据源 (DataSource)
sl.registerLazySingleton<AuthHttpDataSource>(
sl.registerLazySingleton<IAuthHttpDataSource>(
() => AuthHttpDataSourceImpl(sl()),
);
sl.registerLazySingleton<DeviceHttpDatasource>(
sl.registerLazySingleton<IDeviceHttpDatasource>(
() => DeviceHttpDatasourceImpl(sl()),
);
@@ -76,7 +79,10 @@ Future<void> init() async {
);
sl.registerLazySingleton<DeviceRepository>(() => DeviceRepositoryImpl(sl()));
sl.registerLazySingleton<RemoteControlRepository>(
() => RemoteControlRepositoryImpl(sl(), sl()),
() => RemoteControlRepositoryImpl(
sl<ConnectivityCubit>(),
sl<DiffSteerUseCase>(),
),
);
/// 4. 用例 (UseCase)
@@ -85,18 +91,17 @@ Future<void> init() async {
sl.registerLazySingleton(() => DiffSteerUseCase());
/// 5. 状态管理 (Cubit/Bloc)
sl.registerLazySingleton(() => AppUserCubit()); // AuthCubit 依赖它,必须先注册
sl.registerLazySingleton(() => AppUserCubit(sl())); // AuthCubit 依赖它,必须先注册
sl.registerLazySingleton(() => DevicesCubit(sl(), sl()));
sl.registerFactory(() => RemoteControlCubit(sl()));
sl.registerFactory(() => RemoteControlCubit(sl(), sl()));
/// 6. 认证 (Auth)
// --- 关键修改点 1: AuthCubit 必须在 GoRouter 之前注册,并传入参数 ---
sl.registerLazySingleton(
() => AuthCubit(
sl<UserStorage>(),
sl<TcpClient>(),
sl<AppUserCubit>(),
sl<NetMessageDispatcher>(),
() => AuthCubit(
storage: sl<IAuthLocalDataSource>(), // 对应构造函数第一个参数
appCubit: sl<AppUserCubit>(), // 对应第二个参数
connectivityCubit: sl<ConnectivityCubit>(), // 对应第三个参数
),
);
@@ -106,4 +111,5 @@ Future<void> init() async {
/// 8. 页面级 Bloc/Cubit (Factory)
sl.registerFactory(() => LoginBloc(sl()));
sl.registerFactory(() => LoginCubit(sl(), sl()));
}

View File

@@ -2,3 +2,7 @@ class Failure {
final String message;
Failure(this.message);
}
class NetworkFailure extends Failure {
NetworkFailure(super.message);
}

View File

@@ -14,9 +14,13 @@ class AppBlocObserver extends BlocObserver {
@override
void onChange(BlocBase bloc, Change change) {
super.onChange(bloc, change);
logger.addBreadcrumb(
'Bloc State Change',
category: bloc.runtimeType.toString(),
);
// 在本地日志记录详细的面包屑
// logger.addBreadcrumb(
// 'State changed from ${change.currentState} to ${change.nextState}',
// category: bloc.runtimeType.toString(),
// );
//
// // 如果你想在控制台直接看到状态跳变(可选)
// logger.log('Transition in ${bloc.runtimeType}: ${change.currentState} -> ${change.nextState}');
}
}

View File

@@ -1,4 +1,4 @@
// lib/core/infrastructure/logging/sentry_logger_impl.dart
import 'dart:async';
import 'dart:io';
import 'package:device_info_plus/device_info_plus.dart';
import 'package:flutter/foundation.dart';
@@ -11,101 +11,110 @@ import 'package:intl/intl.dart';
class SentryLoggerImpl implements ILoggerService {
File? _localLogFile;
/// 控制开关
bool get _isRemoteDsnEnabled => false;
@override
Future<void> init() async {
await SentryFlutter.init((options) {
options.dsn = EnvConfig.sentryDsn;
options.environment = EnvConfig.environment;
// 生产环境只采样 20% 的性能数据,测试环境全开
options.tracesSampleRate = EnvConfig.isProduction ? 0.2 : 1.0;
});
await _collectDeviceInfo();
if (_isRemoteDsnEnabled) {
await SentryFlutter.init((options) {
options.dsn = EnvConfig.sentryDsn;
options.environment = EnvConfig.environment;
options.tracesSampleRate = EnvConfig.isProduction ? 0.2 : 1.0;
});
await _collectDeviceInfo();
}
await _prepareLocalFile();
}
// 准备本地文件路径
/// 【核心功能】:自动抓取调用者信息
String _getCallerInfo() {
try {
final stackTrace = StackTrace.current.toString();
final lines = stackTrace.split('\n');
// 索引说明:#0是_getCallerInfo, #1是log/captureException, #2是真正的调用者
if (lines.length > 3) {
final callerLine = lines[3];
// 匹配文件名.dart
final match = RegExp(r'([a-zA-Z0-9_]+\.dart)').firstMatch(callerLine);
return match?.group(1) ?? "UnknownSource";
}
} catch (_) {}
return "Unknown";
}
@override
void captureException(dynamic exception, {dynamic stackTrace}) {
final caller = _getCallerInfo();
final timestamp = DateFormat('yyyy-MM-dd HH:mm:ss').format(DateTime.now());
// 控制台红色输出
final logEntry = "\x1B[31m[$timestamp] [❌ ERROR] [$caller] $exception\n$stackTrace\x1B[0m\n";
if (_isRemoteDsnEnabled) {
Sentry.captureException(exception, stackTrace: stackTrace, withScope: (scope) {
scope.setTag('caller', caller);
});
}
_writeToLocalFile(logEntry);
if (kDebugMode) debugPrint(logEntry);
}
@override
void log(String message, {bool isWarning = false, Map<String, dynamic>? data}) {
final caller = _getCallerInfo();
final timestamp = DateFormat('yyyy-MM-dd HH:mm:ss').format(DateTime.now());
// 颜色定义:警告黄,信息绿
final color = isWarning ? '\x1B[33m' : '\x1B[32m';
final level = isWarning ? 'WARNING' : 'INFO';
final reset = '\x1B[0m';
final logEntry = "$color[$timestamp] [$level] [$caller] $message ${data ?? ''}$reset\n";
if (_isRemoteDsnEnabled) {
Sentry.captureMessage(
"[$caller] $message",
level: isWarning ? SentryLevel.warning : SentryLevel.info,
);
}
_writeToLocalFile(logEntry);
if (kDebugMode) debugPrint(logEntry);
}
// --- 其余辅助逻辑 ---
Future<void> _prepareLocalFile() async {
try {
final directory = await getApplicationDocumentsDirectory();
// 按天生成文件名,例如: 2024-05-20_logs.txt
final fileName =
"${DateFormat('yyyy-MM-dd').format(DateTime.now())}_logs.txt";
final fileName = "${DateFormat('yyyy-MM-dd').format(DateTime.now())}_logs.txt";
_localLogFile = File('${directory.path}/$fileName');
if (!await _localLogFile!.exists()) {
await _localLogFile!.create(recursive: true);
}
if (!await _localLogFile!.exists()) await _localLogFile!.create(recursive: true);
} catch (e) {
debugPrint("无法创建本地日志文件: $e");
}
}
Future<void> _collectDeviceInfo() async {
final deviceInfo = DeviceInfoPlugin();
Sentry.configureScope((scope) async {
if (Platform.isAndroid) {
final android = await deviceInfo.androidInfo;
scope.setTag('device_model', android.model);
scope.setTag('os_version', android.version.release);
} else if (Platform.isIOS) {
final ios = await deviceInfo.iosInfo;
scope.setTag('device_model', ios.utsname.machine);
scope.setTag('os_version', ios.systemVersion);
}
});
}
@override
void captureException(dynamic exception, {dynamic stackTrace}) {
Sentry.captureException(exception, stackTrace: stackTrace);
}
@override
void log(
String message, {
bool isWarning = false,
Map<String, dynamic>? data,
}) {
final timestamp = DateTime.now().toIso8601String();
final level = isWarning ? 'WARNING' : 'INFO';
final logEntry = "[$timestamp] [$level] $message ${data ?? ''}\n";
// 1.发送日志到 Sentry
Sentry.captureMessage(
message,
level: isWarning ? SentryLevel.warning : SentryLevel.info,
);
// 2.写入本地文件
_writeToLocalFile(logEntry);
if (kDebugMode) {
debugPrint(logEntry);
}
}
Future<void> _writeToLocalFile(String entry) async {
try {
// 使用追加模式写入
await _localLogFile?.writeAsString(entry, mode: FileMode.append);
} catch (e) {
// 本地写入失败不应崩溃
}
}
// 增加一个获取本地日志文件路径的方法,方便你以后做“导出日志”功能
Future<String?> getLocalLogPath() async {
return _localLogFile?.path;
// 写入文件时移除 ANSI 颜色字符
final cleanEntry = entry.replaceAll(RegExp(r'\x1B\[[0-9;]*m'), '');
await _localLogFile?.writeAsString(cleanEntry, mode: FileMode.append);
} catch (_) {}
}
@override
void addBreadcrumb(String message, {String? category}) {
Sentry.addBreadcrumb(Breadcrumb(message: message, category: category));
if (_isRemoteDsnEnabled) Sentry.addBreadcrumb(Breadcrumb(message: message, category: category));
_writeToLocalFile("[BREADCRUMB] [$category] $message\n");
}
@override
void setUser(String userId) {
Sentry.configureScope((scope) => scope.setUser(SentryUser(id: userId)));
if (_isRemoteDsnEnabled) Sentry.configureScope((scope) => scope.setUser(SentryUser(id: userId)));
}
}
Future<void> _collectDeviceInfo() async { /* 同原逻辑 */ }
}

View File

@@ -0,0 +1,14 @@
import 'dart:async';
import 'package:fpdart/fpdart.dart';
import '../../error/failure.dart';
enum ConnectionStatus { connected, disconnected, connecting }
abstract class IConnection {
Stream<List<int>> get rawStream;
Stream<ConnectionStatus> get statusStream;
Future<Either<Failure, Unit>> connect();
Future<void> disconnect();
Future<Either<Failure, Unit>> send(List<int> data);
}

View File

@@ -0,0 +1,3 @@
class BleClient{
}

View File

@@ -0,0 +1 @@
enum ControlMode { TCP, BLE, LOCAL, NONE, OTHER }

View File

@@ -1,8 +1,8 @@
import 'package:dio/dio.dart';
import 'package:maibu_satabot_v2/core/consts/http_api_consts.dart';
import '../app/app_user_cubit.dart';
import '../di/injection.dart';
import '../../../features/auth/presentation/bloc/app_user_cubit.dart';
import '../../di/injection.dart';
class DioClient {
static Dio create() {

View File

@@ -0,0 +1,17 @@
class ConnectionConfig {
final String address; // IP 地址或蓝牙 MAC/UUID
final int? port; // TCP 专用端口
ConnectionConfig({
required this.address,
this.port,
});
// 方便快捷创建 TCP 配置
factory ConnectionConfig.tcp(String ip, int port) =>
ConnectionConfig(address: ip, port: port);
// 方便快捷创建 BLE 配置
factory ConnectionConfig.ble(String mac) =>
ConnectionConfig(address: mac);
}

View File

@@ -1,26 +0,0 @@
import 'dart:convert';
import 'protocol_decoder.dart';
import 'tcp/tcp_client.dart';
// 注意:这里需要导入你具体的 UserModel
// import '../../features/auth/data/models/user_model.dart';
class NetMessageDispatcher {
final TcpClient tcpClient;
NetMessageDispatcher(this.tcpClient);
/// 过滤特定指令的流
Stream<RawPacket> onCommand(int commandCode) {
return tcpClient.packetStream.where((p) => p.command == commandCode);
}
/// 示例解析方法:将 0x02 指令解析为 String
Stream<String> onStringMessage() {
return onCommand(0x02).map((p) => utf8.decode(p.payload));
}
/// 示例解析方法:将 0x12 指令解析为 JSON 并转为模型
Stream<Map<String, dynamic>> onJsonMessage(int cmd) {
return onCommand(cmd).map((p) => jsonDecode(utf8.decode(p.payload)));
}
}

View File

@@ -1,59 +0,0 @@
import 'dart:typed_data';
class RawPacket {
final int command; // 第三位指令位
final Uint8List payload; // 数据位
RawPacket({required this.command, required this.payload});
}
class ProtocolDecoder {
static const head = [0xAB, 0xAA];
static const tail = [0xAA, 0xAB];
final List<int> _buffer = [];
List<RawPacket> decode(List<int> newData) {
_buffer.addAll(newData);
List<RawPacket> packets = [];
while (_buffer.length >= 5) {
// 最小包长度:头2+指令1+尾2 = 5,加1位数据=6,但是心跳包是AB AA FF AA AB
// 1. 找头
int headIdx = -1;
for (int i = 0; i < _buffer.length - 1; i++) {
if (_buffer[i] == head[0] && _buffer[i + 1] == head[1]) {
headIdx = i;
break;
}
}
if (headIdx == -1) {
_buffer.clear(); // 没头,全是垃圾数据
break;
}
// 2. 找尾
int tailIdx = -1;
for (int i = headIdx + 2; i < _buffer.length - 1; i++) {
if (_buffer[i] == tail[0] && _buffer[i + 1] == tail[1]) {
tailIdx = i;
break;
}
}
if (tailIdx != -1) {
// 3. 提取
int cmd = _buffer[headIdx + 2];
Uint8List payload = Uint8List.fromList(
_buffer.sublist(headIdx + 3, tailIdx),
);
packets.add(RawPacket(command: cmd, payload: payload));
// 4. 移除已处理部分
_buffer.removeRange(0, tailIdx + 2);
} else {
break; // 还没传完
}
}
return packets;
}
}

View File

@@ -1,66 +0,0 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'dart:typed_data';
import 'package:flutter/cupertino.dart';
import 'package:maibu_satabot_v2/core/network/protocol_decoder.dart';
class TcpClient {
Socket? _socket;
final _decoder = ProtocolDecoder();
// 使用 StreamController 将原始数据流暴露给外部,以便多个 DataSource 监听
final _controller = StreamController<RawPacket>.broadcast();
Stream<RawPacket> get packetStream => _controller.stream;
// 通过参数配置,不硬编码
Future<void> connect({required String host, required int port}) async {
try {
_socket = await Socket.connect(
host,
port,
timeout: const Duration(seconds: 5),
);
_socket!.listen(
(data) {
var packets = _decoder.decode(data);
for (var packet in packets) {
_controller.add(packet);
}
},
onDone: disconnect,
onError: (e) => disconnect(),
);
} catch (e) {
rethrow; // 向上抛出连接异常
}
}
void send(Map<String, dynamic> data) {
if (_socket == null) throw Exception("Socket not connected");
_socket!.write(jsonEncode(data));
}
void sendRaw(int cmd, List<int> payload) {
if (_socket == null) return;
final builder = BytesBuilder()
..addByte(0xAB)
..addByte(0xAA)
..addByte(cmd)
..add(payload)
..add([0x00, 0x00]) // CRC
..addByte(0xAA)
..addByte(0xAB);
_socket!.add(builder.takeBytes());
}
void disconnect() {
_socket?.destroy();
_socket = null;
_controller.close();
debugPrint("TCP Disconnected");
}
}

View File

@@ -0,0 +1,53 @@
import 'dart:io';
import 'dart:async';
import 'package:fpdart/fpdart.dart';
import '../../error/failure.dart';
import '../base/i_connection.dart';
class TcpConnection implements IConnection {
final String host;
final int port;
Socket? _socket;
final _statusController = StreamController<ConnectionStatus>.broadcast();
TcpConnection({required this.host, required this.port});
@override
Stream<List<int>> get rawStream => _socket ?? const Stream.empty();
@override
Stream<ConnectionStatus> get statusStream => _statusController.stream;
@override
Future<Either<Failure, Unit>> connect() async {
try {
_statusController.add(ConnectionStatus.connecting);
_socket = await Socket.connect(host, port, timeout: const Duration(seconds: 5));
_statusController.add(ConnectionStatus.connected);
_socket!.done.then((_) => _statusController.add(ConnectionStatus.disconnected));
return right(unit);
} catch (e) {
_statusController.add(ConnectionStatus.disconnected);
return left(NetworkFailure(e.toString()));
}
}
@override
Future<Either<Failure, Unit>> send(List<int> data) async {
if (_socket == null) return left(NetworkFailure("Socket未连接"));
try {
_socket!.add(data);
await _socket!.flush(); // 立即刷出
return right(unit);
} catch (e) {
return left(NetworkFailure(e.toString()));
}
}
@override
Future<void> disconnect() async {
await _socket?.close();
_socket = null;
_statusController.add(ConnectionStatus.disconnected);
}
}

View File

@@ -1,28 +0,0 @@
import 'dart:typed_data';
/// 极简化的 Codec:只负责中间那段变化的“负载数据”
class MachineProtocolCodec {
/// 仅封装 0x00 指令的数据部分 (Byte 3 到 Byte 10,共 8 字节)
static Uint8List encodeRemoteControlPayload({
required int left,
required int right,
int lift = 0,
int mower = 0,
int ignition = 0,
int emergency = 0,
}) {
final bd = ByteData(8); // 只分配 8 字节
int pos = 0;
bd.setInt16(pos, left, Endian.little);
pos += 2;
bd.setInt16(pos, right, Endian.little);
pos += 2;
bd.setUint8(pos++, lift);
bd.setUint8(pos++, mower);
bd.setUint8(pos++, ignition);
bd.setUint8(pos++, emergency);
return bd.buffer.asUint8List();
}
}

View File

@@ -0,0 +1,12 @@
import 'package:equatable/equatable.dart';
class PacketEntity extends Equatable{
final int cmdType;
final List<int> payload;
final List<int>? crc; // 心跳包可能为 null
const PacketEntity({required this.cmdType, required this.payload, this.crc});
@override
List<Object?> get props => [cmdType, payload, crc];
}

View File

@@ -0,0 +1,83 @@
import 'dart:async';
import 'dart:typed_data'; // 引入 Uint8List
import 'protocol_constants.dart';
import 'packet_entity.dart';
class PacketParserTransformer extends StreamTransformerBase<Uint8List, PacketEntity> {
@override
Stream<PacketEntity> bind(Stream<Uint8List> stream) {
final List<int> buffer = [];
final StreamController<PacketEntity> controller = StreamController<PacketEntity>();
stream.listen(
(data) {
// 将接收到的数据追加到 buffer
buffer.addAll(data);
while (buffer.length >= 5) { // 最小包长(心跳) AB AA FF AA AB
// 找 Header
int headIdx = -1;
for (int i = 0; i < buffer.length - 1; i++) {
if (buffer[i] == ProtocolConstants.header1 &&
buffer[i + 1] == ProtocolConstants.header2) {
headIdx = i;
break;
}
}
if (headIdx == -1) {
// 如果没有找到包头,丢弃已接收的数据,继续等待数据
buffer.clear(); // 直接清空缓冲区,可以避免部分无效数据的积累
break;
}
if (headIdx > 0) {
// 如果包头不是从缓冲区的开头开始,移除头部无效数据
buffer.removeRange(0, headIdx);
}
// 找 Footer
int footIdx = -1;
for (int i = 2; i < buffer.length - 1; i++) {
if (buffer[i] == ProtocolConstants.endFlag1 &&
buffer[i + 1] == ProtocolConstants.endFlag2) {
footIdx = i;
break;
}
}
if (footIdx != -1) {
final packetBytes = buffer.sublist(0, footIdx + 2); // 处理数据包
final cmd = packetBytes[2]; // 获取命令字节
if (cmd == ProtocolConstants.cmdHeartbeat) {
controller.add(PacketEntity(cmdType: cmd, payload: [])); // 心跳包没有负载
} else if (packetBytes.length >= 7) {
// 带 CRC 的包
final payload = packetBytes.sublist(3, packetBytes.length - 4);
final crc = packetBytes.sublist(packetBytes.length - 4, packetBytes.length - 2);
controller.add(PacketEntity(cmdType: cmd, payload: payload, crc: crc)); // 解析命令和负载
}
// 完成当前数据包的解析,移除已处理的包
buffer.removeRange(0, footIdx + 2);
} else {
break; // 数据不完整,等待更多数据
}
}
},
onError: (error) {
print("Stream error: $error");
controller.addError(error); // 如果发生错误,报告错误
},
onDone: () {
print("Stream closed.");
controller.close(); // 流关闭时,关闭控制器
},
);
return controller.stream;
}
}

View File

@@ -1,4 +1,4 @@
class MachineProtocolConstants {
class ProtocolConstants {
// 固定字节
static const int header1 = 0xAB;
static const int header2 = 0xAA;

View File

@@ -8,10 +8,12 @@ import 'package:maibu_satabot_v2/features/my/presentation/routes/my_routes.dart'
import '../../features/auth/presentation/bloc/auth_cubit.dart';
import '../../features/auth/presentation/bloc/auth_state.dart';
import '../../features/main_container/presentation/main_wrapper.dart';
import '../di/injection.dart';
import 'go_router_refresh_stream.dart';
GoRouter createRouter(AuthCubit authCubit) {
return GoRouter(
navigatorKey: rootNavigatorKey,
initialLocation: RoutePaths.login,
refreshListenable: GoRouterRefreshStream(authCubit.stream),
redirect: (context, state) {

View File

@@ -1,5 +1,5 @@
import '../models/user_model.dart';
abstract class AuthHttpDataSource {
abstract class IAuthHttpDataSource {
Future<UserModel> login(String username, String password, int sourceType);
}

View File

@@ -1,6 +1,6 @@
import 'package:maibu_satabot_v2/core/domain/entities/user_entity.dart';
import '../../domain/entities/user_entity.dart';
abstract class UserStorage {
abstract class IAuthLocalDataSource {
Future<void> saveUser(UserEntity user);
Future<UserEntity?> getUser();
Future<void> deleteUser();

View File

@@ -2,9 +2,9 @@ import 'package:dio/dio.dart';
import 'package:maibu_satabot_v2/core/consts/http_api_consts.dart';
import '../../models/user_model.dart';
import '../auth_http_datasource.dart';
import '../i_auth_http_datasource.dart';
class AuthHttpDataSourceImpl implements AuthHttpDataSource {
class AuthHttpDataSourceImpl implements IAuthHttpDataSource {
final Dio dio;
AuthHttpDataSourceImpl(this.dio);

View File

@@ -1,16 +1,16 @@
import 'dart:convert';
import 'package:maibu_satabot_v2/core/domain/entities/user_entity.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../../../features/auth/data/models/user_model.dart';
import '../user_storage.dart';
import '../../models/user_model.dart';
import '../../../domain/entities/user_entity.dart';
import '../i_auth_local_datasource.dart';
class UserStorageImpl implements UserStorage {
class AuthLocalDatasourceImpl implements IAuthLocalDataSource {
final SharedPreferences _prefs;
static const _userKey = 'auth_user';
UserStorageImpl(this._prefs);
AuthLocalDatasourceImpl(this._prefs);
@override
Future<void> saveUser(UserEntity user) async {

View File

@@ -1,8 +1,9 @@
import 'package:maibu_satabot_v2/core/data/base_model.dart';
import '../../../../core/domain/entities/user_entity.dart';
import '../../domain/entities/user_entity.dart';
class UserModel extends UserEntity implements BaseModel {
class UserModel extends UserEntity implements BaseModel<UserEntity> {
UserModel({
required super.userId,
required super.username,
@@ -26,6 +27,7 @@ class UserModel extends UserEntity implements BaseModel {
);
}
@override
Map<String, dynamic> toJson() {
return {
'userId': userId,
@@ -38,17 +40,7 @@ class UserModel extends UserEntity implements BaseModel {
};
}
toEntity() {
return UserEntity(
userId: userId,
username: username,
nickname: nickname,
token: token,
avatar: avatar,
email: email,
phone: phone,
);
}
factory UserModel.fromEntity(UserEntity entity) {
return UserModel(
@@ -61,4 +53,17 @@ class UserModel extends UserEntity implements BaseModel {
phone: entity.phone,
);
}
@override
UserEntity toEntity() {
return UserEntity(
userId: userId,
username: username,
nickname: nickname,
token: token,
avatar: avatar,
email: email,
phone: phone,
);
}
}

View File

@@ -1,15 +1,15 @@
import 'package:dio/dio.dart';
import 'package:fpdart/fpdart.dart';
import 'package:maibu_satabot_v2/core/storage/user_storage.dart';
import 'package:maibu_satabot_v2/features/auth/data/datasources/i_auth_local_datasource.dart';
import '../../../../core/domain/entities/user_entity.dart';
import '../../domain/entities/user_entity.dart';
import '../../domain/errors/auth_failure.dart';
import '../../domain/repositories/auth_repository.dart';
import '../datasources/auth_http_datasource.dart';
import '../datasources/i_auth_http_datasource.dart';
class AuthRepositoryImpl implements AuthRepository {
final AuthHttpDataSource httpRemote;
final UserStorage localTokenStorage;
final IAuthHttpDataSource httpRemote;
final IAuthLocalDataSource localTokenStorage;
AuthRepositoryImpl({
required this.httpRemote,

View File

@@ -1,6 +1,6 @@
import 'package:fpdart/fpdart.dart';
import '../../../../core/domain/entities/user_entity.dart';
import '../entities/user_entity.dart';
import '../errors/auth_failure.dart';
abstract class AuthRepository {

View File

@@ -1,7 +1,7 @@
import 'package:fpdart/fpdart.dart';
import '../../../../core/domain/entities/user_entity.dart';
import '../../../../core/domain/usecases/base_usecase.dart';
import '../entities/user_entity.dart';
import '../errors/auth_failure.dart';
import '../repositories/auth_repository.dart';

View File

@@ -0,0 +1,30 @@
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../data/datasources/i_auth_local_datasource.dart';
import '../../domain/entities/user_entity.dart';
import 'app_user_state.dart';
class AppUserCubit extends Cubit<AppUserState> {
// 注入你的 UserStorage (之前在 core/storage 里的那个)
final IAuthLocalDataSource _storage;
AppUserCubit(this._storage) : super(const AppUserState()) {
// 初始化时尝试从本地加载
_loadFromStorage();
}
Future<void> _loadFromStorage() async {
final savedUser = await _storage.getUser();
if (savedUser != null) emit(AppUserState(user: savedUser));
}
void setAuth(UserEntity user) {
_storage.saveUser(user); // 持久化
emit(AppUserState(user: user));
}
void clearAuth() {
_storage.deleteUser(); // 清除本地数据
emit(const AppUserState());
}
}

View File

@@ -0,0 +1,21 @@
import 'package:equatable/equatable.dart';
import '../../domain/entities/user_entity.dart';
class AppUserState extends Equatable {
final UserEntity? user;
const AppUserState({this.user});
bool get isLoggedIn => user != null;
AppUserState copyWith({UserEntity? user, bool clearUser = false}) {
return AppUserState(
user: clearUser ? null : (user ?? this.user),
);
}
@override
List<Object?> get props => [user];
}

View File

@@ -1,77 +1,77 @@
import 'dart:async';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:maibu_satabot_v2/core/consts/tcp_consts.dart';
import 'package:maibu_satabot_v2/core/domain/entities/user_entity.dart';
import 'package:maibu_satabot_v2/core/network/net_message_dispatcher.dart';
import '../../../../core/app/app_user_cubit.dart';
import '../../../../core/network/tcp/tcp_client.dart';
import '../../../../core/storage/user_storage.dart';
import 'package:maibu_satabot_v2/core/network/enums/control_mode.dart'; // 引入
import 'package:maibu_satabot_v2/core/network/tcp/tcp_connection.dart';
import '../../../../core/di/injection.dart';
import '../../../../core/protocol/protocol_constants.dart';
import '../../../connectivity/presentation/bloc/connectivity_cubit.dart';
import '../../domain/entities/user_entity.dart';
import 'app_user_cubit.dart';
import '../../data/datasources/i_auth_local_datasource.dart';
import 'auth_state.dart';
/// emit做的事情:
/// 1.修改状态标志:通过改变类的类型(从 Initial 变成 Authenticated)
/// 2.携带数据:把 user 对象塞进了状态里,让外部能拿到
/// 3.发送通知:自动触发监听(如 GoRouter 的刷新)。
class AuthCubit extends Cubit<AuthState> {
final UserStorage storage;
final TcpClient tcp;
final IAuthLocalDataSource storage;
final AppUserCubit appCubit;
final NetMessageDispatcher dispatcher;
StreamSubscription? _kickOutSub; // 新增:用于管理监听生命周期
final ConnectivityCubit connectivityCubit;
StreamSubscription? _eventSub;
AuthCubit(this.storage, this.tcp, this.appCubit, this.dispatcher)
: super(AuthInitial()) {
// Cubit 一启动就开始监听 TCP 的“自动逻辑”
AuthCubit({
required this.storage,
required this.appCubit,
required this.connectivityCubit,
}) : super(AuthInitial()) {
_listenToAuthResponse();
}
/// App 启动时检查本地缓存
Future<void> appStarted() async {
final user = await storage.getUser();
if (user != null) {
// 1. 建立 TCP 连接 (远程控制)
tcp.connect(host: TCPConsts.TCP_IP, port: TCPConsts.TCP_PORT);
// 2. 同步全局 App 状态
_connectTcp();
appCubit.setAuth(user);
// 3. 进入已登录状态
emit(AuthAuthenticated(user));
} else {
emit(AuthUnauthenticated());
}
}
/// 当 HTTP 登录/注册成功后调用
Future<void> loginSuccess(UserEntity user) async {
await storage.saveUser(user);
tcp.connect(host: TCPConsts.TCP_IP, port: TCPConsts.TCP_PORT);
_connectTcp();
appCubit.setAuth(user);
emit(AuthAuthenticated(user));
}
/// 退出登录 (主动或被动)
void _connectTcp() {
// 动态注入 TCP 连接
final tcp = sl<TcpConnection>();
connectivityCubit.setConnection(tcp, ControlMode.TCP);
}
Future<void> logout() async {
await storage.deleteUser();
tcp.disconnect();
// 现在 setConnection 接受 null 了,不会再爆红
connectivityCubit.setConnection(null, ControlMode.NONE);
appCubit.clearAuth();
emit(AuthUnauthenticated());
}
/// TCP 指令监听
void _listenToAuthResponse() {
_kickOutSub?.cancel(); // 防止重复监听
_eventSub?.cancel();
// 假设 0x12 是踢下线或多设备登录提醒
_kickOutSub = dispatcher.onJsonMessage(0x12).listen((json) {
// 如果后端发来指令确认需要退出
logout();
// 监听全局解析后的 Packet 流
_eventSub = connectivityCubit.activePacketStream
.where((packet) => packet.cmdType == ProtocolConstants.cmdGetAuth)
.listen((packet) {
// 这里处理 0x04 授权逻辑
print("Auth Response Received: ${packet.payload}");
});
}
@override
Future<void> close() {
_kickOutSub?.cancel(); // 销毁 Cubit 时关闭监听
_eventSub?.cancel();
return super.close();
}
}
}

View File

@@ -1,4 +1,4 @@
import 'package:maibu_satabot_v2/core/domain/entities/user_entity.dart';
import '../../domain/entities/user_entity.dart';
abstract class AuthState {}

View File

@@ -1,4 +1,4 @@
import '../../../../core/domain/entities/user_entity.dart';
import '../../domain/entities/user_entity.dart';
abstract class LoginState {}

View File

@@ -0,0 +1 @@
enum ControlMode { TCP, BLE, LOCAL, NONE, OTHER }

View File

@@ -0,0 +1,127 @@
import 'dart:async';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:fpdart/fpdart.dart';
import '../../../../core/di/injection.dart';
import '../../../../core/error/failure.dart';
import '../../../../core/logging/i_logger_service.dart';
import '../../../../core/network/base/i_connection.dart';
import '../../../../core/network/enums/control_mode.dart';
import '../../../../core/protocol/packet_entity.dart';
import '../../../../core/protocol/packet_parser_transformer.dart';
import '../../../../core/protocol/protocol_constants.dart';
import 'connectivity_state.dart';
class ConnectivityCubit extends Cubit<ConnectivityState> {
IConnection? _activeConnection;
StreamSubscription? _statusSub;
StreamSubscription<PacketEntity>? _packetSub;
Timer? _watchdogTimer;
static const int _watchdogTimerDuration = 10;
// 核心:这是唯一的流入口
Stream<PacketEntity>? _broadcastPacketStream;
ConnectivityCubit() : super(ConnectivityState.initial());
void setConnection(IConnection? connection, ControlMode mode) {
// 1. 彻底清理
_stopMonitoring();
_statusSub?.cancel();
_activeConnection?.disconnect();
// 2. 切断旧流的所有引用
_broadcastPacketStream = null;
_activeConnection = connection;
if (connection != null) {
_statusSub = connection.statusStream.listen((status) {
emit(state.copyWith(activeMode: mode, status: status));
if (status == ConnectionStatus.connected) {
// 3. 只有连接成功时,才初始化广播流
_ensureStreamInitialized();
_startMonitoring();
} else {
_stopMonitoring();
}
});
connection.connect();
} else {
emit(state.copyWith(activeMode: ControlMode.NONE, status: ConnectionStatus.disconnected));
}
}
// 核心:强制初始化,确保 transform 只跑一次
void _ensureStreamInitialized() {
if (_activeConnection != null && _broadcastPacketStream == null) {
_broadcastPacketStream = _activeConnection!.rawStream
.transform(PacketParserTransformer())
.asBroadcastStream();
}
}
void _startMonitoring() {
_stopMonitoring();
_resetWatchdog();
// 确保流已存在
_ensureStreamInitialized();
// 此时 listen 的是已经转换好的广播流,绝对不会触发 rawStream.transform
_packetSub = _broadcastPacketStream?.listen((packet) {
_resetWatchdog();
if (packet.cmdType == 0xFF) {
sendCommand(0xFF, null);
sl<ILoggerService>().log("收到心跳包,已回复...");
}
}, onError: (e) => sl<ILoggerService>().log("流监听错误: $e"));
}
// Getter 也要改,保证它只返回已经创建好的流
Stream<PacketEntity> get activePacketStream {
_ensureStreamInitialized();
return _broadcastPacketStream ?? const Stream.empty();
}
// --- 其他逻辑保持不变 ---
void _resetWatchdog() {
_watchdogTimer?.cancel();
_watchdogTimer = Timer(const Duration(seconds: _watchdogTimerDuration), () {
sl<ILoggerService>().log("$_watchdogTimerDuration秒内未收到心跳包...");
emit(state.copyWith(status: ConnectionStatus.disconnected));
});
}
void _stopMonitoring() {
_packetSub?.cancel();
_packetSub = null;
_watchdogTimer?.cancel();
_watchdogTimer = null;
}
Future<Either<Failure, Unit>> sendCommand(int cmd, List<int>? payload) async {
if (_activeConnection == null) return left(NetworkFailure("无连接"));
final frame = <int>[
ProtocolConstants.header1,
ProtocolConstants.header2,
cmd,
...?payload,
if (payload != null && payload.isNotEmpty) ...[0x00, 0x00],
ProtocolConstants.endFlag1,
ProtocolConstants.endFlag2
];
return await _activeConnection!.send(frame);
}
@override
Future<void> close() {
_stopMonitoring();
_statusSub?.cancel();
_activeConnection?.disconnect();
return super.close();
}
}

View File

@@ -0,0 +1,26 @@
import 'package:equatable/equatable.dart';
import '../../../../core/network/base/i_connection.dart';
import '../../../../core/network/enums/control_mode.dart';
class ConnectivityState extends Equatable{
final ControlMode activeMode;
final ConnectionStatus status;
const ConnectivityState({required this.activeMode, required this.status});
factory ConnectivityState.initial() => ConnectivityState(
activeMode: ControlMode.NONE,
status: ConnectionStatus.disconnected,
);
ConnectivityState copyWith({ControlMode? activeMode, ConnectionStatus? status}) {
return ConnectivityState(
activeMode: activeMode ?? this.activeMode,
status: status ?? this.status,
);
}
@override
List<Object?> get props => [activeMode, status];
}

View File

@@ -1,6 +1,6 @@
import 'package:maibu_satabot_v2/features/devices/domain/entities/device_entity.dart';
abstract class DeviceHttpDatasource {
abstract class IDeviceHttpDatasource {
Future<List<DeviceEntity>> getUserDevices(String username);
Future<int> bindDevice(String deviceId, String deviceAlias);
Future<int> unbindDevice(String deviceId);

View File

@@ -1,9 +1,9 @@
import 'package:dio/dio.dart';
import 'package:maibu_satabot_v2/core/consts/http_api_consts.dart';
import 'package:maibu_satabot_v2/features/devices/data/datasources/device_http_datasource.dart';
import 'package:maibu_satabot_v2/features/devices/data/datasources/i_device_http_datasource.dart';
import 'package:maibu_satabot_v2/features/devices/domain/entities/device_entity.dart';
class DeviceHttpDatasourceImpl implements DeviceHttpDatasource {
class DeviceHttpDatasourceImpl implements IDeviceHttpDatasource {
final Dio dio;
DeviceHttpDatasourceImpl(this.dio);

View File

@@ -1,7 +1,7 @@
import 'package:maibu_satabot_v2/core/data/base_model.dart';
import 'package:maibu_satabot_v2/features/devices/domain/entities/device_entity.dart';
class DeviceModel extends DeviceEntity implements BaseModel {
class DeviceModel extends DeviceEntity implements BaseModel<DeviceEntity> {
DeviceModel({
required super.deviceName,
required super.productId,
@@ -30,6 +30,7 @@ class DeviceModel extends DeviceEntity implements BaseModel {
);
}
@override
Map<String, dynamic> toJson() {
return {
'deviceName': deviceName,
@@ -45,7 +46,8 @@ class DeviceModel extends DeviceEntity implements BaseModel {
};
}
toEntity() {
@override
DeviceEntity toEntity() {
return DeviceEntity(
deviceName: deviceName,
productId: productId,

View File

@@ -1,13 +1,13 @@
import 'package:dio/dio.dart';
import 'package:fpdart/src/either.dart';
import 'package:maibu_satabot_v2/features/devices/data/datasources/device_http_datasource.dart';
import 'package:maibu_satabot_v2/features/devices/data/datasources/i_device_http_datasource.dart';
import 'package:maibu_satabot_v2/features/devices/domain/entities/device_entity.dart';
import 'package:maibu_satabot_v2/features/devices/domain/errors/device_failure.dart';
import '../../domain/repositories/device_repository.dart';
class DeviceRepositoryImpl implements DeviceRepository {
DeviceHttpDatasource _deviceHttpDatasource;
IDeviceHttpDatasource _deviceHttpDatasource;
DeviceRepositoryImpl(this._deviceHttpDatasource);

View File

@@ -12,7 +12,7 @@ class DeviceEntity extends Equatable {
final int isBind; // 是否被绑定过
final int onlineStatus; // 在线状态
DeviceEntity({
const DeviceEntity({
required this.deviceName,
required this.productId,
required this.productName,

View File

@@ -1,6 +1,6 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:maibu_satabot_v2/core/app/app_user_cubit.dart';
import 'package:maibu_satabot_v2/features/auth/presentation/bloc/app_user_cubit.dart';
import 'package:maibu_satabot_v2/features/devices/presentation/bloc/devices_cubit.dart';
import '../widgets/ImmersionHeader.dart';

View File

@@ -1,7 +1,7 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../../../core/app/app_user_cubit.dart';
import '../../../auth/presentation/bloc/app_user_cubit.dart';
import '../../../devices/presentation/bloc/devices_cubit.dart';
class RoutePlanPage extends StatelessWidget {

View File

@@ -1,7 +1,7 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../../../core/app/app_user_cubit.dart';
import '../../../auth/presentation/bloc/app_user_cubit.dart';
import '../../../devices/presentation/bloc/devices_cubit.dart';
class RunningStatusPage extends StatelessWidget {

View File

@@ -4,7 +4,7 @@ import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:google_fonts/google_fonts.dart';
import 'package:maibu_satabot_v2/features/devices/domain/entities/device_entity.dart';
import '../../../../core/app/app_user_cubit.dart';
import '../../../auth/presentation/bloc/app_user_cubit.dart';
import '../../../devices/presentation/bloc/devices_cubit.dart';
import '../../../devices/presentation/bloc/devices_state.dart';
// 导入你的主题文件以获取 offWhite

View File

@@ -1,9 +1,9 @@
import 'package:dio/dio.dart';
class RemoteHttpDatasource {
class IRemoteHttpDatasource {
final Dio _dio;
RemoteHttpDatasource(this._dio);
IRemoteHttpDatasource(this._dio);
/// 示例:获取控制授权 (对应之前逻辑中的权限申请)
Future<Map<String, dynamic>> requestControlAuth(String robotId) async {

View File

@@ -1,30 +0,0 @@
import 'dart:typed_data';
import '../../../../core/protocol/machine_protocol_codec.dart';
class LawnMoverProtocolModel {
final int left;
final int right;
final int lift;
final int mower;
final int ignition;
final int emergency;
LawnMoverProtocolModel({
required this.left,
required this.right,
this.lift = 0,
this.mower = 0,
this.ignition = 0,
this.emergency = 0,
});
Uint8List toBytes() => MachineProtocolCodec.encodeRemoteControlPayload(
left: left,
right: right,
lift: lift,
mower: mower,
ignition: ignition,
emergency: emergency,
);
}

View File

@@ -0,0 +1,89 @@
import 'dart:typed_data';
import 'package:maibu_satabot_v2/core/data/base_model.dart';
import 'package:maibu_satabot_v2/features/remote_control/domain/entities/machine_control_entity.dart';
class MachineControlModel extends MachineControlEntity implements BaseModel<MachineControlEntity>{
MachineControlModel({
required super.leftSpeed,
required super.rightSpeed,
required super.chassisLift,
required super.mowerSpeed,
required super.ignitionStatus,
required super.isEmergency,
});
factory MachineControlModel.fromEntity(MachineControlEntity entity) {
return MachineControlModel(
leftSpeed: entity.leftSpeed,
rightSpeed: entity.rightSpeed,
chassisLift: entity.chassisLift,
mowerSpeed: entity.mowerSpeed,
ignitionStatus: entity.ignitionStatus,
isEmergency: entity.isEmergency,
);
}
/// 需要传入计算好的左右轮速度
Uint8List toRawPacket(Map<String, int> speeds) {
// 1. 调用 Codec:生成协议定义的 8 字节 Payload
final payload = encodeRemoteControlPayload(
left: speeds['left'] ?? 0,
right: speeds['right'] ?? 0,
lift: chassisLift,
mower: mowerSpeed,
ignition: ignitionStatus,
emergency: isEmergency ? 1 : 0,
);
// 2. 调用 Builder:封装帧头、指令、CRC、帧尾
return payload;
}
@override
MachineControlEntity toEntity() {
return MachineControlEntity(
leftSpeed: leftSpeed,
rightSpeed: rightSpeed,
chassisLift: chassisLift,
mowerSpeed: mowerSpeed,
ignitionStatus: ignitionStatus,
isEmergency: isEmergency,
);
}
@override
Map<String, dynamic> toJson() {
return {
'leftSpeed': leftSpeed,
'rightSpeed': rightSpeed,
'chassisLift': chassisLift,
'mowerSpeed': mowerSpeed,
'ignitionStatus': ignitionStatus,
'isEmergency': isEmergency,
};
}
Uint8List encodeRemoteControlPayload({
required int left,
required int right,
int lift = 0,
int mower = 0,
int ignition = 0,
int emergency = 0,
}) {
final bd = ByteData(8); // 只分配 8 字节
int pos = 0;
bd.setInt16(pos, left, Endian.little);
pos += 2;
bd.setInt16(pos, right, Endian.little);
pos += 2;
bd.setUint8(pos++, lift);
bd.setUint8(pos++, mower);
bd.setUint8(pos++, ignition);
bd.setUint8(pos++, emergency);
return bd.buffer.asUint8List();
}
}

View File

@@ -1,103 +1,132 @@
class RunningStatusModel {
final String voltage;
final String leftTargetSpeed;
final String rightTargetSpeed;
final String leftMeasureSpeed;
final String rightMeasureSpeed;
final String leftCurrent;
final String rightCurrent;
final String leftMotorTemp;
final String rightMotorTemp;
final String chipTemp;
final String yaw;
final String pitch;
final String roll;
final String satelliteCnt;
final String qual;
final String headingStatus;
final String latitude;
final String longitude;
final String time;
final String knifeCuttingSpeed;
final String controlMode;
final String workingArea;
final String battery;
import 'dart:convert';
import 'dart:typed_data';
import 'package:maibu_satabot_v2/core/data/base_model.dart';
import 'package:maibu_satabot_v2/features/remote_control/domain/entities/running_status_entity.dart';
import '../../../../core/network/enums/control_mode.dart';
class RunningStatusModel extends RunningStatusEntity
implements BaseModel<RunningStatusEntity> {
RunningStatusModel({
this.voltage = '',
this.leftTargetSpeed = '',
this.rightTargetSpeed = '',
this.leftMeasureSpeed = '',
this.rightMeasureSpeed = '',
this.leftCurrent = '',
this.rightCurrent = '',
this.leftMotorTemp = '',
this.rightMotorTemp = '',
this.chipTemp = '',
this.yaw = '',
this.pitch = '',
this.roll = '',
this.satelliteCnt = '',
this.qual = '',
this.headingStatus = '',
this.latitude = '',
this.longitude = '',
this.time = '',
this.knifeCuttingSpeed = '',
this.controlMode = '',
this.workingArea = '',
this.battery = '',
required super.voltage,
required super.leftTargetSpeed,
required super.rightTargetSpeed,
required super.leftMeasureSpeed,
required super.rightMeasureSpeed,
required super.leftCurrent,
required super.rightCurrent,
required super.leftMotorTemp,
required super.rightMotorTemp,
required super.chipTemp,
required super.yaw,
required super.pitch,
required super.roll,
required super.satelliteCnt,
required super.qual,
required super.headingStatus,
required super.latitude,
required super.longitude,
required super.time,
required super.knifeCuttingSpeed,
required super.controlMode,
required super.workingArea,
required super.battery,
required super.obstacleFlag,
});
RunningStatusModel copyWith({
String? voltage,
String? leftTargetSpeed,
String? rightTargetSpeed,
String? leftMeasureSpeed,
String? rightMeasureSpeed,
String? leftCurrent,
String? rightCurrent,
String? leftMotorTemp,
String? rightMotorTemp,
String? chipTemp,
String? yaw,
String? pitch,
String? roll,
String? satelliteCnt,
String? qual,
String? headingStatus,
String? latitude,
String? longitude,
String? time,
String? knifeCuttingSpeed,
String? controlMode,
String? workingArea,
String? battery,
}) {
@override
RunningStatusEntity toEntity() => this;
@override
Map<String, dynamic> toJson() {
return {
'voltage': voltage,
'leftTargetSpeed': leftTargetSpeed,
'rightTargetSpeed': rightTargetSpeed,
'leftMeasureSpeed': leftMeasureSpeed,
'rightMeasureSpeed': rightMeasureSpeed,
'leftCurrent': leftCurrent,
'rightCurrent': rightCurrent,
'leftMotorTemp': leftMotorTemp,
'rightMotorTemp': rightMotorTemp,
'chipTemp': chipTemp,
'yaw': yaw,
'pitch': pitch,
'roll': roll,
'satelliteCnt': satelliteCnt,
'qual': qual,
'headingStatus': headingStatus,
'latitude': latitude,
'longitude': longitude,
'time': time,
'knifeCuttingSpeed': knifeCuttingSpeed,
'controlMode': controlMode,
'workingArea': workingArea,
'battery': battery,
'obstacleFlag': obstacleFlag,
};
}
factory RunningStatusModel.fromBytes(Uint8List payload) {
// 将 Uint8List 转换为字符串 (通常是 utf8 或 ascii)
final rawString = utf8.decode(payload);
// 按逗号分割成数组
final parts = rawString.split(',');
// 辅助函数:安全转换数字,防止硬件发空字符串导致崩溃
double parseD(int index) =>
(index < parts.length && parts[index].isNotEmpty)
? double.tryParse(parts[index]) ?? 0.0
: 0.0;
int parseI(int index) => (index < parts.length && parts[index].isNotEmpty)
? int.tryParse(parts[index]) ?? 0
: 0;
String parseS(int index) => (index < parts.length) ? parts[index] : "";
ControlMode mapMode(int val) {
switch (val) {
case 1:
return ControlMode.LOCAL;
case 2:
return ControlMode.BLE;
case 3:
return ControlMode.TCP;
case 4:
return ControlMode.OTHER;
default:
return ControlMode.NONE; // 0 或其他异常值
}
}
return RunningStatusModel(
voltage: voltage ?? this.voltage,
leftTargetSpeed: leftTargetSpeed ?? this.leftTargetSpeed,
rightTargetSpeed: rightTargetSpeed ?? this.rightTargetSpeed,
leftMeasureSpeed: leftMeasureSpeed ?? this.leftMeasureSpeed,
rightMeasureSpeed: rightMeasureSpeed ?? this.rightMeasureSpeed,
leftCurrent: leftCurrent ?? this.leftCurrent,
rightCurrent: rightCurrent ?? this.rightCurrent,
leftMotorTemp: leftMotorTemp ?? this.leftMotorTemp,
rightMotorTemp: rightMotorTemp ?? this.rightMotorTemp,
chipTemp: chipTemp ?? this.chipTemp,
yaw: yaw ?? this.yaw,
pitch: pitch ?? this.pitch,
roll: roll ?? this.roll,
satelliteCnt: satelliteCnt ?? this.satelliteCnt,
qual: qual ?? this.qual,
headingStatus: headingStatus ?? this.headingStatus,
latitude: latitude ?? this.latitude,
longitude: longitude ?? this.longitude,
time: time ?? this.time,
knifeCuttingSpeed: knifeCuttingSpeed ?? this.knifeCuttingSpeed,
controlMode: controlMode ?? this.controlMode,
workingArea: workingArea ?? this.workingArea,
battery: battery ?? this.battery,
voltage: parseD(0), // 电压
leftTargetSpeed: parseI(1), // 左轮目标速度
rightTargetSpeed: parseI(2), // 右轮目标速度
leftMeasureSpeed: parseI(3), // 左轮测量速度
rightMeasureSpeed: parseI(4), // 右轮测量速度
leftCurrent: parseD(5), // 左轮电流
rightCurrent: parseD(6), // 右轮电流
leftMotorTemp: parseD(7), // 左轮温度
rightMotorTemp: parseD(8), // 右轮温度
chipTemp: parseD(9), // 芯片温度
yaw: parseD(10), // 偏航角
pitch: parseD(11), // 仰俯角
roll: parseD(12), // 翻滚角
satelliteCnt: parseI(13), // 卫星数
qual: parseI(14), // 定位质量 (0-7)
headingStatus: parseI(15), // 航向角状态
latitude: parseD(16), // 纬度
longitude: parseD(17), // 经度
time: parseS(18), // 时间字符串
knifeCuttingSpeed: parseI(19), // 割刀速度
controlMode: mapMode(20), // 控制模式 (0-4)
battery: parseD(21), // 电池电量 (0-100)
workingArea: parseD(22), // 作业面积
obstacleFlag: parseI(23), // 障碍物标志 (0/1)
);
}
}

View File

@@ -1,47 +1,65 @@
import 'dart:async';
import 'dart:typed_data';
import 'package:maibu_satabot_v2/core/protocol/protocol_constants.dart';
import 'package:maibu_satabot_v2/core/protocol/packet_entity.dart';
import 'package:maibu_satabot_v2/features/remote_control/data/models/machine_control_model.dart';
import 'package:maibu_satabot_v2/features/remote_control/domain/entities/running_status_entity.dart';
import '../../../../core/network/protocol_decoder.dart';
import '../../../../core/network/tcp/tcp_client.dart';
import '../../../../core/protocol/machine_protocol_codec.dart';
import '../../../../core/protocol/machine_protocol_constants.dart';
import '../../domain/entities/machine_control_status_entity.dart';
import '../../../connectivity/presentation/bloc/connectivity_cubit.dart';
import '../../domain/entities/machine_control_entity.dart';
import '../../domain/repositories/remote_control_repository.dart';
import '../../domain/usecase/diff_steer_usecase.dart';
import '../models/running_status_model.dart';
class RemoteControlRepositoryImpl implements RemoteControlRepository {
final TcpClient _tcpClient;
final ConnectivityCubit _connectivityCubit;
final DiffSteerUseCase _diffSteer;
RemoteControlRepositoryImpl(this._tcpClient, this._diffSteer);
RemoteControlRepositoryImpl(
this._connectivityCubit, // 现在统一通过 Cubit 管理连接和分发
this._diffSteer,
);
@override
void changeWebViewDirection(String direction) {
// TODO: implement changeWebViewDirection
// 逻辑保持不变
}
@override
void sendControlMachineCmd(MachineControlStatusEntity status) {
// 1. 调用算法:将摇杆坐标 (x, y) 转换为左右轮电机转速
final speeds = _diffSteer.calculate(status.originX, status.originY);
Future<void> sendControlMachineCmd(MachineControlEntity entity) async {
// 1. 调用差速算法计算左右轮速度
final speeds = _diffSteer.calculate(entity.rightSpeed, entity.leftSpeed);
// 2. 调用 Codec:仅生成协议要求的 8 字节 Payload 负载数据
final payload = MachineProtocolCodec.encodeRemoteControlPayload(
left: speeds['left']!,
right: speeds['right']!,
lift: status.chassisLift,
mower: status.mowerSpeed,
ignition: status.ignitionStatus,
emergency: status.isEmergency ? 1 : 0,
);
var model = MachineControlModel.fromEntity(entity);
// 3. 调用 TcpClient:发送指令。
// TcpClient.sendRaw 会自动帮你加上 [0xAB, 0xAA] 头和 [0xAA, 0xAB] 尾
_tcpClient.sendRaw(
MachineProtocolConstants.cmdRemoteControl, // 这里通常是 0x00
payload,
var rawPacket = model.toRawPacket(speeds);
// 3. 通过连接管理器发出,内部会自动处理 [AB AA] ... [CRC] [AA AB]
// 并且会执行 flush() 确保 100ms 实时性
await _connectivityCubit.sendCommand(
ProtocolConstants.cmdRemoteControl, // 0x00
rawPacket,
);
}
/// 过滤出 0x02 状态包并转换为业务 Entity
@override
Stream<RawPacket> get responseStream => _tcpClient.packetStream;
}
Stream<RunningStatusEntity> get runningStatusStream {
return _connectivityCubit.activePacketStream
.where((p) => p.cmdType == ProtocolConstants.cmdStatusInfo) // 0x02
.map((packet) {
// 第一步:由 Model 负责处理“脏活”(字符串拆分、类型转换)
final model = RunningStatusModel.fromBytes(Uint8List.fromList(packet.payload));
// 第二步:转换为 Entity 交付给 UI 或 UseCase
return model.toEntity();
});
}
/// 暴露原始解析流 (RawPacket 在我们这里对应 Packet 类)
/// UI 或其他 Cubit 监听此流来处理机器人主动推送的消息
@override
Stream<PacketEntity> get responseStream => _connectivityCubit.activePacketStream;
}

View File

@@ -1,21 +1,23 @@
class MachineControlStatusEntity {
final int originX;
final int originY;
import 'package:equatable/equatable.dart';
class MachineControlEntity extends Equatable{
final int leftSpeed;
final int rightSpeed;
final int chassisLift; // 0:停, 1:上, 2:下
final int mowerSpeed; // 0:停, 1:+, 2:-
final int ignitionStatus; // 0:停, 1:火, 2:熄
final bool isEmergency;
MachineControlStatusEntity({
this.originX = 0,
this.originY = 0,
MachineControlEntity({
this.leftSpeed = 0,
this.rightSpeed = 0,
this.chassisLift = 0,
this.mowerSpeed = 0,
this.ignitionStatus = 0,
this.isEmergency = false,
});
MachineControlStatusEntity copyWith({
MachineControlEntity copyWith({
int? x,
int? y,
int? lift,
@@ -23,13 +25,23 @@ class MachineControlStatusEntity {
int? ignition,
bool? emergency,
}) {
return MachineControlStatusEntity(
originX: x ?? originX,
originY: y ?? originY,
return MachineControlEntity(
leftSpeed: y ?? leftSpeed,
rightSpeed: x ?? rightSpeed,
chassisLift: lift ?? chassisLift,
mowerSpeed: mower ?? mowerSpeed,
ignitionStatus: ignition ?? ignitionStatus,
isEmergency: emergency ?? isEmergency,
);
}
@override
List<Object?> get props => [
leftSpeed,
rightSpeed,
chassisLift,
mowerSpeed,
ignitionStatus,
isEmergency,
];
}

View File

@@ -0,0 +1,112 @@
import 'package:equatable/equatable.dart';
import '../../../../core/network/enums/control_mode.dart';
import '../../data/models/running_status_model.dart';
class RunningStatusEntity extends Equatable {
final double voltage; //电压
final int leftTargetSpeed; //左目标速度
final int rightTargetSpeed; //右目标速度
final int leftMeasureSpeed; //左测速
final int rightMeasureSpeed; //右测速
final double leftCurrent; //左电流
final double rightCurrent; //右电流
final double leftMotorTemp; //左电机温度
final double rightMotorTemp; //右电机温度
final double chipTemp; //芯片温度
final double yaw; //偏航角
final double pitch; //俯仰角
final double roll; //滚转角
final int satelliteCnt; //卫星数量
final int qual; //定位质量: 0:无效 1:GPS单点定位 2:DGPS 3:RTK固定解 4:RTK浮点解 5.手动输入模式
final int headingStatus; //航向角状态: 0:未完成, 1:完成
final double latitude; //纬度
final double longitude; //经度
final String time; //时间
final int knifeCuttingSpeed; //割刀速度
final ControlMode controlMode; //控制模式: 0.无控制 1.本地控制 2.蓝牙 3.TCP 4.其他接口
final double battery; //电池电量
final double workingArea; //作业面积
final int obstacleFlag; //障碍物标志位 0:无 1:有
RunningStatusEntity({
this.voltage = 0.0,
this.leftTargetSpeed = 0,
this.rightTargetSpeed = 0,
this.leftMeasureSpeed = 0,
this.rightMeasureSpeed = 0,
this.leftCurrent = 0.0,
this.rightCurrent = 0.0,
this.leftMotorTemp = 0.0,
this.rightMotorTemp = 0.0,
this.chipTemp = 0.0,
this.yaw = 0.0,
this.pitch = 0.0,
this.roll = 0.0,
this.satelliteCnt = 0,
this.qual = 0,
this.headingStatus = 0,
this.latitude = 0.0,
this.longitude = 0.0,
this.time = '',
this.knifeCuttingSpeed = 0,
this.controlMode = ControlMode.NONE,
this.workingArea = 0.0,
this.battery = 0.0,
this.obstacleFlag = 0,
});
factory RunningStatusEntity.fromModel(RunningStatusModel model) {
return RunningStatusEntity(
voltage: model.voltage,
leftTargetSpeed: model.leftTargetSpeed,
rightTargetSpeed: model.rightTargetSpeed,
leftMeasureSpeed: model.leftMeasureSpeed,
rightMeasureSpeed: model.rightMeasureSpeed,
leftCurrent: model.leftCurrent,
rightCurrent: model.rightCurrent,
leftMotorTemp: model.leftMotorTemp,
rightMotorTemp: model.rightMotorTemp,
chipTemp: model.chipTemp,
yaw: model.yaw,
pitch: model.pitch,
roll: model.roll,
satelliteCnt: model.satelliteCnt,
qual: model.qual,
headingStatus: model.headingStatus,
latitude: model.latitude,
longitude: model.longitude,
time: model.time,
knifeCuttingSpeed: model.knifeCuttingSpeed,
controlMode: model.controlMode,
workingArea: model.workingArea,
battery: model.battery,
obstacleFlag: model.obstacleFlag,
);
}
@override
List<Object?> get props => [
voltage,
leftTargetSpeed,
rightTargetSpeed,
leftMeasureSpeed,
rightMeasureSpeed,
leftCurrent,
rightCurrent,
leftMotorTemp,
rightMotorTemp,
chipTemp,
yaw,
pitch,
roll,
satelliteCnt,
qual,
headingStatus,
latitude,
longitude,
time,
knifeCuttingSpeed,
controlMode,
];
}

View File

@@ -1,15 +1,20 @@
import '../../../../core/network/protocol_decoder.dart';
import '../../domain/entities/machine_control_status_entity.dart';
import 'package:maibu_satabot_v2/core/protocol/packet_entity.dart';
import 'package:maibu_satabot_v2/features/remote_control/domain/entities/running_status_entity.dart';
import '../../domain/entities/machine_control_entity.dart';
abstract class RemoteControlRepository {
/// 高频发送遥控指令 (被 Cubit 的 100ms 定时器调用)
/// 负责将 Entity 转换为底层的 0x00 指令并发出
void sendControlMachineCmd(MachineControlStatusEntity status);
/// 高频发送遥控指令
/// 修改建议:返回 Future<void> 没问题,但内部建议处理错误
Future<void> sendControlMachineCmd(MachineControlEntity status);
/// 暴露解析后的回包流 (如 0x12 权限申请)
/// UI 或 Cubit 监听此流来处理机器人主动推送的消息
Stream<RawPacket> get responseStream;
/// 状态信息流 (过滤后的 0x02)
Stream<RunningStatusEntity> get runningStatusStream;
/// WebView 视角控制 (逻辑层面的切换,不涉及 TCP)
/// 暴露解析后的原始包流 (统一改为 Packet)
/// 这里的 Packet 已经是去掉了 AB AA 和 AA AB 的清洁包
Stream<PacketEntity> get responseStream;
/// WebView 视角控制 (保持不变)
void changeWebViewDirection(String direction);
}

View File

@@ -1,47 +1,104 @@
import 'dart:async';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:maibu_satabot_v2/features/remote_control/data/models/running_status_model.dart';
import 'package:maibu_satabot_v2/core/logging/i_logger_service.dart';
import 'package:maibu_satabot_v2/core/protocol/packet_entity.dart';
import 'package:maibu_satabot_v2/features/remote_control/domain/entities/running_status_entity.dart';
import 'package:maibu_satabot_v2/features/remote_control/presentation/bloc/remote_control_state.dart';
import '../../domain/entities/machine_control_status_entity.dart';
import '../../../../core/di/injection.dart';
import '../../../connectivity/presentation/bloc/connectivity_cubit.dart';
import '../../domain/entities/machine_control_entity.dart';
import '../../domain/repositories/remote_control_repository.dart';
class RemoteControlCubit extends Cubit<RemoteControlState> {
final RemoteControlRepository _repository;
Timer? _timer;
final ConnectivityCubit connectivityCubit;
RemoteControlCubit(this._repository)
: super(
RemoteControlState(
controlEntity: MachineControlStatusEntity(),
runningStatusModel: RunningStatusModel(),
),
) {
// 定时器与流订阅管理
Timer? _timer;
StreamSubscription<RunningStatusEntity>? _statusSubscription;
StreamSubscription<PacketEntity>? _responseSubscription;
RemoteControlCubit(this._repository,this.connectivityCubit)
: super(
RemoteControlState(
controlEntity: MachineControlEntity(),
runningStatusEntity: RunningStatusEntity(),
),
) {
// 构造时立即开启监听
_initPacketListener();
_initRunningStatusListener();
}
// 1. 初始化回包监听 (如 0x12 权限)
// ==========================================
// 1. 数据监听逻辑 (Data Listening)
// ==========================================
/// 监听原始回包 (处理如 0x12 权限申请等指令)
void _initPacketListener() {
_repository.responseStream.listen((packet) {
if (packet.command == 0x12) {
// 根据负载判断是否有权限,更新状态
emit(state.copyWith(hasPermission: true));
_responseSubscription?.cancel();
_responseSubscription = _repository.responseStream.listen((packet) {
// 处理权限申请回包 (假设 0x12)
if (packet.cmdType == 0x12) {
// 假设 payload[0] == 1 表示获得权限,这里可以解析出平台信息
final bool granted = packet.payload.isNotEmpty && packet.payload[0] == 0x01;
emit(state.copyWith(
hasPermission: granted,
// 假设协议中后续字节带有平台名称
permissionPlatform: granted ? "Other Device" : "",
));
}
});
}
// 2. 开启 100ms 控制循环 (在进入遥控页面或点击“开始”时调用)
/// 监听运行状态回包 (0x02) 并更新 UI
void _initRunningStatusListener() {
_statusSubscription?.cancel();
_statusSubscription = _repository.runningStatusStream.listen((entity) {
// 同步硬件状态到 Bloc State
emit(state.copyWith(
runningStatusEntity: entity,
obstacleFlag: entity.obstacleFlag == 1 ? "检测到障碍物" : "无",
// 如果硬件上报了急停或锁定,这里也可以同步更新状态
isEmergency: entity.obstacleFlag == 1,
));
}, onError: (e) {
emit(state.copyWith(errorMessage: "运行状态流监听异常: $e"));
});
}
// ==========================================
// 2. 控制循环逻辑 (Control Loop)
// ==========================================
/// 启动 100ms 高频控制循环
void startControlLoop() {
_timer?.cancel();
_timer = Timer.periodic(const Duration(milliseconds: 100), (timer) {
// 核心调用:直接把 state 里的实体丢给 repository
_repository.sendControlMachineCmd(state.controlEntity);
_timer = Timer.periodic(const Duration(milliseconds: 100), (timer) async {
// 核心:在具备驾驶条件(有权限、未锁定、非急停)时发送指令
if (state.canDrive) {
try {
await _repository.sendControlMachineCmd(state.controlEntity);
} catch (e) {
print("发送控制指令失败: $e");
}
}
});
emit(state.copyWith(status: RemoteControlStatus.controlling));
}
// 3. 更新摇杆数据
/// 停止控制循环
void stopControlLoop() {
_timer?.cancel();
_timer = null;
emit(state.copyWith(status: RemoteControlStatus.initial));
}
// ==========================================
// 3. UI 交互方法 (UI Actions)
// ==========================================
/// 更新摇杆坐标 (x, y 为 -100 到 100)
void updateJoystick(double x, double y) {
final updatedEntity = state.controlEntity.copyWith(
x: x.toInt(),
@@ -50,53 +107,92 @@ class RemoteControlCubit extends Cubit<RemoteControlState> {
emit(state.copyWith(controlEntity: updatedEntity));
}
// 4. 更新功能开关 (比如割刀速度、灯光、点火等)
void updateFunction({int? mower, int? lift, int? ignition, bool? emergency}) {
final updatedEntity = state.controlEntity.copyWith(
mower: mower,
lift: lift,
ignition: ignition,
emergency: emergency,
);
/// 更新原点 Y 轴 (针对特定的 UI 逻辑)
void updateOriginY(int y) {
final updatedEntity = state.controlEntity.copyWith(y: y);
emit(state.copyWith(controlEntity: updatedEntity));
}
void updateOriginY(int y) {}
// 5. 停止控制循环
void stopControlLoop() {
_timer?.cancel();
_timer = null;
emit(state.copyWith(status: RemoteControlStatus.initial));
/// 更新底盘升降
void updateChassisLift(int level) {
final updatedEntity = state.controlEntity.copyWith(lift: level);
emit(state.copyWith(controlEntity: updatedEntity));
}
void toggleLock() {}
/// 更新急停状态
void updateEmergency(bool emergency) {
final updatedEntity = state.controlEntity.copyWith(emergency: emergency);
emit(state.copyWith(
controlEntity: updatedEntity,
isEmergency: emergency,
));
}
/// 更新割刀/功能开关
void updateMower(int speed) {
final updatedEntity = state.controlEntity.copyWith(mower: speed);
emit(state.copyWith(controlEntity: updatedEntity));
}
/// 切换控制锁定
void toggleLock() {
emit(state.copyWith(isLocked: !state.isLocked));
}
/// 权限请求弹窗控制
void togglePermissionDialog(bool show) {
emit(state.copyWith(showPermissionRequestDialog: show));
}
void requestControlPermission() {
// 1. 关闭弹窗
emit(state.copyWith(showPermissionRequestDialog: false));
// 2. 这里执行你发送 0x12 指令的逻辑
// _sendProtocolData(0x12, ...);
/// 发起/响应权限请求
void respondPermission(bool accept) {
// 这里可以添加发送 0x12 响应包给硬件的逻辑
emit(state.copyWith(
hasPermission: accept,
showPermissionRequestDialog: false,
));
}
/// 切换画中画显示
void toggleLeftPip() => emit(state.copyWith(showLeftPip: !state.showLeftPip));
void toggleRightPip() =>
emit(state.copyWith(showRightPip: !state.showRightPip));
void toggleRightPip() => emit(state.copyWith(showRightPip: !state.showRightPip));
void toggleTopLeftExpand() {
emit(state.copyWith(topRightIsExpanded: !state.topRightIsExpanded));
}
void toggleObstacleRecognition() {
emit(state.copyWith(obstacleRecognitionFlag: !state.obstacleRecognitionFlag));
}
void initVideoUrls(String main, String ai) {
emit(state.copyWith(
mainStreamUrl: main,
aiStreamUrl: ai,
isVideoEnabled: true,
));
}
void disposeVideo() {
emit(state.copyWith(
mainStreamUrl: "",
aiStreamUrl: "",
isVideoEnabled: false,
obstacleRecognitionFlag: false,
));
sl<ILoggerService>().log("Cubit: 视频地址已置空,准备断开连接");
}
// ==========================================
// 4. 销毁与清理 (Cleanup)
// ==========================================
@override
Future<void> close() {
_timer?.cancel(); // 退出页面时务必销毁定时器
_timer?.cancel();
_statusSubscription?.cancel();
_responseSubscription?.cancel();
return super.close();
}
void updateChassisLift(int i) {}
void updateEmergency(bool bool) {}
void respondPermission(bool bool) {}
}
}

View File

@@ -1,88 +1,106 @@
import 'package:equatable/equatable.dart';
import 'package:maibu_satabot_v2/features/remote_control/data/models/running_status_model.dart';
import '../../domain/entities/machine_control_status_entity.dart';
import '../../domain/entities/machine_control_entity.dart';
import '../../domain/entities/running_status_entity.dart';
enum RemoteControlStatus { initial, controlling, error }
enum ControlMode { TCP, BLE, LOCAL, NONE, OTHER }
class RemoteControlState extends Equatable {
// --- 核心业务状态 ---
final RemoteControlStatus status;
final MachineControlStatusEntity controlEntity; // 控制业务实体
final RunningStatusModel runningStatusModel;
//状态业务实体
final String? errorMessage;
final bool hasPermission; // 是否获得了 0x12 权限
final bool isEmergency;
final bool isLocked;
final int ping;
final int battery;
final int voltage;
final String permissionPlatform;
final String currentPlatform;
final bool showPermissionRequestDialog;
final bool showLeftPip;
final bool showRightPip;
final MachineControlEntity controlEntity; // 发送给机器的控制业务实体
final RunningStatusEntity runningStatusEntity; // 机器回传的状态业务实体
final String obstacleFlag; //障碍物标志位
// --- 权限与平台逻辑 ---
final bool hasPermission; // 0x12 权限状态
final String permissionPlatform; // 权限来源平台 (如 "Web", "App")
final String currentPlatform; // 当前操作平台
final bool showPermissionRequestDialog; // 是否显示权限请求弹窗
// --- 安全与锁定 ---
final bool isEmergency; // 紧急停止状态
final bool isLocked; // 控制锁定状态
final String obstacleFlag; // 障碍物标志位文字(这是机器传过来的) (对应 Entity 里的 0/1)
// --- 网络与连接 ---
final int ping; // 延迟/心跳
final String? errorMessage; // 错误提示
// --- UI 视图配置 ---
final bool showLeftPip; // 左侧画中画显示
final bool showRightPip; // 右侧画中画显示
final bool topRightIsExpanded; // 顶部右侧按钮展开状态
final bool obstacleRecognitionFlag; // 障碍物识别标志位(这是UI显示的)
final String mainStreamUrl;
final String aiStreamUrl;
final bool isVideoEnabled;
const RemoteControlState({
this.status = RemoteControlStatus.initial,
required this.controlEntity,
this.errorMessage,
required this.runningStatusEntity,
this.hasPermission = false,
this.isEmergency = false,
this.isLocked = false,
this.ping = 0,
this.battery = 0,
this.voltage = 0,
this.permissionPlatform = '',
this.currentPlatform = '',
this.showPermissionRequestDialog = false,
this.isEmergency = false,
this.isLocked = false,
this.obstacleFlag = '无',
this.ping = 0,
this.errorMessage,
this.showLeftPip = true,
this.showRightPip = true,
this.obstacleFlag = '',
required this.runningStatusModel,
this.topRightIsExpanded = false,
this.obstacleRecognitionFlag = false,
this.mainStreamUrl = '',
this.aiStreamUrl = '',
this.isVideoEnabled = true
});
// 方便 UI 更新部分属性
// 辅助属性:判断当前是否具备物理驾驶条件
bool get canDrive => hasPermission && !isLocked;
RemoteControlState copyWith({
RemoteControlStatus? status,
MachineControlStatusEntity? controlEntity,
String? errorMessage,
MachineControlEntity? controlEntity,
RunningStatusEntity? runningStatusEntity,
bool? hasPermission,
bool? isEmergency,
bool? isLocked,
int? ping,
int? battery,
int? voltage,
String? permissionPlatform,
String? currentPlatform,
bool? showPermissionRequestDialog,
bool? isEmergency,
bool? isLocked,
String? obstacleFlag,
int? ping,
String? errorMessage,
bool? showLeftPip,
bool? showRightPip,
RunningStatusModel? runningStatusModel,
String? obstacleFlag,
bool? topRightIsExpanded,
bool? obstacleRecognitionFlag,
bool? isVideoEnabled,
String? mainStreamUrl,
String? aiStreamUrl
}) {
return RemoteControlState(
status: status ?? this.status,
controlEntity: controlEntity ?? this.controlEntity,
errorMessage: errorMessage ?? this.errorMessage,
runningStatusEntity: runningStatusEntity ?? this.runningStatusEntity,
hasPermission: hasPermission ?? this.hasPermission,
isEmergency: isEmergency ?? this.isEmergency,
isLocked: isLocked ?? this.isLocked,
ping: ping ?? this.ping,
battery: battery ?? this.battery,
voltage: voltage ?? this.voltage,
permissionPlatform: permissionPlatform ?? this.permissionPlatform,
currentPlatform: currentPlatform ?? this.currentPlatform,
showPermissionRequestDialog:
showPermissionRequestDialog ?? this.showPermissionRequestDialog,
showPermissionRequestDialog: showPermissionRequestDialog ?? this.showPermissionRequestDialog,
isEmergency: isEmergency ?? this.isEmergency,
isLocked: isLocked ?? this.isLocked,
obstacleFlag: obstacleFlag ?? this.obstacleFlag,
ping: ping ?? this.ping,
errorMessage: errorMessage ?? this.errorMessage,
showLeftPip: showLeftPip ?? this.showLeftPip,
showRightPip: showRightPip ?? this.showRightPip,
runningStatusModel: runningStatusModel ?? this.runningStatusModel,
obstacleFlag: obstacleFlag ?? this.obstacleFlag,
topRightIsExpanded: topRightIsExpanded ?? this.topRightIsExpanded,
obstacleRecognitionFlag: obstacleRecognitionFlag ?? this.obstacleRecognitionFlag,
isVideoEnabled: isVideoEnabled ?? this.isVideoEnabled,
mainStreamUrl: mainStreamUrl ?? this.mainStreamUrl,
aiStreamUrl: aiStreamUrl ?? this.aiStreamUrl
);
}
@@ -90,19 +108,22 @@ class RemoteControlState extends Equatable {
List<Object?> get props => [
status,
controlEntity,
errorMessage,
runningStatusEntity,
hasPermission,
isEmergency,
isLocked,
ping,
battery,
voltage,
permissionPlatform,
currentPlatform,
showPermissionRequestDialog,
isEmergency,
isLocked,
obstacleFlag,
ping,
errorMessage,
showLeftPip,
showRightPip,
runningStatusModel,
obstacleFlag,
topRightIsExpanded,
obstacleRecognitionFlag,
isVideoEnabled,
mainStreamUrl,
aiStreamUrl
];
}
}

View File

@@ -4,7 +4,7 @@ import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:go_router/go_router.dart';
import 'package:maibu_satabot_v2/core/consts/tcp_consts.dart';
import '../../../../core/app/app_user_cubit.dart';
import '../../../auth/presentation/bloc/app_user_cubit.dart';
import '../../../devices/presentation/bloc/devices_cubit.dart';
import '../bloc/remote_control_cubit.dart';
import '../bloc/remote_control_state.dart';
@@ -49,125 +49,153 @@ class _RemoteControlPageState extends State<RemoteControlPage> {
final deviceState = context.watch<DevicesCubit>().state;
final currentDevice = deviceState.selectedDevice;
if (currentDevice == null) {
return _buildOfflineScaffold();
if (currentDevice != null &&
context.read<RemoteControlCubit>().state.mainStreamUrl.isEmpty) {
// 拼接 URL
// final String mainUrl =
// "webrtc://${TCPConsts.TCP_IP}/live/livestream/${currentDevice.deviceName}?token=${userState.user!.token}";
// final String aiUrl =
// "webrtc://${TCPConsts.TCP_IP}/live/livestream/${currentDevice.deviceName}/detect?token=${userState.user!.token}";
final String mainUrl =
"webrtc://${TCPConsts.TCP_IP}/live/livestream/111?token=${userState.user!.token}";
final String aiUrl =
"webrtc://${TCPConsts.TCP_IP}/live/livestream/222?token=${userState.user!.token}";
// 初始化给 Cubit
WidgetsBinding.instance.addPostFrameCallback((_) {
context.read<RemoteControlCubit>().initVideoUrls(mainUrl, aiUrl);
});
}
return Scaffold(
backgroundColor: Colors.black,
body: Stack(
children: [
// 在 Stack 的最底层替换:
Positioned.fill(
child: BlocBuilder<RemoteControlCubit, RemoteControlState>(
// 只有当显示隐藏状态改变时才重构,内部的拖拽由组件自身 State 处理,不影响这里
buildWhen: (p, c) =>
p.showLeftPip != c.showLeftPip ||
p.showRightPip != c.showRightPip,
return PopScope(
onPopInvokedWithResult: (didPop, result) {
if (didPop) {
// 无论怎么退出的,都强制清理视频和控制循环
context.read<RemoteControlCubit>().stopControlLoop();
context.read<RemoteControlCubit>().disposeVideo();
}
},
child: Scaffold(
backgroundColor: Colors.black,
body: Stack(
children: [
// 在 Stack 的最底层替换:
Positioned.fill(
child: BlocBuilder<RemoteControlCubit, RemoteControlState>(
buildWhen: (p, c) =>
p.showLeftPip != c.showLeftPip ||
p.showRightPip != c.showRightPip ||
p.obstacleRecognitionFlag != c.obstacleRecognitionFlag ||
p.mainStreamUrl != c.mainStreamUrl, // 必须监听地址变化
builder: (context, state) {
return WebRTCLocalPlayer(
// 关键:使用 state 里的地址,这样 disposeVideo 将其置空时,Player 才会断开
streamUrl: state.mainStreamUrl,
secondStreamUrl: state.aiStreamUrl,
showLeftPip: state.showLeftPip,
showRightPip: state.showRightPip,
obstacleRecognitionFlag: state.obstacleRecognitionFlag,
);
},
),
),
BlocBuilder<RemoteControlCubit, RemoteControlState>(
buildWhen: (p, c) => p.isEmergency != c.isEmergency,
builder: (context, state) {
return WebRTCLocalPlayer(
// 这里的 URL 拼接根据你的后端规则
// streamUrl: "webrtc://${TCPConsts.TCP_IP}/live/livestream/${currentDevice.deviceName}?token=${userState.user!.token}",
streamUrl:
"webrtc://${TCPConsts.TCP_IP}/live/livestream/111?token=${userState.user!.token}",
showLeftPip: state.showLeftPip, // 从 Cubit 状态中读取
showRightPip: state.showRightPip, // 从 Cubit 状态中读取
);
return state.isEmergency
? const Positioned.fill(child: EmergencyOverlay())
: const SizedBox.shrink();
},
),
),
BlocBuilder<RemoteControlCubit, RemoteControlState>(
buildWhen: (p, c) => p.isEmergency != c.isEmergency,
builder: (context, state) {
return state.isEmergency
? const Positioned.fill(child: EmergencyOverlay())
: const SizedBox.shrink();
},
),
SafeArea(
child: Column(
children: [
const TopStatusBar(),
Expanded(
child: LayoutBuilder(
builder: (context, constraints) {
final double sideAreaWidth =
constraints.maxWidth * 0.25;
final double centerAreaWidth =
constraints.maxWidth * 0.5;
SafeArea(
child: Column(
children: [
const TopStatusBar(),
Expanded(
child: LayoutBuilder(
builder: (context, constraints) {
final double sideAreaWidth = constraints.maxWidth * 0.25;
final double centerAreaWidth = constraints.maxWidth * 0.5;
// 关键:使用 BlocBuilder 局部刷新摇杆,不要 watch 整个 Page
return BlocBuilder<
RemoteControlCubit,
RemoteControlState
>(
// 只有 isLocked 改变时才重构摇杆区域,摇杆坐标改变由内部处理
buildWhen: (p, c) => p.isLocked != c.isLocked,
builder: (context, remoteState) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 10),
child: Row(
mainAxisAlignment: MainAxisAlignment.end,
crossAxisAlignment: CrossAxisAlignment.end,
children: [
// 左摇杆
SizedBox(
width: sideAreaWidth,
child: Align(
alignment: Alignment.bottomCenter,
child: LeftJoystickArea(
isLocked: remoteState.isLocked,
width: sideAreaWidth * 0.5 * 0.7,
// 关键:使用 BlocBuilder 局部刷新摇杆,不要 watch 整个 Page
return BlocBuilder<
RemoteControlCubit,
RemoteControlState
>(
// 只有 isLocked 改变时才重构摇杆区域,摇杆坐标改变由内部处理
buildWhen: (p, c) => p.isLocked != c.isLocked,
builder: (context, remoteState) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 10),
child: Row(
mainAxisAlignment: MainAxisAlignment.end,
crossAxisAlignment: CrossAxisAlignment.end,
children: [
// 左摇杆
SizedBox(
width: sideAreaWidth,
child: Align(
alignment: Alignment.bottomCenter,
child: LeftJoystickArea(
isLocked: remoteState.isLocked,
width: sideAreaWidth * 0.5 * 0.7,
),
),
),
),
// 中间区
SizedBox(
width: centerAreaWidth,
child: Align(
alignment: Alignment.bottomCenter,
child: CenterControlArea(
totalWidth: centerAreaWidth,
// 中间区
SizedBox(
width: centerAreaWidth,
child: Align(
alignment: Alignment.bottomCenter,
child: CenterControlArea(
totalWidth: centerAreaWidth,
),
),
),
),
// 右摇杆
SizedBox(
width: sideAreaWidth,
child: Align(
alignment: Alignment.bottomCenter,
child: RightJoystickArea(
isLocked: remoteState.isLocked,
width: sideAreaWidth * 0.5 * 0.7,
// 右摇杆
SizedBox(
width: sideAreaWidth,
child: Align(
alignment: Alignment.bottomCenter,
child: RightJoystickArea(
isLocked: remoteState.isLocked,
width: sideAreaWidth * 0.5 * 0.7,
),
),
),
),
],
),
);
},
);
},
],
),
);
},
);
},
),
),
),
],
],
),
),
),
// 权限弹窗
BlocBuilder<RemoteControlCubit, RemoteControlState>(
buildWhen: (p, c) =>
p.showPermissionRequestDialog != c.showPermissionRequestDialog,
builder: (context, state) {
if (state.showPermissionRequestDialog) {
return _buildPermissionDialog(context, state);
}
return const SizedBox.shrink();
},
),
],
// 权限弹窗
BlocBuilder<RemoteControlCubit, RemoteControlState>(
buildWhen: (p, c) =>
p.showPermissionRequestDialog !=
c.showPermissionRequestDialog,
builder: (context, state) {
if (state.showPermissionRequestDialog) {
return _buildPermissionDialog(context, state);
}
return const SizedBox.shrink();
},
),
],
),
),
);
}

View File

@@ -2,9 +2,9 @@ import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_svg/svg.dart';
import 'package:go_router/go_router.dart';
import 'package:maibu_satabot_v2/features/remote_control/presentation/bloc/remote_control_state.dart';
import 'package:maibu_satabot_v2/features/remote_control/presentation/widgets/status_chip.dart';
import '../../../../core/network/enums/control_mode.dart';
import '../../../devices/presentation/bloc/devices_cubit.dart';
import '../bloc/remote_control_cubit.dart';
@@ -20,12 +20,18 @@ class TopStatusBar extends StatelessWidget {
// 监听局部遥控状态
final remoteState = context.watch<RemoteControlCubit>().state;
var _remoteControlCubit = context.read<RemoteControlCubit>();
return Padding(
padding: const EdgeInsets.all(12.0),
child: Row(
children: [
// 1. 返回按钮 (对应 SimpleSmallFunctionButton)
_buildIconButton("assets/svgs/remote_back.svg", () => context.pop()),
_buildIconButton("assets/svgs/remote_back.svg", () async {
_remoteControlCubit.stopControlLoop();
_remoteControlCubit.disposeVideo();
if (context.mounted) context.pop();
}),
const SizedBox(width: 16),
// 2. 控制状态 (对应 StatusChipLeft)
@@ -39,7 +45,7 @@ class TopStatusBar extends StatelessWidget {
onTap: () {
if (!remoteState.hasPermission) {
// 弹出请求权限对话框逻辑
context.read<RemoteControlCubit>().togglePermissionDialog(true);
_remoteControlCubit.togglePermissionDialog(true);
}
},
),
@@ -50,7 +56,7 @@ class TopStatusBar extends StatelessWidget {
remoteState.isLocked
? "assets/svgs/remote_lock.svg"
: "assets/svgs/remote_lock_open.svg",
() => context.read<RemoteControlCubit>().toggleLock(),
() => _remoteControlCubit.toggleLock(),
isSelected: remoteState.isLocked,
),
const SizedBox(width: 8),
@@ -60,28 +66,77 @@ class TopStatusBar extends StatelessWidget {
const Spacer(),
// TODO
_buildControlModeChip(,remoteState.runningStatusModel.controlMode),
_buildExpandIconButton(
iconPath: "assets/svgs/remote_expand.svg",
selectedIconPath: "assets/svgs/remote_unexpand.svg",
isSelected: remoteState.topRightIsExpanded,
onTap: (){
_remoteControlCubit.toggleTopLeftExpand();
},
),
const SizedBox(width: 8),
const SizedBox(width: 5),
_buildVoltageChip(remoteState.voltage),
AnimatedSize(
duration: const Duration(milliseconds: 100),
curve: Curves.easeInOut,
child: Row(
children: !remoteState.topRightIsExpanded
? [
const SizedBox(width: 8),
_buildSwitchIconButton(
iconPath: "assets/svgs/remote_recognition_off.svg",
selectedIconPath: "assets/svgs/remote_recognition.svg",
isSelected: remoteState.obstacleRecognitionFlag,
onTap: (){
_remoteControlCubit.toggleObstacleRecognition();
},
),
const SizedBox(width: 8),
_buildSwitchIconButton(
iconPath: "assets/svgs/remote_video_left_off.svg",
selectedIconPath: "assets/svgs/remote_video_left.svg",
isSelected: remoteState.showLeftPip,
onTap: (){
_remoteControlCubit.toggleLeftPip();
},
),
const SizedBox(width: 8),
_buildSwitchIconButton(
iconPath: "assets/svgs/remote_video_right_off.svg",
selectedIconPath: "assets/svgs/remote_video_right.svg",
isSelected: remoteState.showRightPip,
onTap: (){
_remoteControlCubit.toggleRightPip();
},
),
const SizedBox(width: 8),
_buildControlModeChip(remoteState.runningStatusEntity.controlMode),
const SizedBox(width: 8),
_buildVoltageChip(remoteState.runningStatusEntity.voltage),
const SizedBox(width: 8),
_buildPingChip(remoteState.ping),
]
: [], // 折叠时数组为空
),
),
const SizedBox(width: 8),
// 5. 信号延迟 (对应 pingStatusChip)
_buildPingChip(remoteState.ping),
// _buildPingChip(remoteState.ping),
const SizedBox(width: 8),
// 6. 电量 (对应 StatusChipRight)
_buildBatteryChip(remoteState.battery),
_buildBatteryChip(remoteState.runningStatusEntity.battery),
],
),
);
}
Widget _buildIconButton(
String svgAsset, // 改为 String 路径,例如 'assets/icons/stop.svg'
String svgAsset,
// 改为 String 路径,例如 'assets/icons/stop.svg'
VoidCallback onTap, {
bool isSelected = false,
}) {
@@ -108,6 +163,90 @@ class TopStatusBar extends StatelessWidget {
);
}
Widget _buildSwitchIconButton({
required String iconPath, // 默认图标路径
required String selectedIconPath, // 选中后的图标路径
required VoidCallback onTap, // 点击事件
bool isSelected = false, // 是否选中状态
double size = 32, // 按钮尺寸
}) {
return InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(10),
child: AnimatedContainer(
duration: const Duration(milliseconds: 200),
// 增加颜色切换动画
width: size,
height: size,
decoration: BoxDecoration(
// 选中时为深蓝色,未选中时为半透明灰色
color: isSelected
? const Color(0xFF0078D4)
: Colors.grey.withOpacity(0.4),
borderRadius: BorderRadius.circular(10),
border: Border.all(
color: isSelected
? Colors.transparent
: Colors.white.withOpacity(0.3),
width: 0.5,
),
boxShadow: isSelected
? [
BoxShadow(
color: const Color(0xFF0078D4).withOpacity(0.4),
blurRadius: 8,
offset: const Offset(0, 2),
),
]
: null,
),
padding: const EdgeInsets.all(7),
child: SvgPicture.asset(
// 根据状态切换图片
isSelected ? selectedIconPath : iconPath,
colorFilter: const ColorFilter.mode(Colors.white, BlendMode.srcIn),
),
),
);
}
Widget _buildExpandIconButton({
required String iconPath, // 默认图标路径
required String selectedIconPath, // 选中后的图标路径
required VoidCallback onTap, // 点击事件
bool isSelected = false, // 是否选中状态
double size = 28, // 按钮尺寸
}) {
return InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(10),
child: AnimatedContainer(
duration: const Duration(milliseconds: 200),
// 增加颜色切换动画
width: size,
height: size,
decoration: BoxDecoration(
// 选中时为深蓝色,未选中时为半透明灰色
color: Colors.grey.withOpacity(0.4),
borderRadius: BorderRadius.circular(15),
border: Border.all(
color: Colors.white.withOpacity(0.3),
width: 0.5,
),
boxShadow: null,
),
padding: const EdgeInsets.all(7),
child: SvgPicture.asset(
// 根据状态切换图片
isSelected ? selectedIconPath : iconPath,
colorFilter: const ColorFilter.mode(Colors.white, BlendMode.srcIn),
),
),
);
}
Widget _buildPingChip(int ping) {
Color color = ping < 100
? Colors.green
@@ -133,13 +272,11 @@ class TopStatusBar extends StatelessWidget {
displayText = 'TCP模式';
break;
case ControlMode.NONE:
displayText = '无模式';
displayText = '无';
break;
case ControlMode.OTHER:
displayText = '其他模式';
break;
default:
displayText = '未知模式';
}
return StatusChip(
@@ -150,7 +287,7 @@ class TopStatusBar extends StatelessWidget {
);
}
Widget _buildVoltageChip(int voltage) {
Widget _buildVoltageChip(double voltage) {
return StatusChip(
text: "$voltage V",
color: Color(0xFFFACC15).withValues(alpha: 0.6),
@@ -159,7 +296,7 @@ class TopStatusBar extends StatelessWidget {
);
}
Widget _buildBatteryChip(int level) {
Widget _buildBatteryChip(double level) {
// 1. 根据数值判断图片路径 (逻辑与之前一致)
String svgPath;
Color color;

View File

@@ -5,15 +5,19 @@ import 'package:flutter_webrtc/flutter_webrtc.dart';
import 'package:http/http.dart' as http;
class WebRTCLocalPlayer extends StatefulWidget {
final String streamUrl;
final String streamUrl; // 第一个流(四分屏)
final String? secondStreamUrl; // 第二条完整流,可选
final bool showLeftPip;
final bool showRightPip;
final bool obstacleRecognitionFlag;
const WebRTCLocalPlayer({
super.key,
required this.streamUrl,
this.secondStreamUrl,
this.showLeftPip = true,
this.showRightPip = true,
this.obstacleRecognitionFlag = false,
});
@override
@@ -21,19 +25,17 @@ class WebRTCLocalPlayer extends StatefulWidget {
}
class _WebRTCLocalPlayerState extends State<WebRTCLocalPlayer> {
final RTCVideoRenderer _renderer = RTCVideoRenderer();
final RTCVideoRenderer _renderer = RTCVideoRenderer(); // 第一个流(四分屏)
RTCVideoRenderer? _secondRenderer; // 第二条完整流
RTCPeerConnection? _peerConnection;
RTCPeerConnection? _secondPeerConnection;
bool _isInitialized = false;
bool _isFrontMain = true;
bool _isFrontMain = true; // true=前,false=后
// 使用 ValueNotifier 配合局部刷新,提升拖拽性能
final ValueNotifier<Offset> _leftPosNotifier = ValueNotifier(
const Offset(20, 80),
);
final ValueNotifier<Offset> _rightPosNotifier = ValueNotifier(
const Offset(200, 80),
);
final ValueNotifier<Offset> _leftPosNotifier = ValueNotifier(const Offset(20, 80));
final ValueNotifier<Offset> _rightPosNotifier = ValueNotifier(const Offset(200, 80));
bool _isPosInitialized = false;
@override
@@ -45,42 +47,44 @@ class _WebRTCLocalPlayerState extends State<WebRTCLocalPlayer> {
Future<void> _prepareAndConnect() async {
try {
await _renderer.initialize();
if (!mounted) return;
await _initWebRTCConnection();
await _initWebRTCConnection(widget.streamUrl, _renderer, true);
if (widget.obstacleRecognitionFlag && widget.secondStreamUrl != null) {
await _startSecondStream(widget.secondStreamUrl!);
}
setState(() => _isInitialized = true);
} catch (e) {
debugPrint("初始化失败: $e");
}
}
Future<void> _initWebRTCConnection() async {
_peerConnection = await createPeerConnection({
Future<void> _initWebRTCConnection(
String url, RTCVideoRenderer renderer, bool isMain) async {
final pc = await createPeerConnection({
"sdpSemantics": "unified-plan",
"iceServers": [
{"urls": "stun:stun.l.google.com:19302"},
],
});
await _peerConnection!.addTransceiver(
// 只接收视频
await pc.addTransceiver(
kind: RTCRtpMediaType.RTCRtpMediaTypeVideo,
init: RTCRtpTransceiverInit(direction: TransceiverDirection.RecvOnly),
);
_peerConnection!.onTrack = (RTCTrackEvent event) {
if (event.track.kind == 'video' && event.streams.isNotEmpty) {
if (mounted) setState(() => _renderer.srcObject = event.streams[0]);
pc.onTrack = (RTCTrackEvent event) {
if (event.streams.isNotEmpty && event.track.kind == 'video') {
if (mounted) setState(() => renderer.srcObject = event.streams[0]);
}
};
try {
RTCSessionDescription offer = await _peerConnection!.createOffer({
'offerToReceiveVideo': 1,
});
await _peerConnection!.setLocalDescription(offer);
RTCSessionDescription offer = await pc.createOffer({'offerToReceiveVideo': 1});
await pc.setLocalDescription(offer);
Uri uri = Uri.parse(
widget.streamUrl.replaceFirst('webrtc://', 'http://'),
);
Uri uri = Uri.parse(url.replaceFirst('webrtc://', 'http://'));
String apiUrl = "http://${uri.host}:1985/rtc/v1/play/";
final response = await http.post(
@@ -88,7 +92,7 @@ class _WebRTCLocalPlayerState extends State<WebRTCLocalPlayer> {
headers: {'Content-Type': 'application/json'},
body: jsonEncode({
"api": apiUrl,
"streamurl": widget.streamUrl,
"streamurl": url,
"clientip": null,
"sdp": offer.sdp,
}),
@@ -96,179 +100,155 @@ class _WebRTCLocalPlayerState extends State<WebRTCLocalPlayer> {
if (response.statusCode == 200) {
final data = jsonDecode(response.body);
await _peerConnection!.setRemoteDescription(
await pc.setRemoteDescription(
RTCSessionDescription(data['sdp'], 'answer'),
);
}
} catch (e) {
debugPrint("信令错误: $e");
}
if (isMain) _peerConnection = pc;
else _secondPeerConnection = pc;
}
Future<void> _startSecondStream(String url) async {
_secondRenderer = RTCVideoRenderer();
await _secondRenderer!.initialize();
await _initWebRTCConnection(url, _secondRenderer!, false);
}
Future<void> _stopSecondStream() async {
if (_secondPeerConnection != null) {
await _secondPeerConnection!.close();
await _secondPeerConnection!.dispose();
_secondPeerConnection = null;
}
if (_secondRenderer != null) {
_secondRenderer!.srcObject = null;
await _secondRenderer!.dispose();
_secondRenderer = null;
}
}
@override
void didUpdateWidget(covariant WebRTCLocalPlayer oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.obstacleRecognitionFlag != oldWidget.obstacleRecognitionFlag) {
if (widget.obstacleRecognitionFlag) {
if (widget.secondStreamUrl != null) _startSecondStream(widget.secondStreamUrl!);
} else {
_stopSecondStream();
}
}
}
@override
void dispose() {
_peerConnection?.close();
_peerConnection?.dispose();
_secondPeerConnection?.close();
_secondPeerConnection?.dispose();
_renderer.srcObject = null;
_renderer.dispose();
_secondRenderer?.srcObject = null;
_secondRenderer?.dispose();
_leftPosNotifier.dispose();
_rightPosNotifier.dispose();
super.dispose();
}
// 四分屏裁剪视图
Widget _buildQuadrantView({required Alignment alignment}) {
if (!_isInitialized || _renderer.srcObject == null)
return Container(color: Colors.black);
// -------------------- 渲染逻辑 --------------------
return RepaintBoundary(
child: ClipRect(
child: FractionallySizedBox(
widthFactor: 2.0,
heightFactor: 2.0,
alignment: alignment,
child: RTCVideoView(
_renderer,
objectFit: RTCVideoViewObjectFit.RTCVideoViewObjectFitCover,
mirror: false,
RTCVideoRenderer getFrontRenderer() {
if (widget.obstacleRecognitionFlag && _secondRenderer != null) {
return _secondRenderer!;
}
return _renderer;
}
RTCVideoRenderer getBackRenderer() => _renderer;
RTCVideoRenderer getLeftRenderer() => _renderer;
RTCVideoRenderer getRightRenderer() => _renderer;
Widget _buildQuadrantView({required Alignment alignment, required RTCVideoRenderer renderer}) {
if (renderer.srcObject == null) return Container(color: Colors.black);
if (renderer == _secondRenderer) {
// 第二条流完整显示
return RTCVideoView(renderer,
objectFit: RTCVideoViewObjectFit.RTCVideoViewObjectFitCover, mirror: false);
}
// 第一个流四分屏
return ClipRect(
child: FractionallySizedBox(
widthFactor: 2.0,
heightFactor: 2.0,
alignment: alignment,
child: RTCVideoView(renderer,
objectFit: RTCVideoViewObjectFit.RTCVideoViewObjectFitCover, mirror: false),
),
);
}
Widget _buildMainViewWithFeathering() {
final renderer = _isFrontMain ? getFrontRenderer() : getBackRenderer();
return Stack(
children: [
// 背景层(虚化 + 半透明黑色)
Positioned.fill(
child: _buildQuadrantView(
alignment: _isFrontMain ? Alignment.topLeft : Alignment.topRight,
renderer: renderer,
),
),
),
);
}
// 主视角的矩形边缘羽化效果
Widget _buildMainViewWithFeathering() {
return ShaderMask(
shaderCallback: (Rect bounds) {
return const LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
Colors.transparent,
Colors.white,
Colors.white,
Colors.transparent,
],
stops: [0.0, 0.15, 0.85, 1.0],
).createShader(bounds);
},
blendMode: BlendMode.dstIn,
child: ShaderMask(
shaderCallback: (Rect bounds) {
return const LinearGradient(
begin: Alignment.centerLeft,
end: Alignment.centerRight,
colors: [
Colors.transparent,
Colors.white,
Colors.white,
Colors.transparent,
],
stops: [0.0, 0.05, 0.95, 1.0],
).createShader(bounds);
},
blendMode: BlendMode.dstIn,
child: _buildQuadrantView(
alignment: _isFrontMain ? Alignment.topLeft : Alignment.topRight,
Positioned.fill(
child: BackdropFilter(
filter: ImageFilter.blur(sigmaX: 45, sigmaY: 45),
child: Container(color: Colors.black.withOpacity(0.55)),
),
),
),
// 中心主视角 + 羽化
Center(
child: ShaderMask(
shaderCallback: (Rect bounds) {
return const LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [Colors.transparent, Colors.white, Colors.white, Colors.transparent],
stops: [0.0, 0.15, 0.85, 1.0],
).createShader(bounds);
},
blendMode: BlendMode.dstIn,
child: ShaderMask(
shaderCallback: (Rect bounds) {
return const LinearGradient(
begin: Alignment.centerLeft,
end: Alignment.centerRight,
colors: [Colors.transparent, Colors.white, Colors.white, Colors.transparent],
stops: [0.0, 0.05, 0.95, 1.0],
).createShader(bounds);
},
blendMode: BlendMode.dstIn,
child: AspectRatio(
aspectRatio: 16 / 9,
child: _buildQuadrantView(
alignment: _isFrontMain ? Alignment.topLeft : Alignment.topRight,
renderer: renderer,
),
),
),
),
),
],
);
}
@override
Widget build(BuildContext context) {
if (!_isInitialized)
return const Scaffold(
backgroundColor: Colors.black,
body: Center(child: CircularProgressIndicator()),
);
return Scaffold(
backgroundColor: Colors.black,
body: LayoutBuilder(
builder: (context, constraints) {
final double pipW = constraints.maxWidth / 5;
final double pipH = pipW * 9 / 16;
// 核心:处理 Windows 窗口大小变化时的坐标修正
Future.microtask(() {
if (!mounted) return;
_correctPosition(_leftPosNotifier, constraints, pipW, pipH);
_correctPosition(_rightPosNotifier, constraints, pipW, pipH);
});
if (!_isPosInitialized) {
_leftPosNotifier.value = const Offset(20, 80);
_rightPosNotifier.value = Offset(
constraints.maxWidth - pipW - 20,
80,
);
_isPosInitialized = true;
}
return Stack(
children: [
// ① 背景虚化层(背景两侧模糊,模拟视觉聚焦)
Positioned.fill(
child: Stack(
children: [
_buildQuadrantView(
alignment: _isFrontMain
? Alignment.topLeft
: Alignment.topRight,
),
Positioned.fill(
child: BackdropFilter(
filter: ImageFilter.blur(sigmaX: 45, sigmaY: 45),
child: Container(color: Colors.black.withOpacity(0.55)),
),
),
],
),
),
// ② 主视频层
Center(
child: GestureDetector(
onDoubleTap: () =>
setState(() => _isFrontMain = !_isFrontMain),
child: AspectRatio(
aspectRatio: 16 / 9,
child: _buildMainViewWithFeathering(),
),
),
),
// ③ 悬浮小窗 - 解决阻尼感的 1:1 稳定拖拽
if (widget.showLeftPip)
_buildFastPip(
_leftPosNotifier,
Alignment.bottomLeft,
constraints,
pipW,
pipH,
),
if (widget.showRightPip)
_buildFastPip(
_rightPosNotifier,
Alignment.bottomRight,
constraints,
pipW,
pipH,
),
],
);
},
),
);
}
void _correctPosition(
ValueNotifier<Offset> notifier,
BoxConstraints constraints,
double w,
double h,
) {
void _correctPosition(ValueNotifier<Offset> notifier, BoxConstraints constraints, double w, double h) {
double maxX = (constraints.maxWidth - w).clamp(0.0, double.infinity);
double maxY = (constraints.maxHeight - h).clamp(0.0, double.infinity);
double newX = notifier.value.dx.clamp(0.0, maxX);
@@ -278,14 +258,7 @@ class _WebRTCLocalPlayerState extends State<WebRTCLocalPlayer> {
}
}
// 极致丝滑的局部刷新组件
Widget _buildFastPip(
ValueNotifier<Offset> notifier,
Alignment align,
BoxConstraints constraints,
double w,
double h,
) {
Widget _buildFastPip(ValueNotifier<Offset> notifier, Alignment align, BoxConstraints constraints, double w, double h) {
return ValueListenableBuilder<Offset>(
valueListenable: notifier,
builder: (context, pos, child) {
@@ -295,7 +268,6 @@ class _WebRTCLocalPlayerState extends State<WebRTCLocalPlayer> {
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onPanUpdate: (details) {
// 关键修正:使用 notifier.value.dx 替换 pos.dx,消除位移丢失导致的阻尼感
final double nextX = (notifier.value.dx + details.delta.dx).clamp(
0.0,
(constraints.maxWidth - w).clamp(0.0, double.infinity),
@@ -315,16 +287,57 @@ class _WebRTCLocalPlayerState extends State<WebRTCLocalPlayer> {
height: h,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(10),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.5),
blurRadius: 20,
offset: const Offset(0, 8),
),
],
boxShadow: [BoxShadow(color: Colors.black.withOpacity(0.5), blurRadius: 20, offset: const Offset(0, 8))],
),
clipBehavior: Clip.antiAlias,
child: _buildQuadrantView(alignment: align),
child: _buildQuadrantView(alignment: align, renderer: _renderer),
),
);
}
@override
Widget build(BuildContext context) {
if (!_isInitialized) {
return const Scaffold(
backgroundColor: Colors.black,
body: Center(child: CircularProgressIndicator()),
);
}
return Scaffold(
backgroundColor: Colors.black,
body: LayoutBuilder(
builder: (context, constraints) {
final double pipW = constraints.maxWidth / 5;
final double pipH = pipW * 9 / 16;
Future.microtask(() {
if (!mounted) return;
_correctPosition(_leftPosNotifier, constraints, pipW, pipH);
_correctPosition(_rightPosNotifier, constraints, pipW, pipH);
});
if (!_isPosInitialized) {
_leftPosNotifier.value = const Offset(20, 80);
_rightPosNotifier.value = Offset(constraints.maxWidth - pipW - 20, 80);
_isPosInitialized = true;
}
return Stack(
children: [
Center(
child: GestureDetector(
onDoubleTap: () => setState(() => _isFrontMain = !_isFrontMain),
child: _buildMainViewWithFeathering(),
),
),
if (widget.showLeftPip)
_buildFastPip(_leftPosNotifier, Alignment.bottomLeft, constraints, pipW, pipH),
if (widget.showRightPip)
_buildFastPip(_rightPosNotifier, Alignment.bottomRight, constraints, pipW, pipH),
],
);
},
),
);
}

View File

@@ -4,7 +4,7 @@ import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:go_router/go_router.dart';
import 'package:maibu_satabot_v2/core/app/app_user_cubit.dart';
import 'package:maibu_satabot_v2/features/auth/presentation/bloc/app_user_cubit.dart';
import 'package:maibu_satabot_v2/core/infrastructure/logging/app_bloc_observer.dart';
import 'package:maibu_satabot_v2/core/logging/i_logger_service.dart';
import 'package:maibu_satabot_v2/core/theme/AppTheme.dart';
@@ -12,7 +12,10 @@ import 'package:maibu_satabot_v2/features/auth/presentation/bloc/auth_cubit.dart
import 'package:maibu_satabot_v2/features/devices/presentation/bloc/devices_cubit.dart';
import 'core/di/injection.dart';
import 'core/network/base/i_connection.dart';
import 'features/auth/presentation/bloc/login_cubit.dart';
import 'features/connectivity/presentation/bloc/connectivity_cubit.dart';
import 'features/connectivity/presentation/bloc/connectivity_state.dart';
void main() async {
// 1. 使用 runZonedGuarded 捕获所有未处理的异步错误
@@ -55,12 +58,60 @@ class MyApp extends StatelessWidget {
sl<DevicesCubit>()
..fetchAllDevices(sl<AppUserCubit>().state.user!.username),
),
BlocProvider(create: (_) => sl<ConnectivityCubit>()),
],
child: MaterialApp.router(
title: 'Maibu Satabot',
theme: AppTheme.lightTheme,
routerConfig: sl<GoRouter>(),
builder: (context, child) {
return BlocListener<ConnectivityCubit, ConnectivityState>(
// 只有当状态真正变为 disconnected 时才弹出
listenWhen: (prev, curr) => prev.status != curr.status,
listener: (context, state) {
if (state.status == ConnectionStatus.disconnected) {
_showGlobalDisconnectDialog();
}
},
child: child, // child 就是当前路由显示的页面内容
);
},
),
);
}
// 全局断连提示弹窗
void _showGlobalDisconnectDialog() {
// 直接从全局变量获取真正的 Navigator Context
final context = rootNavigatorKey.currentContext;
if (context != null) {
showDialog(
context: context,
barrierDismissible: false,
builder: (context) => AlertDialog(
title: const Row(
children: [
Icon(Icons.warning, color: Colors.red),
SizedBox(width: 8),
Text("连接已断开"),
],
),
content: const Text("与服务器通信丢失,请检查网络连接。"),
actions: [
TextButton(
onPressed: () {
Navigator.of(context).pop(); // 关闭弹窗
// 如果需要,这里可以执行 context.go('/login');
},
child: const Text("知道了"),
),
],
),
);
} else {
print("DEBUG: 弹窗失败,rootNavigatorKey.currentContext 为空");
}
}
}

View File

@@ -751,6 +751,14 @@ packages:
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.5.0"
rxdart:
dependency: "direct main"
description:
name: rxdart
sha256: "5c3004a4a8dbb94bd4bf5412a4def4acdaa12e12f269737a5751369e12d1a962"
url: "https://pub.flutter-io.cn"
source: hosted
version: "0.28.0"
sentry:
dependency: transitive
description:

View File

@@ -51,6 +51,9 @@ dependencies:
flutter_bloc: ^9.1.1
bloc: ^9.2.0
# ===== 状态管理(RxDart)=====
rxdart: ^0.28.0
# ===== 依赖注入 =====
get_it: ^9.2.0
@@ -98,6 +101,8 @@ dependencies:
# ===== 文件存储 =====
path_provider: ^2.1.5
intl: ^0.20.2
dev_dependencies: