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.INTERNET" />
<uses-permission android:name="android.permission.CAMERA" /> <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.RECORD_AUDIO" />
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" /> <uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
<application <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 { class TCPConsts {
static const String TCP_IP = "1.95.137.212"; static const String TCP_IP = "1.95.137.212";
// static const String TCP_IP = "192.210.172.250";
static const int TCP_PORT = 9001; static const int TCP_PORT = 9001;
} }

View File

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

View File

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

View File

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

View File

@@ -14,9 +14,13 @@ class AppBlocObserver extends BlocObserver {
@override @override
void onChange(BlocBase bloc, Change change) { void onChange(BlocBase bloc, Change change) {
super.onChange(bloc, change); super.onChange(bloc, change);
logger.addBreadcrumb( // 在本地日志记录详细的面包屑
'Bloc State Change', // logger.addBreadcrumb(
category: bloc.runtimeType.toString(), // '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 'dart:io';
import 'package:device_info_plus/device_info_plus.dart'; import 'package:device_info_plus/device_info_plus.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
@@ -11,101 +11,110 @@ import 'package:intl/intl.dart';
class SentryLoggerImpl implements ILoggerService { class SentryLoggerImpl implements ILoggerService {
File? _localLogFile; File? _localLogFile;
/// 控制开关
bool get _isRemoteDsnEnabled => false;
@override @override
Future<void> init() async { Future<void> init() async {
await SentryFlutter.init((options) { if (_isRemoteDsnEnabled) {
options.dsn = EnvConfig.sentryDsn; await SentryFlutter.init((options) {
options.environment = EnvConfig.environment; options.dsn = EnvConfig.sentryDsn;
// 生产环境只采样 20% 的性能数据,测试环境全开 options.environment = EnvConfig.environment;
options.tracesSampleRate = EnvConfig.isProduction ? 0.2 : 1.0; options.tracesSampleRate = EnvConfig.isProduction ? 0.2 : 1.0;
}); });
await _collectDeviceInfo(); await _collectDeviceInfo();
}
await _prepareLocalFile(); 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 { Future<void> _prepareLocalFile() async {
try { try {
final directory = await getApplicationDocumentsDirectory(); 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'); _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) { } catch (e) {
debugPrint("无法创建本地日志文件: $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 { Future<void> _writeToLocalFile(String entry) async {
try { try {
// 使用追加模式写入 // 写入文件时移除 ANSI 颜色字符
await _localLogFile?.writeAsString(entry, mode: FileMode.append); final cleanEntry = entry.replaceAll(RegExp(r'\x1B\[[0-9;]*m'), '');
} catch (e) { await _localLogFile?.writeAsString(cleanEntry, mode: FileMode.append);
// 本地写入失败不应崩溃 } catch (_) {}
}
}
// 增加一个获取本地日志文件路径的方法,方便你以后做“导出日志”功能
Future<String?> getLocalLogPath() async {
return _localLogFile?.path;
} }
@override @override
void addBreadcrumb(String message, {String? category}) { 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 @override
void setUser(String userId) { 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:dio/dio.dart';
import 'package:maibu_satabot_v2/core/consts/http_api_consts.dart'; import 'package:maibu_satabot_v2/core/consts/http_api_consts.dart';
import '../app/app_user_cubit.dart'; import '../../../features/auth/presentation/bloc/app_user_cubit.dart';
import '../di/injection.dart'; import '../../di/injection.dart';
class DioClient { class DioClient {
static Dio create() { 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 header1 = 0xAB;
static const int header2 = 0xAA; 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_cubit.dart';
import '../../features/auth/presentation/bloc/auth_state.dart'; import '../../features/auth/presentation/bloc/auth_state.dart';
import '../../features/main_container/presentation/main_wrapper.dart'; import '../../features/main_container/presentation/main_wrapper.dart';
import '../di/injection.dart';
import 'go_router_refresh_stream.dart'; import 'go_router_refresh_stream.dart';
GoRouter createRouter(AuthCubit authCubit) { GoRouter createRouter(AuthCubit authCubit) {
return GoRouter( return GoRouter(
navigatorKey: rootNavigatorKey,
initialLocation: RoutePaths.login, initialLocation: RoutePaths.login,
refreshListenable: GoRouterRefreshStream(authCubit.stream), refreshListenable: GoRouterRefreshStream(authCubit.stream),
redirect: (context, state) { redirect: (context, state) {

View File

@@ -1,5 +1,5 @@
import '../models/user_model.dart'; import '../models/user_model.dart';
abstract class AuthHttpDataSource { abstract class IAuthHttpDataSource {
Future<UserModel> login(String username, String password, int sourceType); 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<void> saveUser(UserEntity user);
Future<UserEntity?> getUser(); Future<UserEntity?> getUser();
Future<void> deleteUser(); 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 'package:maibu_satabot_v2/core/consts/http_api_consts.dart';
import '../../models/user_model.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; final Dio dio;
AuthHttpDataSourceImpl(this.dio); AuthHttpDataSourceImpl(this.dio);

View File

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

View File

@@ -1,8 +1,9 @@
import 'package:maibu_satabot_v2/core/data/base_model.dart'; 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({ UserModel({
required super.userId, required super.userId,
required super.username, required super.username,
@@ -26,6 +27,7 @@ class UserModel extends UserEntity implements BaseModel {
); );
} }
@override
Map<String, dynamic> toJson() { Map<String, dynamic> toJson() {
return { return {
'userId': userId, '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) { factory UserModel.fromEntity(UserEntity entity) {
return UserModel( return UserModel(
@@ -61,4 +53,17 @@ class UserModel extends UserEntity implements BaseModel {
phone: entity.phone, 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:dio/dio.dart';
import 'package:fpdart/fpdart.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/errors/auth_failure.dart';
import '../../domain/repositories/auth_repository.dart'; import '../../domain/repositories/auth_repository.dart';
import '../datasources/auth_http_datasource.dart'; import '../datasources/i_auth_http_datasource.dart';
class AuthRepositoryImpl implements AuthRepository { class AuthRepositoryImpl implements AuthRepository {
final AuthHttpDataSource httpRemote; final IAuthHttpDataSource httpRemote;
final UserStorage localTokenStorage; final IAuthLocalDataSource localTokenStorage;
AuthRepositoryImpl({ AuthRepositoryImpl({
required this.httpRemote, required this.httpRemote,

View File

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

View File

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

View File

@@ -1,9 +1,9 @@
import 'package:dio/dio.dart'; import 'package:dio/dio.dart';
import 'package:maibu_satabot_v2/core/consts/http_api_consts.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'; import 'package:maibu_satabot_v2/features/devices/domain/entities/device_entity.dart';
class DeviceHttpDatasourceImpl implements DeviceHttpDatasource { class DeviceHttpDatasourceImpl implements IDeviceHttpDatasource {
final Dio dio; final Dio dio;
DeviceHttpDatasourceImpl(this.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/core/data/base_model.dart';
import 'package:maibu_satabot_v2/features/devices/domain/entities/device_entity.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({ DeviceModel({
required super.deviceName, required super.deviceName,
required super.productId, required super.productId,
@@ -30,6 +30,7 @@ class DeviceModel extends DeviceEntity implements BaseModel {
); );
} }
@override
Map<String, dynamic> toJson() { Map<String, dynamic> toJson() {
return { return {
'deviceName': deviceName, 'deviceName': deviceName,
@@ -45,7 +46,8 @@ class DeviceModel extends DeviceEntity implements BaseModel {
}; };
} }
toEntity() { @override
DeviceEntity toEntity() {
return DeviceEntity( return DeviceEntity(
deviceName: deviceName, deviceName: deviceName,
productId: productId, productId: productId,

View File

@@ -1,13 +1,13 @@
import 'package:dio/dio.dart'; import 'package:dio/dio.dart';
import 'package:fpdart/src/either.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/entities/device_entity.dart';
import 'package:maibu_satabot_v2/features/devices/domain/errors/device_failure.dart'; import 'package:maibu_satabot_v2/features/devices/domain/errors/device_failure.dart';
import '../../domain/repositories/device_repository.dart'; import '../../domain/repositories/device_repository.dart';
class DeviceRepositoryImpl implements DeviceRepository { class DeviceRepositoryImpl implements DeviceRepository {
DeviceHttpDatasource _deviceHttpDatasource; IDeviceHttpDatasource _deviceHttpDatasource;
DeviceRepositoryImpl(this._deviceHttpDatasource); DeviceRepositoryImpl(this._deviceHttpDatasource);

View File

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

View File

@@ -1,6 +1,6 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.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 'package:maibu_satabot_v2/features/devices/presentation/bloc/devices_cubit.dart';
import '../widgets/ImmersionHeader.dart'; import '../widgets/ImmersionHeader.dart';

View File

@@ -1,7 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.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'; import '../../../devices/presentation/bloc/devices_cubit.dart';
class RoutePlanPage extends StatelessWidget { class RoutePlanPage extends StatelessWidget {

View File

@@ -1,7 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.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'; import '../../../devices/presentation/bloc/devices_cubit.dart';
class RunningStatusPage extends StatelessWidget { 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:google_fonts/google_fonts.dart';
import 'package:maibu_satabot_v2/features/devices/domain/entities/device_entity.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_cubit.dart';
import '../../../devices/presentation/bloc/devices_state.dart'; import '../../../devices/presentation/bloc/devices_state.dart';
// 导入你的主题文件以获取 offWhite // 导入你的主题文件以获取 offWhite

View File

@@ -1,9 +1,9 @@
import 'package:dio/dio.dart'; import 'package:dio/dio.dart';
class RemoteHttpDatasource { class IRemoteHttpDatasource {
final Dio _dio; final Dio _dio;
RemoteHttpDatasource(this._dio); IRemoteHttpDatasource(this._dio);
/// 示例:获取控制授权 (对应之前逻辑中的权限申请) /// 示例:获取控制授权 (对应之前逻辑中的权限申请)
Future<Map<String, dynamic>> requestControlAuth(String robotId) async { 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 { import 'dart:convert';
final String voltage; import 'dart:typed_data';
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 '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({ RunningStatusModel({
this.voltage = '', required super.voltage,
this.leftTargetSpeed = '', required super.leftTargetSpeed,
this.rightTargetSpeed = '', required super.rightTargetSpeed,
this.leftMeasureSpeed = '', required super.leftMeasureSpeed,
this.rightMeasureSpeed = '', required super.rightMeasureSpeed,
this.leftCurrent = '', required super.leftCurrent,
this.rightCurrent = '', required super.rightCurrent,
this.leftMotorTemp = '', required super.leftMotorTemp,
this.rightMotorTemp = '', required super.rightMotorTemp,
this.chipTemp = '', required super.chipTemp,
this.yaw = '', required super.yaw,
this.pitch = '', required super.pitch,
this.roll = '', required super.roll,
this.satelliteCnt = '', required super.satelliteCnt,
this.qual = '', required super.qual,
this.headingStatus = '', required super.headingStatus,
this.latitude = '', required super.latitude,
this.longitude = '', required super.longitude,
this.time = '', required super.time,
this.knifeCuttingSpeed = '', required super.knifeCuttingSpeed,
this.controlMode = '', required super.controlMode,
this.workingArea = '', required super.workingArea,
this.battery = '', required super.battery,
required super.obstacleFlag,
}); });
RunningStatusModel copyWith({ @override
String? voltage, RunningStatusEntity toEntity() => this;
String? leftTargetSpeed,
String? rightTargetSpeed, @override
String? leftMeasureSpeed, Map<String, dynamic> toJson() {
String? rightMeasureSpeed, return {
String? leftCurrent, 'voltage': voltage,
String? rightCurrent, 'leftTargetSpeed': leftTargetSpeed,
String? leftMotorTemp, 'rightTargetSpeed': rightTargetSpeed,
String? rightMotorTemp, 'leftMeasureSpeed': leftMeasureSpeed,
String? chipTemp, 'rightMeasureSpeed': rightMeasureSpeed,
String? yaw, 'leftCurrent': leftCurrent,
String? pitch, 'rightCurrent': rightCurrent,
String? roll, 'leftMotorTemp': leftMotorTemp,
String? satelliteCnt, 'rightMotorTemp': rightMotorTemp,
String? qual, 'chipTemp': chipTemp,
String? headingStatus, 'yaw': yaw,
String? latitude, 'pitch': pitch,
String? longitude, 'roll': roll,
String? time, 'satelliteCnt': satelliteCnt,
String? knifeCuttingSpeed, 'qual': qual,
String? controlMode, 'headingStatus': headingStatus,
String? workingArea, 'latitude': latitude,
String? battery, '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( return RunningStatusModel(
voltage: voltage ?? this.voltage, voltage: parseD(0), // 电压
leftTargetSpeed: leftTargetSpeed ?? this.leftTargetSpeed, leftTargetSpeed: parseI(1), // 左轮目标速度
rightTargetSpeed: rightTargetSpeed ?? this.rightTargetSpeed, rightTargetSpeed: parseI(2), // 右轮目标速度
leftMeasureSpeed: leftMeasureSpeed ?? this.leftMeasureSpeed, leftMeasureSpeed: parseI(3), // 左轮测量速度
rightMeasureSpeed: rightMeasureSpeed ?? this.rightMeasureSpeed, rightMeasureSpeed: parseI(4), // 右轮测量速度
leftCurrent: leftCurrent ?? this.leftCurrent, leftCurrent: parseD(5), // 左轮电流
rightCurrent: rightCurrent ?? this.rightCurrent, rightCurrent: parseD(6), // 右轮电流
leftMotorTemp: leftMotorTemp ?? this.leftMotorTemp, leftMotorTemp: parseD(7), // 左轮温度
rightMotorTemp: rightMotorTemp ?? this.rightMotorTemp, rightMotorTemp: parseD(8), // 右轮温度
chipTemp: chipTemp ?? this.chipTemp, chipTemp: parseD(9), // 芯片温度
yaw: yaw ?? this.yaw, yaw: parseD(10), // 偏航角
pitch: pitch ?? this.pitch, pitch: parseD(11), // 仰俯角
roll: roll ?? this.roll, roll: parseD(12), // 翻滚角
satelliteCnt: satelliteCnt ?? this.satelliteCnt, satelliteCnt: parseI(13), // 卫星数
qual: qual ?? this.qual, qual: parseI(14), // 定位质量 (0-7)
headingStatus: headingStatus ?? this.headingStatus, headingStatus: parseI(15), // 航向角状态
latitude: latitude ?? this.latitude, latitude: parseD(16), // 纬度
longitude: longitude ?? this.longitude, longitude: parseD(17), // 经度
time: time ?? this.time, time: parseS(18), // 时间字符串
knifeCuttingSpeed: knifeCuttingSpeed ?? this.knifeCuttingSpeed, knifeCuttingSpeed: parseI(19), // 割刀速度
controlMode: controlMode ?? this.controlMode, controlMode: mapMode(20), // 控制模式 (0-4)
workingArea: workingArea ?? this.workingArea, battery: parseD(21), // 电池电量 (0-100)
battery: battery ?? this.battery, workingArea: parseD(22), // 作业面积
obstacleFlag: parseI(23), // 障碍物标志 (0/1)
); );
} }
} }

View File

@@ -1,47 +1,65 @@
import 'dart:async'; 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 '../../../connectivity/presentation/bloc/connectivity_cubit.dart';
import '../../../../core/network/tcp/tcp_client.dart'; import '../../domain/entities/machine_control_entity.dart';
import '../../../../core/protocol/machine_protocol_codec.dart';
import '../../../../core/protocol/machine_protocol_constants.dart';
import '../../domain/entities/machine_control_status_entity.dart';
import '../../domain/repositories/remote_control_repository.dart'; import '../../domain/repositories/remote_control_repository.dart';
import '../../domain/usecase/diff_steer_usecase.dart'; import '../../domain/usecase/diff_steer_usecase.dart';
import '../models/running_status_model.dart';
class RemoteControlRepositoryImpl implements RemoteControlRepository { class RemoteControlRepositoryImpl implements RemoteControlRepository {
final TcpClient _tcpClient; final ConnectivityCubit _connectivityCubit;
final DiffSteerUseCase _diffSteer; final DiffSteerUseCase _diffSteer;
RemoteControlRepositoryImpl(this._tcpClient, this._diffSteer); RemoteControlRepositoryImpl(
this._connectivityCubit, // 现在统一通过 Cubit 管理连接和分发
this._diffSteer,
);
@override @override
void changeWebViewDirection(String direction) { void changeWebViewDirection(String direction) {
// TODO: implement changeWebViewDirection // 逻辑保持不变
} }
@override @override
void sendControlMachineCmd(MachineControlStatusEntity status) { Future<void> sendControlMachineCmd(MachineControlEntity entity) async {
// 1. 调用算法:将摇杆坐标 (x, y) 转换为左右轮电机转速 // 1. 调用差速算法计算左右轮速度
final speeds = _diffSteer.calculate(status.originX, status.originY); final speeds = _diffSteer.calculate(entity.rightSpeed, entity.leftSpeed);
// 2. 调用 Codec:仅生成协议要求的 8 字节 Payload 负载数据 var model = MachineControlModel.fromEntity(entity);
final payload = MachineProtocolCodec.encodeRemoteControlPayload(
left: speeds['left']!,
right: speeds['right']!,
lift: status.chassisLift,
mower: status.mowerSpeed,
ignition: status.ignitionStatus,
emergency: status.isEmergency ? 1 : 0,
);
// 3. 调用 TcpClient:发送指令。 var rawPacket = model.toRawPacket(speeds);
// TcpClient.sendRaw 会自动帮你加上 [0xAB, 0xAA] 头和 [0xAA, 0xAB] 尾
_tcpClient.sendRaw( // 3. 通过连接管理器发出,内部会自动处理 [AB AA] ... [CRC] [AA AB]
MachineProtocolConstants.cmdRemoteControl, // 这里通常是 0x00 // 并且会执行 flush() 确保 100ms 实时性
payload, await _connectivityCubit.sendCommand(
ProtocolConstants.cmdRemoteControl, // 0x00
rawPacket,
); );
} }
/// 过滤出 0x02 状态包并转换为业务 Entity
@override @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 { import 'package:equatable/equatable.dart';
final int originX;
final int originY; class MachineControlEntity extends Equatable{
final int leftSpeed;
final int rightSpeed;
final int chassisLift; // 0:停, 1:上, 2:下 final int chassisLift; // 0:停, 1:上, 2:下
final int mowerSpeed; // 0:停, 1:+, 2:- final int mowerSpeed; // 0:停, 1:+, 2:-
final int ignitionStatus; // 0:停, 1:火, 2:熄 final int ignitionStatus; // 0:停, 1:火, 2:熄
final bool isEmergency; final bool isEmergency;
MachineControlStatusEntity({ MachineControlEntity({
this.originX = 0, this.leftSpeed = 0,
this.originY = 0, this.rightSpeed = 0,
this.chassisLift = 0, this.chassisLift = 0,
this.mowerSpeed = 0, this.mowerSpeed = 0,
this.ignitionStatus = 0, this.ignitionStatus = 0,
this.isEmergency = false, this.isEmergency = false,
}); });
MachineControlStatusEntity copyWith({ MachineControlEntity copyWith({
int? x, int? x,
int? y, int? y,
int? lift, int? lift,
@@ -23,13 +25,23 @@ class MachineControlStatusEntity {
int? ignition, int? ignition,
bool? emergency, bool? emergency,
}) { }) {
return MachineControlStatusEntity( return MachineControlEntity(
originX: x ?? originX, leftSpeed: y ?? leftSpeed,
originY: y ?? originY, rightSpeed: x ?? rightSpeed,
chassisLift: lift ?? chassisLift, chassisLift: lift ?? chassisLift,
mowerSpeed: mower ?? mowerSpeed, mowerSpeed: mower ?? mowerSpeed,
ignitionStatus: ignition ?? ignitionStatus, ignitionStatus: ignition ?? ignitionStatus,
isEmergency: emergency ?? isEmergency, 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 'package:maibu_satabot_v2/core/protocol/packet_entity.dart';
import '../../domain/entities/machine_control_status_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 { abstract class RemoteControlRepository {
/// 高频发送遥控指令 (被 Cubit 的 100ms 定时器调用) /// 高频发送遥控指令
/// 负责将 Entity 转换为底层的 0x00 指令并发出 /// 修改建议:返回 Future<void> 没问题,但内部建议处理错误
void sendControlMachineCmd(MachineControlStatusEntity status); Future<void> sendControlMachineCmd(MachineControlEntity status);
/// 暴露解析后的回包流 (如 0x12 权限申请) /// 状态信息流 (过滤后的 0x02)
/// UI 或 Cubit 监听此流来处理机器人主动推送的消息 Stream<RunningStatusEntity> get runningStatusStream;
Stream<RawPacket> get responseStream;
/// WebView 视角控制 (逻辑层面的切换,不涉及 TCP) /// 暴露解析后的原始包流 (统一改为 Packet)
/// 这里的 Packet 已经是去掉了 AB AA 和 AA AB 的清洁包
Stream<PacketEntity> get responseStream;
/// WebView 视角控制 (保持不变)
void changeWebViewDirection(String direction); void changeWebViewDirection(String direction);
} }

View File

@@ -1,47 +1,104 @@
import 'dart:async'; import 'dart:async';
import 'package:flutter_bloc/flutter_bloc.dart'; 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 'package:maibu_satabot_v2/features/remote_control/presentation/bloc/remote_control_state.dart';
import '../../../../core/di/injection.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/repositories/remote_control_repository.dart';
class RemoteControlCubit extends Cubit<RemoteControlState> { class RemoteControlCubit extends Cubit<RemoteControlState> {
final RemoteControlRepository _repository; final RemoteControlRepository _repository;
Timer? _timer; final ConnectivityCubit connectivityCubit;
RemoteControlCubit(this._repository) // 定时器与流订阅管理
: super( Timer? _timer;
RemoteControlState( StreamSubscription<RunningStatusEntity>? _statusSubscription;
controlEntity: MachineControlStatusEntity(), StreamSubscription<PacketEntity>? _responseSubscription;
runningStatusModel: RunningStatusModel(),
), RemoteControlCubit(this._repository,this.connectivityCubit)
) { : super(
RemoteControlState(
controlEntity: MachineControlEntity(),
runningStatusEntity: RunningStatusEntity(),
),
) {
// 构造时立即开启监听
_initPacketListener(); _initPacketListener();
_initRunningStatusListener();
} }
// 1. 初始化回包监听 (如 0x12 权限) // ==========================================
// 1. 数据监听逻辑 (Data Listening)
// ==========================================
/// 监听原始回包 (处理如 0x12 权限申请等指令)
void _initPacketListener() { void _initPacketListener() {
_repository.responseStream.listen((packet) { _responseSubscription?.cancel();
if (packet.command == 0x12) { _responseSubscription = _repository.responseStream.listen((packet) {
// 根据负载判断是否有权限,更新状态 // 处理权限申请回包 (假设 0x12)
emit(state.copyWith(hasPermission: true)); 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() { void startControlLoop() {
_timer?.cancel(); _timer?.cancel();
_timer = Timer.periodic(const Duration(milliseconds: 100), (timer) { _timer = Timer.periodic(const Duration(milliseconds: 100), (timer) async {
// 核心调用:直接把 state 里的实体丢给 repository // 核心:在具备驾驶条件(有权限、未锁定、非急停)时发送指令
_repository.sendControlMachineCmd(state.controlEntity); if (state.canDrive) {
try {
await _repository.sendControlMachineCmd(state.controlEntity);
} catch (e) {
print("发送控制指令失败: $e");
}
}
}); });
emit(state.copyWith(status: RemoteControlStatus.controlling)); 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) { void updateJoystick(double x, double y) {
final updatedEntity = state.controlEntity.copyWith( final updatedEntity = state.controlEntity.copyWith(
x: x.toInt(), x: x.toInt(),
@@ -50,53 +107,92 @@ class RemoteControlCubit extends Cubit<RemoteControlState> {
emit(state.copyWith(controlEntity: updatedEntity)); emit(state.copyWith(controlEntity: updatedEntity));
} }
// 4. 更新功能开关 (比如割刀速度、灯光、点火等) /// 更新原点 Y 轴 (针对特定的 UI 逻辑)
void updateFunction({int? mower, int? lift, int? ignition, bool? emergency}) { void updateOriginY(int y) {
final updatedEntity = state.controlEntity.copyWith( final updatedEntity = state.controlEntity.copyWith(y: y);
mower: mower,
lift: lift,
ignition: ignition,
emergency: emergency,
);
emit(state.copyWith(controlEntity: updatedEntity)); emit(state.copyWith(controlEntity: updatedEntity));
} }
void updateOriginY(int y) {} /// 更新底盘升降
void updateChassisLift(int level) {
// 5. 停止控制循环 final updatedEntity = state.controlEntity.copyWith(lift: level);
void stopControlLoop() { emit(state.copyWith(controlEntity: updatedEntity));
_timer?.cancel();
_timer = null;
emit(state.copyWith(status: RemoteControlStatus.initial));
} }
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) { void togglePermissionDialog(bool show) {
emit(state.copyWith(showPermissionRequestDialog: show)); emit(state.copyWith(showPermissionRequestDialog: show));
} }
void requestControlPermission() { /// 发起/响应权限请求
// 1. 关闭弹窗 void respondPermission(bool accept) {
emit(state.copyWith(showPermissionRequestDialog: false)); // 这里可以添加发送 0x12 响应包给硬件的逻辑
emit(state.copyWith(
// 2. 这里执行你发送 0x12 指令的逻辑 hasPermission: accept,
// _sendProtocolData(0x12, ...); showPermissionRequestDialog: false,
));
} }
/// 切换画中画显示
void toggleLeftPip() => emit(state.copyWith(showLeftPip: !state.showLeftPip)); void toggleLeftPip() => emit(state.copyWith(showLeftPip: !state.showLeftPip));
void toggleRightPip() => void toggleRightPip() => emit(state.copyWith(showRightPip: !state.showRightPip));
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 @override
Future<void> close() { Future<void> close() {
_timer?.cancel(); // 退出页面时务必销毁定时器 _timer?.cancel();
_statusSubscription?.cancel();
_responseSubscription?.cancel();
return super.close(); 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:equatable/equatable.dart';
import 'package:maibu_satabot_v2/features/remote_control/data/models/running_status_model.dart'; import '../../domain/entities/machine_control_entity.dart';
import '../../domain/entities/running_status_entity.dart';
import '../../domain/entities/machine_control_status_entity.dart';
enum RemoteControlStatus { initial, controlling, error } enum RemoteControlStatus { initial, controlling, error }
enum ControlMode { TCP, BLE, LOCAL, NONE, OTHER }
class RemoteControlState extends Equatable { class RemoteControlState extends Equatable {
// --- 核心业务状态 ---
final RemoteControlStatus status; final RemoteControlStatus status;
final MachineControlStatusEntity controlEntity; // 控制业务实体 final MachineControlEntity controlEntity; // 发送给机器的控制业务实体
final RunningStatusModel runningStatusModel; final RunningStatusEntity runningStatusEntity; // 机器回传的状态业务实体
//状态业务实体
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 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({ const RemoteControlState({
this.status = RemoteControlStatus.initial, this.status = RemoteControlStatus.initial,
required this.controlEntity, required this.controlEntity,
this.errorMessage, required this.runningStatusEntity,
this.hasPermission = false, this.hasPermission = false,
this.isEmergency = false,
this.isLocked = false,
this.ping = 0,
this.battery = 0,
this.voltage = 0,
this.permissionPlatform = '', this.permissionPlatform = '',
this.currentPlatform = '', this.currentPlatform = '',
this.showPermissionRequestDialog = false, this.showPermissionRequestDialog = false,
this.isEmergency = false,
this.isLocked = false,
this.obstacleFlag = '无',
this.ping = 0,
this.errorMessage,
this.showLeftPip = true, this.showLeftPip = true,
this.showRightPip = true, this.showRightPip = true,
this.obstacleFlag = '', this.topRightIsExpanded = false,
required this.runningStatusModel, this.obstacleRecognitionFlag = false,
this.mainStreamUrl = '',
this.aiStreamUrl = '',
this.isVideoEnabled = true
}); });
// 方便 UI 更新部分属性 // 辅助属性:判断当前是否具备物理驾驶条件
bool get canDrive => hasPermission && !isLocked;
RemoteControlState copyWith({ RemoteControlState copyWith({
RemoteControlStatus? status, RemoteControlStatus? status,
MachineControlStatusEntity? controlEntity, MachineControlEntity? controlEntity,
String? errorMessage, RunningStatusEntity? runningStatusEntity,
bool? hasPermission, bool? hasPermission,
bool? isEmergency,
bool? isLocked,
int? ping,
int? battery,
int? voltage,
String? permissionPlatform, String? permissionPlatform,
String? currentPlatform, String? currentPlatform,
bool? showPermissionRequestDialog, bool? showPermissionRequestDialog,
bool? isEmergency,
bool? isLocked,
String? obstacleFlag,
int? ping,
String? errorMessage,
bool? showLeftPip, bool? showLeftPip,
bool? showRightPip, bool? showRightPip,
RunningStatusModel? runningStatusModel, bool? topRightIsExpanded,
String? obstacleFlag, bool? obstacleRecognitionFlag,
bool? isVideoEnabled,
String? mainStreamUrl,
String? aiStreamUrl
}) { }) {
return RemoteControlState( return RemoteControlState(
status: status ?? this.status, status: status ?? this.status,
controlEntity: controlEntity ?? this.controlEntity, controlEntity: controlEntity ?? this.controlEntity,
errorMessage: errorMessage ?? this.errorMessage, runningStatusEntity: runningStatusEntity ?? this.runningStatusEntity,
hasPermission: hasPermission ?? this.hasPermission, 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, permissionPlatform: permissionPlatform ?? this.permissionPlatform,
currentPlatform: currentPlatform ?? this.currentPlatform, currentPlatform: currentPlatform ?? this.currentPlatform,
showPermissionRequestDialog: showPermissionRequestDialog: showPermissionRequestDialog ?? this.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, showLeftPip: showLeftPip ?? this.showLeftPip,
showRightPip: showRightPip ?? this.showRightPip, showRightPip: showRightPip ?? this.showRightPip,
runningStatusModel: runningStatusModel ?? this.runningStatusModel, topRightIsExpanded: topRightIsExpanded ?? this.topRightIsExpanded,
obstacleFlag: obstacleFlag ?? this.obstacleFlag, 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 => [ List<Object?> get props => [
status, status,
controlEntity, controlEntity,
errorMessage, runningStatusEntity,
hasPermission, hasPermission,
isEmergency,
isLocked,
ping,
battery,
voltage,
permissionPlatform, permissionPlatform,
currentPlatform, currentPlatform,
showPermissionRequestDialog, showPermissionRequestDialog,
isEmergency,
isLocked,
obstacleFlag,
ping,
errorMessage,
showLeftPip, showLeftPip,
showRightPip, showRightPip,
runningStatusModel, topRightIsExpanded,
obstacleFlag, 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:go_router/go_router.dart';
import 'package:maibu_satabot_v2/core/consts/tcp_consts.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 '../../../devices/presentation/bloc/devices_cubit.dart';
import '../bloc/remote_control_cubit.dart'; import '../bloc/remote_control_cubit.dart';
import '../bloc/remote_control_state.dart'; import '../bloc/remote_control_state.dart';
@@ -49,125 +49,153 @@ class _RemoteControlPageState extends State<RemoteControlPage> {
final deviceState = context.watch<DevicesCubit>().state; final deviceState = context.watch<DevicesCubit>().state;
final currentDevice = deviceState.selectedDevice; final currentDevice = deviceState.selectedDevice;
if (currentDevice == null) { if (currentDevice != null &&
return _buildOfflineScaffold(); 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( return PopScope(
backgroundColor: Colors.black, onPopInvokedWithResult: (didPop, result) {
body: Stack( if (didPop) {
children: [ // 无论怎么退出的,都强制清理视频和控制循环
// 在 Stack 的最底层替换: context.read<RemoteControlCubit>().stopControlLoop();
Positioned.fill( context.read<RemoteControlCubit>().disposeVideo();
child: BlocBuilder<RemoteControlCubit, RemoteControlState>( }
// 只有当显示隐藏状态改变时才重构,内部的拖拽由组件自身 State 处理,不影响这里 },
buildWhen: (p, c) => child: Scaffold(
p.showLeftPip != c.showLeftPip || backgroundColor: Colors.black,
p.showRightPip != c.showRightPip, 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) { builder: (context, state) {
return WebRTCLocalPlayer( return state.isEmergency
// 这里的 URL 拼接根据你的后端规则 ? const Positioned.fill(child: EmergencyOverlay())
// streamUrl: "webrtc://${TCPConsts.TCP_IP}/live/livestream/${currentDevice.deviceName}?token=${userState.user!.token}", : const SizedBox.shrink();
streamUrl:
"webrtc://${TCPConsts.TCP_IP}/live/livestream/111?token=${userState.user!.token}",
showLeftPip: state.showLeftPip, // 从 Cubit 状态中读取
showRightPip: state.showRightPip, // 从 Cubit 状态中读取
);
}, },
), ),
),
BlocBuilder<RemoteControlCubit, RemoteControlState>( SafeArea(
buildWhen: (p, c) => p.isEmergency != c.isEmergency, child: Column(
builder: (context, state) { children: [
return state.isEmergency const TopStatusBar(),
? const Positioned.fill(child: EmergencyOverlay()) Expanded(
: const SizedBox.shrink(); child: LayoutBuilder(
}, builder: (context, constraints) {
), final double sideAreaWidth =
constraints.maxWidth * 0.25;
final double centerAreaWidth =
constraints.maxWidth * 0.5;
SafeArea( // 关键:使用 BlocBuilder 局部刷新摇杆,不要 watch 整个 Page
child: Column( return BlocBuilder<
children: [ RemoteControlCubit,
const TopStatusBar(), RemoteControlState
Expanded( >(
child: LayoutBuilder( // 只有 isLocked 改变时才重构摇杆区域,摇杆坐标改变由内部处理
builder: (context, constraints) { buildWhen: (p, c) => p.isLocked != c.isLocked,
final double sideAreaWidth = constraints.maxWidth * 0.25; builder: (context, remoteState) {
final double centerAreaWidth = constraints.maxWidth * 0.5; return Padding(
padding: const EdgeInsets.symmetric(vertical: 10),
// 关键:使用 BlocBuilder 局部刷新摇杆,不要 watch 整个 Page child: Row(
return BlocBuilder< mainAxisAlignment: MainAxisAlignment.end,
RemoteControlCubit, crossAxisAlignment: CrossAxisAlignment.end,
RemoteControlState children: [
>( // 左摇杆
// 只有 isLocked 改变时才重构摇杆区域,摇杆坐标改变由内部处理 SizedBox(
buildWhen: (p, c) => p.isLocked != c.isLocked, width: sideAreaWidth,
builder: (context, remoteState) { child: Align(
return Padding( alignment: Alignment.bottomCenter,
padding: const EdgeInsets.symmetric(vertical: 10), child: LeftJoystickArea(
child: Row( isLocked: remoteState.isLocked,
mainAxisAlignment: MainAxisAlignment.end, width: sideAreaWidth * 0.5 * 0.7,
crossAxisAlignment: CrossAxisAlignment.end, ),
children: [
// 左摇杆
SizedBox(
width: sideAreaWidth,
child: Align(
alignment: Alignment.bottomCenter,
child: LeftJoystickArea(
isLocked: remoteState.isLocked,
width: sideAreaWidth * 0.5 * 0.7,
), ),
), ),
),
// 中间区 // 中间区
SizedBox( SizedBox(
width: centerAreaWidth, width: centerAreaWidth,
child: Align( child: Align(
alignment: Alignment.bottomCenter, alignment: Alignment.bottomCenter,
child: CenterControlArea( child: CenterControlArea(
totalWidth: centerAreaWidth, totalWidth: centerAreaWidth,
),
), ),
), ),
),
// 右摇杆 // 右摇杆
SizedBox( SizedBox(
width: sideAreaWidth, width: sideAreaWidth,
child: Align( child: Align(
alignment: Alignment.bottomCenter, alignment: Alignment.bottomCenter,
child: RightJoystickArea( child: RightJoystickArea(
isLocked: remoteState.isLocked, isLocked: remoteState.isLocked,
width: sideAreaWidth * 0.5 * 0.7, width: sideAreaWidth * 0.5 * 0.7,
),
), ),
), ),
), ],
], ),
), );
); },
}, );
); },
}, ),
), ),
), ],
], ),
), ),
),
// 权限弹窗 // 权限弹窗
BlocBuilder<RemoteControlCubit, RemoteControlState>( BlocBuilder<RemoteControlCubit, RemoteControlState>(
buildWhen: (p, c) => buildWhen: (p, c) =>
p.showPermissionRequestDialog != c.showPermissionRequestDialog, p.showPermissionRequestDialog !=
builder: (context, state) { c.showPermissionRequestDialog,
if (state.showPermissionRequestDialog) { builder: (context, state) {
return _buildPermissionDialog(context, state); if (state.showPermissionRequestDialog) {
} return _buildPermissionDialog(context, state);
return const SizedBox.shrink(); }
}, 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_bloc/flutter_bloc.dart';
import 'package:flutter_svg/svg.dart'; import 'package:flutter_svg/svg.dart';
import 'package:go_router/go_router.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 '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 '../../../devices/presentation/bloc/devices_cubit.dart';
import '../bloc/remote_control_cubit.dart'; import '../bloc/remote_control_cubit.dart';
@@ -20,12 +20,18 @@ class TopStatusBar extends StatelessWidget {
// 监听局部遥控状态 // 监听局部遥控状态
final remoteState = context.watch<RemoteControlCubit>().state; final remoteState = context.watch<RemoteControlCubit>().state;
var _remoteControlCubit = context.read<RemoteControlCubit>();
return Padding( return Padding(
padding: const EdgeInsets.all(12.0), padding: const EdgeInsets.all(12.0),
child: Row( child: Row(
children: [ children: [
// 1. 返回按钮 (对应 SimpleSmallFunctionButton) // 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), const SizedBox(width: 16),
// 2. 控制状态 (对应 StatusChipLeft) // 2. 控制状态 (对应 StatusChipLeft)
@@ -39,7 +45,7 @@ class TopStatusBar extends StatelessWidget {
onTap: () { onTap: () {
if (!remoteState.hasPermission) { if (!remoteState.hasPermission) {
// 弹出请求权限对话框逻辑 // 弹出请求权限对话框逻辑
context.read<RemoteControlCubit>().togglePermissionDialog(true); _remoteControlCubit.togglePermissionDialog(true);
} }
}, },
), ),
@@ -50,7 +56,7 @@ class TopStatusBar extends StatelessWidget {
remoteState.isLocked remoteState.isLocked
? "assets/svgs/remote_lock.svg" ? "assets/svgs/remote_lock.svg"
: "assets/svgs/remote_lock_open.svg", : "assets/svgs/remote_lock_open.svg",
() => context.read<RemoteControlCubit>().toggleLock(), () => _remoteControlCubit.toggleLock(),
isSelected: remoteState.isLocked, isSelected: remoteState.isLocked,
), ),
const SizedBox(width: 8), const SizedBox(width: 8),
@@ -60,28 +66,77 @@ class TopStatusBar extends StatelessWidget {
const Spacer(), const Spacer(),
// TODO _buildExpandIconButton(
_buildControlModeChip(,remoteState.runningStatusModel.controlMode), 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) // 5. 信号延迟 (对应 pingStatusChip)
_buildPingChip(remoteState.ping), // _buildPingChip(remoteState.ping),
const SizedBox(width: 8), const SizedBox(width: 8),
// 6. 电量 (对应 StatusChipRight) // 6. 电量 (对应 StatusChipRight)
_buildBatteryChip(remoteState.battery), _buildBatteryChip(remoteState.runningStatusEntity.battery),
], ],
), ),
); );
} }
Widget _buildIconButton( Widget _buildIconButton(
String svgAsset, // 改为 String 路径,例如 'assets/icons/stop.svg' String svgAsset,
// 改为 String 路径,例如 'assets/icons/stop.svg'
VoidCallback onTap, { VoidCallback onTap, {
bool isSelected = false, 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) { Widget _buildPingChip(int ping) {
Color color = ping < 100 Color color = ping < 100
? Colors.green ? Colors.green
@@ -133,13 +272,11 @@ class TopStatusBar extends StatelessWidget {
displayText = 'TCP模式'; displayText = 'TCP模式';
break; break;
case ControlMode.NONE: case ControlMode.NONE:
displayText = '无模式'; displayText = '无';
break; break;
case ControlMode.OTHER: case ControlMode.OTHER:
displayText = '其他模式'; displayText = '其他模式';
break; break;
default:
displayText = '未知模式';
} }
return StatusChip( return StatusChip(
@@ -150,7 +287,7 @@ class TopStatusBar extends StatelessWidget {
); );
} }
Widget _buildVoltageChip(int voltage) { Widget _buildVoltageChip(double voltage) {
return StatusChip( return StatusChip(
text: "$voltage V", text: "$voltage V",
color: Color(0xFFFACC15).withValues(alpha: 0.6), color: Color(0xFFFACC15).withValues(alpha: 0.6),
@@ -159,7 +296,7 @@ class TopStatusBar extends StatelessWidget {
); );
} }
Widget _buildBatteryChip(int level) { Widget _buildBatteryChip(double level) {
// 1. 根据数值判断图片路径 (逻辑与之前一致) // 1. 根据数值判断图片路径 (逻辑与之前一致)
String svgPath; String svgPath;
Color color; Color color;

View File

@@ -5,15 +5,19 @@ import 'package:flutter_webrtc/flutter_webrtc.dart';
import 'package:http/http.dart' as http; import 'package:http/http.dart' as http;
class WebRTCLocalPlayer extends StatefulWidget { class WebRTCLocalPlayer extends StatefulWidget {
final String streamUrl; final String streamUrl; // 第一个流(四分屏)
final String? secondStreamUrl; // 第二条完整流,可选
final bool showLeftPip; final bool showLeftPip;
final bool showRightPip; final bool showRightPip;
final bool obstacleRecognitionFlag;
const WebRTCLocalPlayer({ const WebRTCLocalPlayer({
super.key, super.key,
required this.streamUrl, required this.streamUrl,
this.secondStreamUrl,
this.showLeftPip = true, this.showLeftPip = true,
this.showRightPip = true, this.showRightPip = true,
this.obstacleRecognitionFlag = false,
}); });
@override @override
@@ -21,19 +25,17 @@ class WebRTCLocalPlayer extends StatefulWidget {
} }
class _WebRTCLocalPlayerState extends State<WebRTCLocalPlayer> { class _WebRTCLocalPlayerState extends State<WebRTCLocalPlayer> {
final RTCVideoRenderer _renderer = RTCVideoRenderer(); final RTCVideoRenderer _renderer = RTCVideoRenderer(); // 第一个流(四分屏)
RTCVideoRenderer? _secondRenderer; // 第二条完整流
RTCPeerConnection? _peerConnection; RTCPeerConnection? _peerConnection;
RTCPeerConnection? _secondPeerConnection;
bool _isInitialized = false; bool _isInitialized = false;
bool _isFrontMain = true; bool _isFrontMain = true; // true=前,false=后
// 使用 ValueNotifier 配合局部刷新,提升拖拽性能 final ValueNotifier<Offset> _leftPosNotifier = ValueNotifier(const Offset(20, 80));
final ValueNotifier<Offset> _leftPosNotifier = ValueNotifier( final ValueNotifier<Offset> _rightPosNotifier = ValueNotifier(const Offset(200, 80));
const Offset(20, 80),
);
final ValueNotifier<Offset> _rightPosNotifier = ValueNotifier(
const Offset(200, 80),
);
bool _isPosInitialized = false; bool _isPosInitialized = false;
@override @override
@@ -45,42 +47,44 @@ class _WebRTCLocalPlayerState extends State<WebRTCLocalPlayer> {
Future<void> _prepareAndConnect() async { Future<void> _prepareAndConnect() async {
try { try {
await _renderer.initialize(); await _renderer.initialize();
if (!mounted) return; await _initWebRTCConnection(widget.streamUrl, _renderer, true);
await _initWebRTCConnection();
if (widget.obstacleRecognitionFlag && widget.secondStreamUrl != null) {
await _startSecondStream(widget.secondStreamUrl!);
}
setState(() => _isInitialized = true); setState(() => _isInitialized = true);
} catch (e) { } catch (e) {
debugPrint("初始化失败: $e"); debugPrint("初始化失败: $e");
} }
} }
Future<void> _initWebRTCConnection() async { Future<void> _initWebRTCConnection(
_peerConnection = await createPeerConnection({ String url, RTCVideoRenderer renderer, bool isMain) async {
final pc = await createPeerConnection({
"sdpSemantics": "unified-plan", "sdpSemantics": "unified-plan",
"iceServers": [ "iceServers": [
{"urls": "stun:stun.l.google.com:19302"}, {"urls": "stun:stun.l.google.com:19302"},
], ],
}); });
await _peerConnection!.addTransceiver( // 只接收视频
await pc.addTransceiver(
kind: RTCRtpMediaType.RTCRtpMediaTypeVideo, kind: RTCRtpMediaType.RTCRtpMediaTypeVideo,
init: RTCRtpTransceiverInit(direction: TransceiverDirection.RecvOnly), init: RTCRtpTransceiverInit(direction: TransceiverDirection.RecvOnly),
); );
_peerConnection!.onTrack = (RTCTrackEvent event) { pc.onTrack = (RTCTrackEvent event) {
if (event.track.kind == 'video' && event.streams.isNotEmpty) { if (event.streams.isNotEmpty && event.track.kind == 'video') {
if (mounted) setState(() => _renderer.srcObject = event.streams[0]); if (mounted) setState(() => renderer.srcObject = event.streams[0]);
} }
}; };
try { try {
RTCSessionDescription offer = await _peerConnection!.createOffer({ RTCSessionDescription offer = await pc.createOffer({'offerToReceiveVideo': 1});
'offerToReceiveVideo': 1, await pc.setLocalDescription(offer);
});
await _peerConnection!.setLocalDescription(offer);
Uri uri = Uri.parse( Uri uri = Uri.parse(url.replaceFirst('webrtc://', 'http://'));
widget.streamUrl.replaceFirst('webrtc://', 'http://'),
);
String apiUrl = "http://${uri.host}:1985/rtc/v1/play/"; String apiUrl = "http://${uri.host}:1985/rtc/v1/play/";
final response = await http.post( final response = await http.post(
@@ -88,7 +92,7 @@ class _WebRTCLocalPlayerState extends State<WebRTCLocalPlayer> {
headers: {'Content-Type': 'application/json'}, headers: {'Content-Type': 'application/json'},
body: jsonEncode({ body: jsonEncode({
"api": apiUrl, "api": apiUrl,
"streamurl": widget.streamUrl, "streamurl": url,
"clientip": null, "clientip": null,
"sdp": offer.sdp, "sdp": offer.sdp,
}), }),
@@ -96,179 +100,155 @@ class _WebRTCLocalPlayerState extends State<WebRTCLocalPlayer> {
if (response.statusCode == 200) { if (response.statusCode == 200) {
final data = jsonDecode(response.body); final data = jsonDecode(response.body);
await _peerConnection!.setRemoteDescription( await pc.setRemoteDescription(
RTCSessionDescription(data['sdp'], 'answer'), RTCSessionDescription(data['sdp'], 'answer'),
); );
} }
} catch (e) { } catch (e) {
debugPrint("信令错误: $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 @override
void dispose() { void dispose() {
_peerConnection?.close();
_peerConnection?.dispose(); _peerConnection?.dispose();
_secondPeerConnection?.close();
_secondPeerConnection?.dispose();
_renderer.srcObject = null; _renderer.srcObject = null;
_renderer.dispose(); _renderer.dispose();
_secondRenderer?.srcObject = null;
_secondRenderer?.dispose();
_leftPosNotifier.dispose(); _leftPosNotifier.dispose();
_rightPosNotifier.dispose(); _rightPosNotifier.dispose();
super.dispose(); super.dispose();
} }
// 四分屏裁剪视图 // -------------------- 渲染逻辑 --------------------
Widget _buildQuadrantView({required Alignment alignment}) {
if (!_isInitialized || _renderer.srcObject == null)
return Container(color: Colors.black);
return RepaintBoundary( RTCVideoRenderer getFrontRenderer() {
child: ClipRect( if (widget.obstacleRecognitionFlag && _secondRenderer != null) {
child: FractionallySizedBox( return _secondRenderer!;
widthFactor: 2.0, }
heightFactor: 2.0, return _renderer;
alignment: alignment, }
child: RTCVideoView(
_renderer, RTCVideoRenderer getBackRenderer() => _renderer;
objectFit: RTCVideoViewObjectFit.RTCVideoViewObjectFitCover, RTCVideoRenderer getLeftRenderer() => _renderer;
mirror: false, 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,
), ),
), ),
), Positioned.fill(
); child: BackdropFilter(
} filter: ImageFilter.blur(sigmaX: 45, sigmaY: 45),
child: Container(color: Colors.black.withOpacity(0.55)),
// 主视角的矩形边缘羽化效果 ),
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,
), ),
), // 中心主视角 + 羽化
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 void _correctPosition(ValueNotifier<Offset> notifier, BoxConstraints constraints, double w, double h) {
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,
) {
double maxX = (constraints.maxWidth - w).clamp(0.0, double.infinity); double maxX = (constraints.maxWidth - w).clamp(0.0, double.infinity);
double maxY = (constraints.maxHeight - h).clamp(0.0, double.infinity); double maxY = (constraints.maxHeight - h).clamp(0.0, double.infinity);
double newX = notifier.value.dx.clamp(0.0, maxX); 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>( return ValueListenableBuilder<Offset>(
valueListenable: notifier, valueListenable: notifier,
builder: (context, pos, child) { builder: (context, pos, child) {
@@ -295,7 +268,6 @@ class _WebRTCLocalPlayerState extends State<WebRTCLocalPlayer> {
child: GestureDetector( child: GestureDetector(
behavior: HitTestBehavior.opaque, behavior: HitTestBehavior.opaque,
onPanUpdate: (details) { onPanUpdate: (details) {
// 关键修正:使用 notifier.value.dx 替换 pos.dx,消除位移丢失导致的阻尼感
final double nextX = (notifier.value.dx + details.delta.dx).clamp( final double nextX = (notifier.value.dx + details.delta.dx).clamp(
0.0, 0.0,
(constraints.maxWidth - w).clamp(0.0, double.infinity), (constraints.maxWidth - w).clamp(0.0, double.infinity),
@@ -315,16 +287,57 @@ class _WebRTCLocalPlayerState extends State<WebRTCLocalPlayer> {
height: h, height: h,
decoration: BoxDecoration( decoration: BoxDecoration(
borderRadius: BorderRadius.circular(10), borderRadius: BorderRadius.circular(10),
boxShadow: [ boxShadow: [BoxShadow(color: Colors.black.withOpacity(0.5), blurRadius: 20, offset: const Offset(0, 8))],
BoxShadow(
color: Colors.black.withOpacity(0.5),
blurRadius: 20,
offset: const Offset(0, 8),
),
],
), ),
clipBehavior: Clip.antiAlias, 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/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:go_router/go_router.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/infrastructure/logging/app_bloc_observer.dart';
import 'package:maibu_satabot_v2/core/logging/i_logger_service.dart'; import 'package:maibu_satabot_v2/core/logging/i_logger_service.dart';
import 'package:maibu_satabot_v2/core/theme/AppTheme.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 'package:maibu_satabot_v2/features/devices/presentation/bloc/devices_cubit.dart';
import 'core/di/injection.dart'; import 'core/di/injection.dart';
import 'core/network/base/i_connection.dart';
import 'features/auth/presentation/bloc/login_cubit.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 { void main() async {
// 1. 使用 runZonedGuarded 捕获所有未处理的异步错误 // 1. 使用 runZonedGuarded 捕获所有未处理的异步错误
@@ -55,12 +58,60 @@ class MyApp extends StatelessWidget {
sl<DevicesCubit>() sl<DevicesCubit>()
..fetchAllDevices(sl<AppUserCubit>().state.user!.username), ..fetchAllDevices(sl<AppUserCubit>().state.user!.username),
), ),
BlocProvider(create: (_) => sl<ConnectivityCubit>()),
], ],
child: MaterialApp.router( child: MaterialApp.router(
title: 'Maibu Satabot', title: 'Maibu Satabot',
theme: AppTheme.lightTheme, theme: AppTheme.lightTheme,
routerConfig: sl<GoRouter>(), 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" url: "https://pub.flutter-io.cn"
source: hosted source: hosted
version: "1.5.0" 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: sentry:
dependency: transitive dependency: transitive
description: description:

View File

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