Compare commits
2 Commits
feature/ne
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e16625c288 | ||
|
|
949743972c |
@@ -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
|
|
||||||
@@ -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"}}
|
|
||||||
@@ -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
|
||||||
@@ -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Ë?Úãå¸
|
||||||
@@ -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"}}
|
||||||
@@ -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"}}
|
||||||
@@ -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
|
||||||
@@ -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>
|
||||||
@@ -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
|
||||||
@@ -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>
|
||||||
@@ -1 +1 @@
|
|||||||
2026-01-21T02:19:04.070843Z
|
2026-01-26T14:14:12.259855Z
|
||||||
Binary file not shown.
BIN
.sentry-native/reports/52ea07c5-b695-4da2-bc97-95e97f431831.dmp
Normal file
BIN
.sentry-native/reports/52ea07c5-b695-4da2-bc97-95e97f431831.dmp
Normal file
Binary file not shown.
Binary file not shown.
@@ -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
|
||||||
|
|||||||
10
assets/svgs/remote_recognition.svg
Normal file
10
assets/svgs/remote_recognition.svg
Normal 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 |
11
assets/svgs/remote_recognition_off.svg
Normal file
11
assets/svgs/remote_recognition_off.svg
Normal 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 |
@@ -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());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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];
|
|
||||||
}
|
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()));
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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}');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 { /* 同原逻辑 */ }
|
||||||
|
}
|
||||||
14
lib/core/network/base/i_connection.dart
Normal file
14
lib/core/network/base/i_connection.dart
Normal 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);
|
||||||
|
}
|
||||||
3
lib/core/network/ble/ble_client.dart
Normal file
3
lib/core/network/ble/ble_client.dart
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
class BleClient{
|
||||||
|
|
||||||
|
}
|
||||||
1
lib/core/network/enums/control_mode.dart
Normal file
1
lib/core/network/enums/control_mode.dart
Normal file
@@ -0,0 +1 @@
|
|||||||
|
enum ControlMode { TCP, BLE, LOCAL, NONE, OTHER }
|
||||||
@@ -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() {
|
||||||
17
lib/core/network/models/connection_config.dart
Normal file
17
lib/core/network/models/connection_config.dart
Normal 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);
|
||||||
|
}
|
||||||
@@ -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)));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
53
lib/core/network/tcp/tcp_connection.dart
Normal file
53
lib/core/network/tcp/tcp_connection.dart
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
12
lib/core/protocol/packet_entity.dart
Normal file
12
lib/core/protocol/packet_entity.dart
Normal 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];
|
||||||
|
}
|
||||||
83
lib/core/protocol/packet_parser_transformer.dart
Normal file
83
lib/core/protocol/packet_parser_transformer.dart
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
@@ -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();
|
||||||
@@ -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);
|
||||||
|
|||||||
@@ -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 {
|
||||||
@@ -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,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|
||||||
|
|||||||
30
lib/features/auth/presentation/bloc/app_user_cubit.dart
Normal file
30
lib/features/auth/presentation/bloc/app_user_cubit.dart
Normal 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());
|
||||||
|
}
|
||||||
|
}
|
||||||
21
lib/features/auth/presentation/bloc/app_user_state.dart
Normal file
21
lib/features/auth/presentation/bloc/app_user_state.dart
Normal 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];
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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 {}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import '../../../../core/domain/entities/user_entity.dart';
|
import '../../domain/entities/user_entity.dart';
|
||||||
|
|
||||||
abstract class LoginState {}
|
abstract class LoginState {}
|
||||||
|
|
||||||
|
|||||||
1
lib/features/connectivity/domain/control_mode.dart
Normal file
1
lib/features/connectivity/domain/control_mode.dart
Normal file
@@ -0,0 +1 @@
|
|||||||
|
enum ControlMode { TCP, BLE, LOCAL, NONE, OTHER }
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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];
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
@@ -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);
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 {
|
||||||
@@ -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,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
];
|
||||||
}
|
}
|
||||||
@@ -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,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) {}
|
|
||||||
}
|
|
||||||
@@ -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
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
@@ -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();
|
||||||
),
|
},
|
||||||
],
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 为空");
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
Reference in New Issue
Block a user