diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 3e05deac..ba8b796c 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -4,7 +4,7 @@ android:label="飒沓机器人" android:name="${applicationName}" android:usesCleartextTraffic="true" - android:icon="@mipmap/ic_launcher"> + android:icon="@mipmap/launcher_icon"> + + + + diff --git a/android/app/src/main/res/mipmap-hdpi/launcher_icon.png b/android/app/src/main/res/mipmap-hdpi/launcher_icon.png new file mode 100644 index 00000000..87750511 Binary files /dev/null and b/android/app/src/main/res/mipmap-hdpi/launcher_icon.png differ diff --git a/android/app/src/main/res/mipmap-mdpi/launcher_icon.png b/android/app/src/main/res/mipmap-mdpi/launcher_icon.png new file mode 100644 index 00000000..16a85bac Binary files /dev/null and b/android/app/src/main/res/mipmap-mdpi/launcher_icon.png differ diff --git a/android/app/src/main/res/mipmap-xhdpi/launcher_icon.png b/android/app/src/main/res/mipmap-xhdpi/launcher_icon.png new file mode 100644 index 00000000..29f84b66 Binary files /dev/null and b/android/app/src/main/res/mipmap-xhdpi/launcher_icon.png differ diff --git a/android/app/src/main/res/mipmap-xxhdpi/launcher_icon.png b/android/app/src/main/res/mipmap-xxhdpi/launcher_icon.png new file mode 100644 index 00000000..5dbd0cd4 Binary files /dev/null and b/android/app/src/main/res/mipmap-xxhdpi/launcher_icon.png differ diff --git a/android/app/src/main/res/mipmap-xxxhdpi/launcher_icon.png b/android/app/src/main/res/mipmap-xxxhdpi/launcher_icon.png new file mode 100644 index 00000000..3893e23a Binary files /dev/null and b/android/app/src/main/res/mipmap-xxxhdpi/launcher_icon.png differ diff --git a/android/app/src/main/res/values/colors.xml b/android/app/src/main/res/values/colors.xml new file mode 100644 index 00000000..d150a391 --- /dev/null +++ b/android/app/src/main/res/values/colors.xml @@ -0,0 +1,4 @@ + + + #2e2e2e + \ No newline at end of file diff --git a/assets/images/app_logo.png b/assets/images/app_logo.png new file mode 100644 index 00000000..6f04bb7b Binary files /dev/null and b/assets/images/app_logo.png differ diff --git a/assets/images/app_logo_black.png b/assets/images/app_logo_black.png new file mode 100644 index 00000000..6f3d9fec Binary files /dev/null and b/assets/images/app_logo_black.png differ diff --git a/assets/images/app_logo_gray.png b/assets/images/app_logo_gray.png new file mode 100644 index 00000000..22439b59 Binary files /dev/null and b/assets/images/app_logo_gray.png differ diff --git a/assets/images/app_logo_transparent.png b/assets/images/app_logo_transparent.png new file mode 100644 index 00000000..0a88fb3b Binary files /dev/null and b/assets/images/app_logo_transparent.png differ diff --git a/assets/images/car.png b/assets/images/car.png new file mode 100644 index 00000000..3136a96a Binary files /dev/null and b/assets/images/car.png differ diff --git a/assets/www/webrtc/playwebrtc.html b/assets/www/webrtc/playwebrtc.html new file mode 100644 index 00000000..d453bb72 --- /dev/null +++ b/assets/www/webrtc/playwebrtc.html @@ -0,0 +1,340 @@ + + + + + + WebRTC 前后视角 + PIP + 虚化背景(Apple TV 风格) + + + + + + + + + + + \ No newline at end of file diff --git a/assets/www/webrtc/websocket.js b/assets/www/webrtc/websocket.js new file mode 100644 index 00000000..782351c7 --- /dev/null +++ b/assets/www/webrtc/websocket.js @@ -0,0 +1,117 @@ +class WebSocketClient { + constructor(url) { + this.url = url; + this.socket = null; + this.connected = false; + this.reconnecting = false; + this.reconnectInterval = 3000; // 重连间隔(ms) + this.maxReconnectAttempts = 10; // 最大重连次数 + this.reconnectAttempts = 0; + this.messageQueue = []; // 消息队列 + + // 事件回调 + this.onConnect = null; + this.onMessage = null; + this.onClose = null; + this.onError = null; + } + + // 连接WebSocket服务器 + connect() { + if (this.socket && (this.socket.readyState === WebSocket.CONNECTING || this.socket.readyState === WebSocket.OPEN)) { + return; + } + + this.socket = new WebSocket(this.url); + + this.socket.onopen = (event) => { + this.connected = true; + this.reconnecting = false; + this.reconnectAttempts = 0; + console.log('WebSocket连接已建立'); + + // 发送队列中的所有消息 + this._sendQueuedMessages(); + + if (typeof this.onConnect === 'function') { + this.onConnect(event); + } + }; + + this.socket.onmessage = (event) => { + console.log('收到消息:', event.data); + this.onMessage(event.data); + }; + + this.socket.onclose = (event) => { + this.connected = false; + console.log('WebSocket连接已关闭,代码:', event.code, '原因:', event.reason); + + if (typeof this.onClose === 'function') { + this.onClose(event); + } + + // 非主动关闭时尝试重连 + if (!this.reconnecting && event.code !== 1000) { + this._scheduleReconnect(); + } + }; + + this.socket.onerror = (error) => { + console.error('WebSocket错误:', error); + if (typeof this.onError === 'function') { + this.onError(error); + } + }; + } + + // 发送消息 + send(message) { + if (this.connected && this.socket.readyState === WebSocket.OPEN) { + this.socket.send(message); + } else { + // 连接未建立,将消息加入队列 + this.messageQueue.push(message); + if (!this.reconnecting) { + this._scheduleReconnect(); + } + } + } + + // 关闭连接 + close(code = 1000, reason = '') { + this.reconnecting = false; + if (this.socket) { + this.socket.close(code, reason); + } + } + + // 安排重连 + _scheduleReconnect() { + if (this.reconnectAttempts < this.maxReconnectAttempts) { + this.reconnecting = true; + this.reconnectAttempts++; + const delay = this.reconnectInterval * Math.min(1, this.reconnectAttempts / 3); // 指数退避 + + console.log(`尝试重连 (${this.reconnectAttempts}/${this.maxReconnectAttempts}),${delay/1000}秒后...`); + setTimeout(() => { + console.log('正在重连...'); + this.connect(); + }, delay); + } else { + console.error('达到最大重连次数,停止重连'); + this.reconnecting = false; + } + } + + // 发送队列中的消息 + _sendQueuedMessages() { + if (this.messageQueue.length > 0) { + console.log(`发送队列中的${this.messageQueue.length}条消息`); + this.messageQueue.forEach(message => { + this.socket.send(message); + }); + this.messageQueue = []; + } + } + } \ No newline at end of file diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index 4386552f..387432f0 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -427,7 +427,7 @@ isa = XCBuildConfiguration; buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; - ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = AppIcon; CLANG_ANALYZER_NONNULL = YES; CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; CLANG_CXX_LIBRARY = "libc++"; @@ -484,7 +484,7 @@ isa = XCBuildConfiguration; buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; - ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = AppIcon; CLANG_ANALYZER_NONNULL = YES; CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; CLANG_CXX_LIBRARY = "libc++"; diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png index dc9ada47..412ebb50 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png index 7353c41e..3146402a 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png index 797d452e..f15d4d6e 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png index 6ed2d933..dff58910 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png index 4cd7b009..a65284af 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png index fe730945..3a97b1cb 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png index 321773cd..0ca83e35 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png index 797d452e..f15d4d6e 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png index 502f463a..b30f86a5 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png index 0ec30343..f19f3565 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@1x.png new file mode 100644 index 00000000..040e9018 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@2x.png new file mode 100644 index 00000000..55524e05 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@1x.png new file mode 100644 index 00000000..0828a9bd Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@2x.png new file mode 100644 index 00000000..3b32f14f Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png index 0ec30343..f19f3565 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png index e9f5fea2..537906b0 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@1x.png new file mode 100644 index 00000000..87750511 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@2x.png new file mode 100644 index 00000000..5dbd0cd4 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png index 84ac32ae..56b2f756 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png index 8953cba0..a69e4f51 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png index 0467bf12..0f6ae5a8 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png differ diff --git a/lib/core/app/app_cubit.dart b/lib/core/app/app_user_cubit.dart similarity index 51% rename from lib/core/app/app_cubit.dart rename to lib/core/app/app_user_cubit.dart index 51e3a380..1addcc9a 100644 --- a/lib/core/app/app_cubit.dart +++ b/lib/core/app/app_user_cubit.dart @@ -1,17 +1,16 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import '../domain/entities/user_entity.dart'; -import 'app_state.dart'; +import 'app_user_state.dart'; -class AppCubit extends Cubit { - AppCubit() : super(const AppState()); +class AppUserCubit extends Cubit { + AppUserCubit() : super(const AppUserState()); - // 传入实体对象,而不是零散的 id void setAuth(UserEntity user) { emit(state.copyWith(user)); } void clearAuth() { - emit(const AppState()); + emit(const AppUserState()); } } diff --git a/lib/core/app/app_state.dart b/lib/core/app/app_user_state.dart similarity index 69% rename from lib/core/app/app_state.dart rename to lib/core/app/app_user_state.dart index e2f9d7fc..7047586f 100644 --- a/lib/core/app/app_state.dart +++ b/lib/core/app/app_user_state.dart @@ -2,16 +2,16 @@ import 'package:equatable/equatable.dart'; import '../domain/entities/user_entity.dart'; -class AppState extends Equatable { +class AppUserState extends Equatable { final UserEntity? user; // 直接存实体,包含 userId, username,token 等所有信息 - const AppState({this.user}); + const AppUserState({this.user}); // 辅助属性:判断是否登录 bool get isLoggedIn => user != null; - AppState copyWith(UserEntity? user) { - return AppState(user: user ?? this.user); + AppUserState copyWith(UserEntity? user) { + return AppUserState(user: user ?? this.user); } // 必须重写 props,UI 才会只在数据真正变化时刷新 diff --git a/lib/core/consts/http_api_consts.dart b/lib/core/consts/http_api_consts.dart new file mode 100644 index 00000000..b304ce23 --- /dev/null +++ b/lib/core/consts/http_api_consts.dart @@ -0,0 +1,17 @@ +class HttpApiConsts { + static const String baseUrl = "http://1.95.137.212:8081"; + + /// 账号相关 + // 登录 + static const String loginUrl = "$baseUrl/login"; + + ///设备相关 + // 获取设备列表 + static const String getUserDevicesList = "$baseUrl/iot/device/list"; + // 绑定设备 + static const String bindDevice = "$baseUrl/forward/device/bind"; + // 解绑设备 + static const String unbindDevice = "$baseUrl/forward/device/unbind"; + // 切换设备 + static const String switchDevice = "$baseUrl/forward/device/switchDevice"; +} diff --git a/lib/core/data/base_model.dart b/lib/core/data/base_model.dart new file mode 100644 index 00000000..a08bf222 --- /dev/null +++ b/lib/core/data/base_model.dart @@ -0,0 +1,8 @@ +import '../../features/devices/domain/entities/device_entity.dart'; + +abstract class BaseModel { + BaseModel.fromJson(Map json); + Map toJson(); + toEntity(); + BaseModel.fromEntity(DeviceEntity entity); +} diff --git a/lib/core/di/injection.dart b/lib/core/di/injection.dart index be1e50a7..3634f0d0 100644 --- a/lib/core/di/injection.dart +++ b/lib/core/di/injection.dart @@ -1,6 +1,13 @@ import 'package:dio/dio.dart'; import 'package:get_it/get_it.dart'; import 'package:go_router/go_router.dart'; +import 'package:maibu_satabot_v2/features/devices/data/datasources/impl/device_http_datasource_impl.dart'; +import 'package:maibu_satabot_v2/features/devices/domain/repositories/device_repository.dart'; +import 'package:maibu_satabot_v2/features/devices/domain/usecases/get_user_device_usecase.dart'; +import 'package:maibu_satabot_v2/features/devices/presentation/bloc/devices_cubit.dart'; +import 'package:maibu_satabot_v2/features/remote_control/data/repositories/remote_control_repository_impl.dart'; +import 'package:maibu_satabot_v2/features/remote_control/domain/repositories/remote_control_repository.dart'; +import 'package:maibu_satabot_v2/features/remote_control/domain/usecase/diff_steer_usecase.dart'; import 'package:shared_preferences/shared_preferences.dart'; import '../../features/auth/data/datasources/auth_http_datasource.dart'; @@ -11,7 +18,10 @@ import '../../features/auth/domain/usecases/login_usecase.dart'; import '../../features/auth/presentation/bloc/auth_cubit.dart'; import '../../features/auth/presentation/bloc/login_bloc.dart'; import '../../features/auth/presentation/bloc/login_cubit.dart'; -import '../app/app_cubit.dart'; +import '../../features/devices/data/datasources/device_http_datasource.dart'; +import '../../features/devices/data/repositories/device_repository_impl.dart'; +import '../../features/remote_control/presentation/bloc/remote_control_cubit.dart'; +import '../app/app_user_cubit.dart'; import '../network/dio_client.dart'; import '../network/net_message_dispatcher.dart'; import '../network/tcp/tcp_client.dart'; @@ -48,20 +58,31 @@ Future init() async { sl.registerLazySingleton( () => AuthHttpDataSourceImpl(sl()), ); + sl.registerLazySingleton( + () => DeviceHttpDatasourceImpl(sl()), + ); /// 3. 仓库 (Repository) sl.registerLazySingleton( () => AuthRepositoryImpl( - remote: sl(), // 自动寻找已注册的 LoginRemoteDataSource + httpRemote: sl(), // 自动寻找已注册的 LoginRemoteDataSource localTokenStorage: sl(), // 自动寻找已注册的 TokenStorage ), ); + sl.registerLazySingleton(() => DeviceRepositoryImpl(sl())); + sl.registerLazySingleton( + () => RemoteControlRepositoryImpl(sl(), sl()), + ); /// 4. 用例 (UseCase) sl.registerLazySingleton(() => LoginUseCase(sl())); + sl.registerLazySingleton(() => GetUserDeviceUseCase(sl())); + sl.registerLazySingleton(() => DiffSteerUseCase()); /// 5. 状态管理 (Cubit/Bloc) - sl.registerLazySingleton(() => AppCubit()); // AuthCubit 依赖它,必须先注册 + sl.registerLazySingleton(() => AppUserCubit()); // AuthCubit 依赖它,必须先注册 + sl.registerLazySingleton(() => DevicesCubit(sl(), sl())); + sl.registerFactory(() => RemoteControlCubit(sl())); /// 6. 认证 (Auth) // --- 关键修改点 1: AuthCubit 必须在 GoRouter 之前注册,并传入参数 --- @@ -69,7 +90,7 @@ Future init() async { () => AuthCubit( sl(), sl(), - sl(), + sl(), sl(), ), ); diff --git a/lib/core/domain/usecases/base_usecase.dart b/lib/core/domain/usecases/base_usecase.dart index 868b905d..f34f7ebc 100644 --- a/lib/core/domain/usecases/base_usecase.dart +++ b/lib/core/domain/usecases/base_usecase.dart @@ -1,5 +1,11 @@ +import 'package:fpdart/fpdart.dart'; + +import '../../error/failure.dart'; + abstract class BaseUseCase { - Future call(Params params); + Future> call(Params params); } -class NoParams {} +class NoParams { + const NoParams(); +} diff --git a/lib/core/network/dio_client.dart b/lib/core/network/dio_client.dart index ad3b89f8..22c2ae85 100644 --- a/lib/core/network/dio_client.dart +++ b/lib/core/network/dio_client.dart @@ -1,16 +1,39 @@ import 'package:dio/dio.dart'; +import 'package:maibu_satabot_v2/core/consts/http_api_consts.dart'; + +import '../app/app_user_cubit.dart'; +import '../di/injection.dart'; class DioClient { static Dio create() { final dio = Dio( BaseOptions( - baseUrl: 'http://1.95.137.212:8081', + baseUrl: HttpApiConsts.baseUrl, connectTimeout: const Duration(seconds: 5), receiveTimeout: const Duration(seconds: 5), headers: {'Content-Type': 'application/json'}, ), ); dio.interceptors.add(LogInterceptor(requestBody: true, responseBody: true)); + dio.interceptors.add( + InterceptorsWrapper( + onRequest: (options, handler) { + // 1. 从 GetIt 中获取全局的 AppUserCubit + final userCubit = sl(); + + // 2. 从 Cubit 的状态中提取 Token + final token = userCubit.state.user?.token; + + // 3. 如果 Token 存在,则添加到请求头 + if (token != null && token.isNotEmpty) { + options.headers['Authorization'] = 'Bearer $token'; + } + + // 继续发送请求 + return handler.next(options); + }, + ), + ); return dio; } } diff --git a/lib/core/network/tcp/tcp_client.dart b/lib/core/network/tcp/tcp_client.dart index a7d51a05..483bec2c 100644 --- a/lib/core/network/tcp/tcp_client.dart +++ b/lib/core/network/tcp/tcp_client.dart @@ -1,7 +1,9 @@ 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 { @@ -42,13 +44,23 @@ class TcpClient { void sendRaw(int cmd, List payload) { if (_socket == null) return; - // 组装:头 AB AA + 指令 + 数据 + 尾 AA AB - final data = [0xAB, 0xAA, cmd, ...payload, 0xAA, 0xAB]; - _socket!.add(data); + + 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"); } } diff --git a/lib/core/protocol/machine_protocol_codec.dart b/lib/core/protocol/machine_protocol_codec.dart new file mode 100644 index 00000000..c2fea862 --- /dev/null +++ b/lib/core/protocol/machine_protocol_codec.dart @@ -0,0 +1,28 @@ +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(); + } +} diff --git a/lib/core/protocol/machine_protocol_constants.dart b/lib/core/protocol/machine_protocol_constants.dart new file mode 100644 index 00000000..ec213cd2 --- /dev/null +++ b/lib/core/protocol/machine_protocol_constants.dart @@ -0,0 +1,18 @@ +class MachineProtocolConstants { + // 固定字节 + static const int header1 = 0xAB; + static const int header2 = 0xAA; + static const int endFlag1 = 0xAA; + static const int endFlag2 = 0xAB; + + // 指令类型 (根据你的 CSV 文件) + static const int cmdRemoteControl = 0x00; // 远程遥控 + static const int cmdPathPlanning = 0x01; // 路径规划 + static const int cmdStatusInfo = 0x02; // 状态信息 + static const int cmdGetId = 0x03; // 连接请求/查询ID + static const int cmdGetAuth = 0x04; // 获取授权 + static const int cmdReadConfig = 0x05; // 读配置 + static const int cmdWriteConfig = 0x06; // 写配置 + static const int cmdObstacleAvoid = 0x07; // 避障 + static const int cmdHeartbeat = 0xFF; // 心跳包 +} diff --git a/lib/core/router/app_router.dart b/lib/core/router/app_router.dart index e39d9d28..73765c83 100644 --- a/lib/core/router/app_router.dart +++ b/lib/core/router/app_router.dart @@ -1,11 +1,13 @@ import 'package:go_router/go_router.dart'; import 'package:maibu_satabot_v2/core/router/route_paths.dart'; +import 'package:maibu_satabot_v2/features/ai/presentation/routes/ai_routes.dart'; +import 'package:maibu_satabot_v2/features/auth/presentation/routes/auth_routes.dart'; +import 'package:maibu_satabot_v2/features/home/presentation/routes/home_routes.dart'; +import 'package:maibu_satabot_v2/features/my/presentation/routes/my_routes.dart'; import '../../features/auth/presentation/bloc/auth_cubit.dart'; import '../../features/auth/presentation/bloc/auth_state.dart'; -import '../../features/auth/presentation/pages/login_page.dart'; -import '../../features/home/presentation/pages/home_page.dart'; -import '../../features/register/presentation/pages/register_page.dart'; +import '../../features/main_container/presentation/main_wrapper.dart'; import 'go_router_refresh_stream.dart'; GoRouter createRouter(AuthCubit authCubit) { @@ -25,9 +27,27 @@ GoRouter createRouter(AuthCubit authCubit) { return null; }, routes: [ - GoRoute(path: RoutePaths.login, builder: (_, __) => LoginPage()), - GoRoute(path: RoutePaths.register, builder: (_, __) => RegisterPage()), - GoRoute(path: RoutePaths.home, builder: (context, state) => HomePage()), + // 1. 外部路由(无底部导航栏) + ...AuthRoutes.routes, + ...HomeRoutes.routes, + ...AiRoutes.routes, + ...MyRoutes.routes, + + // 2. 内部路由(带底部导航栏的容器) + StatefulShellRoute.indexedStack( + builder: (context, state, navigationShell) { + // 返回我们定义的 MainWrapper + return MainWrapper(navigationShell: navigationShell); + }, + branches: [ + // 拿一级页面 + HomeRoutes.branch, + + AiRoutes.branch, + + MyRoutes.branch, + ], + ), ], ); } diff --git a/lib/core/router/route_paths.dart b/lib/core/router/route_paths.dart index 36e3fcaf..85c3b6a8 100644 --- a/lib/core/router/route_paths.dart +++ b/lib/core/router/route_paths.dart @@ -1,5 +1,16 @@ class RoutePaths { - static const login = '/auth'; - static const register = '/register'; + /// 账号相关页面 + static const login = '/auth/login'; + static const register = '/auth/register'; + + /// 一级页面 static const home = '/home'; + static const ai = '/ai'; + static const my = '/my'; + + /// 二级页面 + // Home下子页面 + static const remoteControl = '/home/remote_control'; + static const routePlan = '/home/route_plan'; + static const runningStatus = '/home/running_status'; } diff --git a/lib/core/theme/AppTheme.dart b/lib/core/theme/AppTheme.dart new file mode 100644 index 00000000..d25acfb7 --- /dev/null +++ b/lib/core/theme/AppTheme.dart @@ -0,0 +1,85 @@ +import 'package:flutter/material.dart'; + +class AppTheme { + // 基础色值定义 + static const Color _pureWhite = Color(0xFFFFFFFF); + static const Color _offWhite = Color(0xFFfafafc); // 稍微带点灰的白,用于背景增加层次感 + static const Color _pureBlack = Color(0xFF000000); + static const Color _darkGrey = Color(0xFF1C1C1E); // iOS 风格的深灰黑色 + + static ThemeData get lightTheme { + return ThemeData( + useMaterial3: true, + brightness: Brightness.light, + + // 1. 整体背景色 + scaffoldBackgroundColor: _offWhite, + + // 2. 核心颜色方案 + colorScheme: const ColorScheme.light( + primary: _pureBlack, // 主色设为黑色 + onPrimary: _pureWhite, // 黑色背景上的文字设为白色 + surface: _pureWhite, // 卡片、弹窗等表面颜色 + onSurface: _pureBlack, // 表面上的文字色 + background: _offWhite, + ), + + // 3. 全局按钮主题 + elevatedButtonTheme: ElevatedButtonThemeData( + style: ElevatedButton.styleFrom( + backgroundColor: _pureBlack, + foregroundColor: _pureWhite, + elevation: 0, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + padding: const EdgeInsets.symmetric(vertical: 16, horizontal: 24), + ), + ), + + // 4. 填充按钮主题 (常用语 Material 3) + filledButtonTheme: FilledButtonThemeData( + style: FilledButton.styleFrom( + backgroundColor: _pureBlack, + foregroundColor: _pureWhite, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + ), + + // 5. AppBar 主题 (纯白背景,黑色文字) + appBarTheme: const AppBarTheme( + backgroundColor: _pureWhite, + surfaceTintColor: Colors.transparent, + elevation: 0, + centerTitle: true, + iconTheme: IconThemeData(color: _pureBlack), + titleTextStyle: TextStyle( + color: _pureBlack, + fontSize: 18, + fontWeight: FontWeight.w700, + letterSpacing: -0.5, + ), + ), + + // 6. 输入框主题 (黑白简约) + inputDecorationTheme: InputDecorationTheme( + filled: true, + fillColor: _pureWhite, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: const BorderSide(color: Colors.black12), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: const BorderSide(color: Colors.black12), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: const BorderSide(color: _pureBlack, width: 1.5), + ), + ), + ); + } +} diff --git a/lib/core/utils/vibrate_util.dart b/lib/core/utils/vibrate_util.dart new file mode 100644 index 00000000..e69de29b diff --git a/lib/features/ai/presentation/pages/ai_page.dart b/lib/features/ai/presentation/pages/ai_page.dart new file mode 100644 index 00000000..f5b84d5d --- /dev/null +++ b/lib/features/ai/presentation/pages/ai_page.dart @@ -0,0 +1,10 @@ +import 'package:flutter/material.dart'; + +class AiPage extends StatelessWidget { + const AiPage({super.key}); + + @override + Widget build(BuildContext context) { + return Scaffold(body: Center(child: Text('Welcome AI page'))); + } +} diff --git a/lib/features/ai/presentation/routes/ai_routes.dart b/lib/features/ai/presentation/routes/ai_routes.dart new file mode 100644 index 00000000..3cdb5ce4 --- /dev/null +++ b/lib/features/ai/presentation/routes/ai_routes.dart @@ -0,0 +1,31 @@ +import 'package:go_router/go_router.dart'; +import 'package:maibu_satabot_v2/features/ai/presentation/pages/ai_page.dart'; + +import '../../../../core/router/route_paths.dart'; + +class AiRoutes { + /// 1. 全屏功能页面(不带导航栏) + /// 在 createRouter 的根 routes 中使用 ...HomeRoutes.routes 引入 + static List get routes => [ + // GoRoute( + // path: RoutePaths.remoteControl, + // builder: (context, state) => const RemoteControlPage(), // 需导入对应 Page + // ), + // GoRoute( + // path: RoutePaths.routePlan, + // builder: (context, state) => const RoutePlanPage(), + // ), + // GoRoute( + // path: RoutePaths.runningStatus, + // builder: (context, state) => const RunningStatusPage(), + // ), + ]; + + /// 2. 首页 Tab 分支(带导航栏) + /// 仅保留真正的首页入口 + static StatefulShellBranch get branch => StatefulShellBranch( + routes: [ + GoRoute(path: RoutePaths.ai, builder: (context, state) => const AiPage()), + ], + ); +} diff --git a/lib/features/auth/data/datasources/impl/auth_http_datasource_impl.dart b/lib/features/auth/data/datasources/impl/auth_http_datasource_impl.dart index 44693fce..30ee753f 100644 --- a/lib/features/auth/data/datasources/impl/auth_http_datasource_impl.dart +++ b/lib/features/auth/data/datasources/impl/auth_http_datasource_impl.dart @@ -1,4 +1,5 @@ import 'package:dio/dio.dart'; +import 'package:maibu_satabot_v2/core/consts/http_api_consts.dart'; import '../../models/user_model.dart'; import '../auth_http_datasource.dart'; @@ -11,7 +12,7 @@ class AuthHttpDataSourceImpl implements AuthHttpDataSource { @override Future login(String username, String password, sourceType) async { final response = await dio.post( - '/login', + HttpApiConsts.loginUrl, data: { 'username': username, 'password': password, @@ -19,11 +20,21 @@ class AuthHttpDataSourceImpl implements AuthHttpDataSource { }, ); - if (response.statusCode == 200) { - print('runtimeType = ${response.data.runtimeType}'); - return UserModel.fromJson(response.data); - } else { - throw Exception(response.data['message'] ?? 'Login failed'); + if (response.statusCode != 200) { + throw Exception('网络请求失败: ${response.statusCode}'); } + + final responseData = response.data; + + if (responseData['code'] != 200) { + throw Exception(responseData['msg'] ?? '业务异常'); + } + + final loginUsername = responseData['username']; + if (loginUsername == null || loginUsername == '') { + throw Exception('服务器返回的数据结构不完整(缺少用户信息)'); + } + var userModel = UserModel.fromJson(responseData); + return userModel; } } diff --git a/lib/features/auth/data/models/user_model.dart b/lib/features/auth/data/models/user_model.dart index 0d55891e..d37729bf 100644 --- a/lib/features/auth/data/models/user_model.dart +++ b/lib/features/auth/data/models/user_model.dart @@ -1,6 +1,8 @@ +import 'package:maibu_satabot_v2/core/data/base_model.dart'; + import '../../../../core/domain/entities/user_entity.dart'; -class UserModel extends UserEntity { +class UserModel extends UserEntity implements BaseModel { UserModel({ required super.userId, required super.username, diff --git a/lib/features/auth/data/repositories/auth_repository_impl.dart b/lib/features/auth/data/repositories/auth_repository_impl.dart index d3dca798..ffb3faf1 100644 --- a/lib/features/auth/data/repositories/auth_repository_impl.dart +++ b/lib/features/auth/data/repositories/auth_repository_impl.dart @@ -1,37 +1,40 @@ +import 'package:dio/dio.dart'; +import 'package:fpdart/fpdart.dart'; import 'package:maibu_satabot_v2/core/storage/user_storage.dart'; import '../../../../core/domain/entities/user_entity.dart'; +import '../../domain/errors/auth_failure.dart'; import '../../domain/repositories/auth_repository.dart'; import '../datasources/auth_http_datasource.dart'; class AuthRepositoryImpl implements AuthRepository { - final AuthHttpDataSource remote; + final AuthHttpDataSource httpRemote; final UserStorage localTokenStorage; - AuthRepositoryImpl({required this.remote, required this.localTokenStorage}); + AuthRepositoryImpl({ + required this.httpRemote, + required this.localTokenStorage, + }); @override - // 注意 1:返回类型应该是 Domain 层的 UserEntity,而不是 UserModel - Future login( + Future> login( String username, String password, int sourceType, ) async { try { // 1. 从数据源获取 Data Model - final userModel = await remote.login(username, password, sourceType); + final userModel = await httpRemote.login(username, password, sourceType); - // 2. 职责核心:将 Model 转换为 Entity (解耦业务与 JSON 结构) - if (userModel != null) { - await localTokenStorage.saveUser(userModel); - } + await localTokenStorage.saveUser(userModel); - // 2. 只返回纯粹的用户信息实体 - return userModel.toEntity(); + return Right(userModel.toEntity()); + } on DioException catch (e) { + final String serverMessage = e.response?.data['msg'] ?? "网络连接异常"; + return Left(AuthFailure(serverMessage)); } catch (e) { - // 3. 职责核心:异常翻译 - // 不直接抛出 Dio 异常,而是抛出业务层定义的自定义异常或返回 Failure - throw Exception(e.toString()); + final cleanMessage = e.toString().replaceFirst('Exception: ', ''); + return Left(AuthFailure(cleanMessage)); } } } diff --git a/lib/features/auth/domain/errors/auth_failure.dart b/lib/features/auth/domain/errors/auth_failure.dart new file mode 100644 index 00000000..c2b6701d --- /dev/null +++ b/lib/features/auth/domain/errors/auth_failure.dart @@ -0,0 +1,14 @@ +import 'package:maibu_satabot_v2/core/error/failure.dart'; + +class AuthFailure extends Failure implements Exception { + final int? code; + AuthFailure(super.message, {this.code}); +} + +class InvalidCredentialsFailure extends AuthFailure { + InvalidCredentialsFailure(String message) : super(message); +} + +class ServerFailure extends AuthFailure { + ServerFailure(String message, int code) : super(message, code: code); +} diff --git a/lib/features/auth/domain/repositories/auth_repository.dart b/lib/features/auth/domain/repositories/auth_repository.dart index d453578c..7f8415d6 100644 --- a/lib/features/auth/domain/repositories/auth_repository.dart +++ b/lib/features/auth/domain/repositories/auth_repository.dart @@ -1,5 +1,12 @@ +import 'package:fpdart/fpdart.dart'; + import '../../../../core/domain/entities/user_entity.dart'; +import '../errors/auth_failure.dart'; abstract class AuthRepository { - Future login(String username, String password, int sourceType); + Future> login( + String username, + String password, + int sourceType, + ); } diff --git a/lib/features/auth/domain/usecases/login_usecase.dart b/lib/features/auth/domain/usecases/login_usecase.dart index 392033dc..d81d8c10 100644 --- a/lib/features/auth/domain/usecases/login_usecase.dart +++ b/lib/features/auth/domain/usecases/login_usecase.dart @@ -1,5 +1,8 @@ +import 'package:fpdart/fpdart.dart'; + import '../../../../core/domain/entities/user_entity.dart'; import '../../../../core/domain/usecases/base_usecase.dart'; +import '../errors/auth_failure.dart'; import '../repositories/auth_repository.dart'; class LoginUseCase implements BaseUseCase { @@ -8,8 +11,8 @@ class LoginUseCase implements BaseUseCase { LoginUseCase(this.repository); @override - Future call(LoginParams params) { - return repository.login( + Future> call(LoginParams params) async { + return await repository.login( params.username, params.password, params.sourceType, diff --git a/lib/features/auth/presentation/bloc/auth_cubit.dart b/lib/features/auth/presentation/bloc/auth_cubit.dart index ad1ef644..75a66bc4 100644 --- a/lib/features/auth/presentation/bloc/auth_cubit.dart +++ b/lib/features/auth/presentation/bloc/auth_cubit.dart @@ -5,7 +5,7 @@ import 'package:maibu_satabot_v2/core/consts/tcp_consts.dart'; import 'package:maibu_satabot_v2/core/domain/entities/user_entity.dart'; import 'package:maibu_satabot_v2/core/network/net_message_dispatcher.dart'; -import '../../../../core/app/app_cubit.dart'; +import '../../../../core/app/app_user_cubit.dart'; import '../../../../core/network/tcp/tcp_client.dart'; import '../../../../core/storage/user_storage.dart'; import 'auth_state.dart'; @@ -17,7 +17,7 @@ import 'auth_state.dart'; class AuthCubit extends Cubit { final UserStorage storage; final TcpClient tcp; - final AppCubit appCubit; + final AppUserCubit appCubit; final NetMessageDispatcher dispatcher; StreamSubscription? _kickOutSub; // 新增:用于管理监听生命周期 diff --git a/lib/features/auth/presentation/bloc/login_bloc.dart b/lib/features/auth/presentation/bloc/login_bloc.dart index 71f8514d..dd95ed92 100644 --- a/lib/features/auth/presentation/bloc/login_bloc.dart +++ b/lib/features/auth/presentation/bloc/login_bloc.dart @@ -11,11 +11,13 @@ class LoginBloc extends Bloc { on((event, emit) async { emit(LoginLoading()); try { - final user = await loginUseCase( + final result = await loginUseCase( LoginParams(event.username, event.password, event.sourceType), ); - print('✅ Got user: $user'); - emit(LoginSuccess(user)); + result.fold( + (failure) => emit(LoginFailure(failure.message)), + (user) => emit(LoginSuccess(user)), + ); } catch (e) { emit(LoginFailure(e.toString())); } diff --git a/lib/features/auth/presentation/bloc/login_cubit.dart b/lib/features/auth/presentation/bloc/login_cubit.dart index 8d7c3658..f488a691 100644 --- a/lib/features/auth/presentation/bloc/login_cubit.dart +++ b/lib/features/auth/presentation/bloc/login_cubit.dart @@ -17,11 +17,13 @@ class LoginCubit extends Cubit { Future login(String username, String password, sourceType) async { emit(LoginLoading()); try { - final resultUser = await loginUseCase.call( + final result = await loginUseCase.call( LoginParams(username, password, sourceType), ); - authCubit.loginSuccess(resultUser); - emit(LoginSuccess(resultUser)); + result.fold((failure) => emit(LoginFailure(failure.message)), (user) { + emit(LoginSuccess(user)); + authCubit.loginSuccess(user); + }); } catch (e) { emit(LoginFailure(e.toString())); } diff --git a/lib/features/auth/presentation/pages/change_password_page.dart b/lib/features/auth/presentation/pages/change_password_page.dart index e69de29b..cfd093d2 100644 --- a/lib/features/auth/presentation/pages/change_password_page.dart +++ b/lib/features/auth/presentation/pages/change_password_page.dart @@ -0,0 +1,13 @@ +import 'package:flutter/material.dart'; + +class ChangePasswordPage extends StatelessWidget { + const ChangePasswordPage({super.key}); + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('ChangePwd')), + body: Center(child: Text('Welcome ChangePwd page')), + ); + } +} diff --git a/lib/features/auth/presentation/pages/login_page.dart b/lib/features/auth/presentation/pages/login_page.dart index 382e475f..e0385353 100644 --- a/lib/features/auth/presentation/pages/login_page.dart +++ b/lib/features/auth/presentation/pages/login_page.dart @@ -7,42 +7,259 @@ import '../../../../core/router/route_paths.dart'; import '../bloc/login_cubit.dart'; import '../bloc/login_state.dart'; -class LoginPage extends StatelessWidget { +class LoginPage extends StatefulWidget { + const LoginPage({super.key}); + + @override + State createState() => _LoginPageState(); +} + +class _LoginPageState extends State { final _userCtrl = TextEditingController(); final _pwdCtrl = TextEditingController(); + bool _isAgreed = false; // 协议勾选状态 + bool _obscurePwd = true; // 密码可见性 - LoginPage({super.key}); + @override + void dispose() { + _userCtrl.dispose(); + _pwdCtrl.dispose(); + super.dispose(); + } @override Widget build(BuildContext context) { return Scaffold( - appBar: AppBar(title: const Text('Login')), + backgroundColor: Colors.white, + // 保持 AppBar 简洁或直接去掉 + appBar: AppBar(backgroundColor: Colors.white, elevation: 0), body: BlocListener( listener: (context, state) { if (state is LoginSuccess) { - // Navigator.pushReplacement( - // context, - // MaterialPageRoute(builder: (_) => HomePage(user: state.user)), - // ); context.go(RoutePaths.home); + } else if (state is LoginFailure) { + // 可以在这里弹出简洁的黑白提示框 + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text('登录失败,${state.message}'))); } }, + child: SingleChildScrollView( + padding: const EdgeInsets.symmetric(horizontal: 30.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox(height: 40), + const Text( + "账号登录", + style: TextStyle( + fontSize: 28, + fontWeight: FontWeight.bold, + color: Colors.black, + ), + ), + const SizedBox(height: 12), + const Text( + "请填写以下信息以验证身份", + style: TextStyle(fontSize: 15, color: Colors.grey), + ), + const SizedBox(height: 40), + + // 账号输入框 + _buildInputLabel("账号"), + TextField( + controller: _userCtrl, + cursorColor: Colors.black, + decoration: _inputDecoration(hint: "请输入用户名"), + ), + + const SizedBox(height: 24), + + // 密码输入框 + _buildInputLabel("密码"), + TextField( + controller: _pwdCtrl, + obscureText: _obscurePwd, + cursorColor: Colors.black, + decoration: _inputDecoration( + hint: "请输入密码", + suffixIcon: IconButton( + icon: Icon( + _obscurePwd + ? Icons.visibility_off_outlined + : Icons.visibility_outlined, + color: Colors.grey, + size: 20, + ), + onPressed: () => setState(() => _obscurePwd = !_obscurePwd), + ), + ), + ), + + const SizedBox(height: 20), + + // 协议勾选区域 + _buildProtocolSection(), + + const SizedBox(height: 40), + + // 登录按钮 (使用你的 CCPrimaryButton 并增加状态控制) + SizedBox( + width: double.infinity, + child: BlocBuilder( + builder: (context, state) { + bool isLoading = state is LoginLoading; // 假设你有 Loading 状态 + + return CCPrimaryButton( + onPressed: () { + if (!_isAgreed) { + // 如果没有勾选协议,弹出提示 + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('请先阅读并同意用户协议')), + ); + return; + } + if (state is! LoginLoading) { + _handleLogin(); + } + }, + text: state is LoginLoading ? '登录中...' : '登 录', + ); + }, + ), + ), + ], + ), + ), + ), + ); + } + + // 执行登录逻辑 + void _handleLogin() { + context.read().login(_userCtrl.text, _pwdCtrl.text, 2); + } + + // 辅助组件:输入框标签 + Widget _buildInputLabel(String label) { + return Padding( + padding: const EdgeInsets.only(bottom: 8.0), + child: Text( + label, + style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w600), + ), + ); + } + + // 辅助组件:输入框装饰 + InputDecoration _inputDecoration({required String hint, Widget? suffixIcon}) { + return InputDecoration( + hintText: hint, + hintStyle: TextStyle(color: Colors.grey.shade400, fontSize: 14), + filled: true, + fillColor: Colors.grey.shade50, + contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: BorderSide(color: Colors.grey.shade200), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: const BorderSide(color: Colors.black, width: 1), + ), + suffixIcon: suffixIcon, + ); + } + + // 辅助组件:协议勾选 + Widget _buildProtocolSection() { + return Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + SizedBox( + height: 24, + width: 24, + child: Checkbox( + value: _isAgreed, + activeColor: Colors.black, + onChanged: (val) => setState(() => _isAgreed = val!), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(4), + ), + ), + ), + const SizedBox(width: 8), + Expanded( + child: Wrap( + children: [ + const Text( + "我已阅读并同意 ", + style: TextStyle(fontSize: 13, color: Colors.grey), + ), + _protocolText("《用户协议》", () => _showProtocolDetail("用户协议")), + const Text( + " 和 ", + style: TextStyle(fontSize: 13, color: Colors.grey), + ), + _protocolText("《隐私政策》", () => _showProtocolDetail("隐私政策")), + ], + ), + ), + ], + ); + } + + Widget _protocolText(String text, VoidCallback onTap) { + return GestureDetector( + onTap: onTap, + child: Text( + text, + style: const TextStyle( + fontSize: 13, + color: Colors.black, + fontWeight: FontWeight.bold, + ), + ), + ); + } + + void _showProtocolDetail(String title) { + showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.white, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(16)), + ), + builder: (context) => Container( + padding: const EdgeInsets.all(24), + height: MediaQuery.of(context).size.height * 0.7, child: Column( children: [ - TextField(controller: _userCtrl), - TextField(controller: _pwdCtrl, obscureText: true), - CCPrimaryButton( - onPressed: () { - // context.read().add( - // LoginSubmitted(_userCtrl.text, _pwdCtrl.text), - // ); - context.read().login( - _userCtrl.text, - _pwdCtrl.text, - 2, - ); - }, - text: 'Login', + Text( + title, + style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold), + ), + const Divider(height: 32), + Expanded( + child: SingleChildScrollView( + child: Text( + "此处放置您的${title}详细内容...\n" * 20, + style: const TextStyle(color: Colors.black87, height: 1.5), + ), + ), + ), + const SizedBox(height: 16), + SizedBox( + width: double.infinity, + child: BlocBuilder( + builder: (context, state) { + return CCPrimaryButton( + onPressed: () => Navigator.pop(context), + text: "我已了解", + ); + }, + ), ), ], ), diff --git a/lib/features/auth/presentation/pages/register_page.dart b/lib/features/auth/presentation/pages/register_page.dart index e69de29b..b1f3badf 100644 --- a/lib/features/auth/presentation/pages/register_page.dart +++ b/lib/features/auth/presentation/pages/register_page.dart @@ -0,0 +1,13 @@ +import 'package:flutter/material.dart'; + +class RegisterPage extends StatelessWidget { + const RegisterPage({super.key}); + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('Register')), + body: Center(child: Text('Welcome Register page')), + ); + } +} diff --git a/lib/features/auth/presentation/routes/auth_routes.dart b/lib/features/auth/presentation/routes/auth_routes.dart new file mode 100644 index 00000000..8dc121d6 --- /dev/null +++ b/lib/features/auth/presentation/routes/auth_routes.dart @@ -0,0 +1,19 @@ +import 'package:go_router/go_router.dart'; +import 'package:maibu_satabot_v2/core/router/route_paths.dart'; +import 'package:maibu_satabot_v2/features/auth/presentation/pages/login_page.dart'; + +import '../pages/register_page.dart'; + +class AuthRoutes { + // 返回一个 List + static List routes = [ + GoRoute( + path: RoutePaths.login, + builder: (context, state) => const LoginPage(), + ), + GoRoute( + path: RoutePaths.register, + builder: (context, state) => const RegisterPage(), + ), + ]; +} diff --git a/lib/features/devices/data/datasources/device_http_datasource.dart b/lib/features/devices/data/datasources/device_http_datasource.dart new file mode 100644 index 00000000..f11ae76e --- /dev/null +++ b/lib/features/devices/data/datasources/device_http_datasource.dart @@ -0,0 +1,8 @@ +import 'package:maibu_satabot_v2/features/devices/domain/entities/device_entity.dart'; + +abstract class DeviceHttpDatasource { + Future> getUserDevices(String username); + Future bindDevice(String deviceId, String deviceAlias); + Future unbindDevice(String deviceId); + Future switchDevice(String platform, String deviceId); +} diff --git a/lib/features/devices/data/datasources/impl/device_http_datasource_impl.dart b/lib/features/devices/data/datasources/impl/device_http_datasource_impl.dart new file mode 100644 index 00000000..334089cf --- /dev/null +++ b/lib/features/devices/data/datasources/impl/device_http_datasource_impl.dart @@ -0,0 +1,81 @@ +import 'package:dio/dio.dart'; +import 'package:maibu_satabot_v2/core/consts/http_api_consts.dart'; +import 'package:maibu_satabot_v2/features/devices/data/datasources/device_http_datasource.dart'; +import 'package:maibu_satabot_v2/features/devices/domain/entities/device_entity.dart'; + +class DeviceHttpDatasourceImpl implements DeviceHttpDatasource { + final Dio dio; + + DeviceHttpDatasourceImpl(this.dio); + + @override + Future bindDevice(String deviceId, String deviceAlias) async { + var response = await dio.post( + HttpApiConsts.bindDevice, + data: {'deviceId': deviceId, 'deviceAlias': deviceAlias}, + ); + if (response.statusCode != 200) { + throw Exception('网络请求失败:${response.statusCode}'); + } + + final responseData = response.data; + + if (responseData['code'] != 200 || responseData['data'] != true) { + throw Exception(responseData['msg'] ?? '业务异常'); + } + + return 1; + } + + @override + Future> getUserDevices(String username) async { + var response = await dio.get( + HttpApiConsts.getUserDevicesList, + queryParameters: {'tenantName': username}, + ); + if (response.statusCode != 200) { + throw Exception('网络请求失败:${response.statusCode}'); + } + + final responseData = response.data; + + if (responseData['code'] != 200) { + throw Exception(responseData['msg'] ?? '业务异常'); + } + + if (responseData['total'] == 0) { + return []; + } else { + List devices = []; + for (var item in responseData['rows']) { + devices.add(DeviceEntity.fromJson(item)); + } + return devices; + } + } + + @override + Future switchDevice(String platform, String deviceId) async { + var response = await dio.post( + HttpApiConsts.switchDevice, + data: {'platform': platform, 'deviceId': deviceId}, + ); + if (response.statusCode != 200) { + throw Exception('网络请求失败:${response.statusCode}'); + } + + final responseData = response.data; + + if (responseData['code'] != 200 || responseData['data'] != true) { + throw Exception(responseData['msg'] ?? '业务异常'); + } + + return 1; + } + + @override + Future unbindDevice(String deviceId) { + // TODO: implement unbindDevice + throw UnimplementedError(); + } +} diff --git a/lib/features/devices/data/models/device_model.dart b/lib/features/devices/data/models/device_model.dart new file mode 100644 index 00000000..2579882f --- /dev/null +++ b/lib/features/devices/data/models/device_model.dart @@ -0,0 +1,77 @@ +import 'package:maibu_satabot_v2/core/data/base_model.dart'; +import 'package:maibu_satabot_v2/features/devices/domain/entities/device_entity.dart'; + +class DeviceModel extends DeviceEntity implements BaseModel { + DeviceModel({ + required super.deviceName, + required super.productId, + required super.productName, + required super.tenantId, + required super.tenantName, + required super.status, + required super.activeTime, + required super.deviceAlias, + required super.isBind, + required super.onlineStatus, + }); + + factory DeviceModel.fromJson(Map json) { + return DeviceModel( + deviceName: json['deviceName'], + productId: json['productId'], + productName: json['productName'], + tenantId: json['tenantId'], + tenantName: json['tenantName'], + status: json['status'], + activeTime: json['activeTime'], + deviceAlias: json['deviceAlias'], + isBind: json['isBind'], + onlineStatus: json['onlineStatus'], + ); + } + + Map toJson() { + return { + 'deviceName': deviceName, + 'productId': productId, + 'productName': productName, + 'tenantId': tenantId, + 'tenantName': tenantName, + 'status': status, + 'activeTime': activeTime, + 'deviceAlias': deviceAlias, + 'isBind': isBind, + 'onlineStatus': onlineStatus, + }; + } + + toEntity() { + return DeviceEntity( + deviceName: deviceName, + productId: productId, + productName: productName, + tenantId: tenantId, + tenantName: tenantName, + status: status, + activeTime: activeTime, + deviceAlias: deviceAlias, + isBind: isBind, + onlineStatus: onlineStatus, + ); + } + + factory DeviceModel.fromEntity(DeviceEntity entity) { + return DeviceModel( + deviceName: entity.deviceName, + productId: entity.productId, + productName: entity.productName, + tenantId: entity.tenantId, + tenantName: entity.tenantName, + status: entity.status, + activeTime: entity.activeTime, + deviceAlias: entity.deviceAlias, + isBind: entity.isBind, + onlineStatus: entity.onlineStatus, + ); + } +} diff --git a/lib/features/devices/data/repositories/device_repository_impl.dart b/lib/features/devices/data/repositories/device_repository_impl.dart new file mode 100644 index 00000000..652da73b --- /dev/null +++ b/lib/features/devices/data/repositories/device_repository_impl.dart @@ -0,0 +1,72 @@ +import 'package:dio/dio.dart'; +import 'package:fpdart/src/either.dart'; +import 'package:maibu_satabot_v2/features/devices/data/datasources/device_http_datasource.dart'; +import 'package:maibu_satabot_v2/features/devices/domain/entities/device_entity.dart'; +import 'package:maibu_satabot_v2/features/devices/domain/errors/device_failure.dart'; + +import '../../domain/repositories/device_repository.dart'; + +class DeviceRepositoryImpl implements DeviceRepository { + DeviceHttpDatasource _deviceHttpDatasource; + + DeviceRepositoryImpl(this._deviceHttpDatasource); + + @override + Future> bindDevice( + String deviceId, + String deviceAlias, + ) async { + try { + var result = await _deviceHttpDatasource.bindDevice( + deviceId, + deviceAlias, + ); + return Right(result); + } on DioException catch (e) { + final String serverMessage = e.response?.data['msg'] ?? "网络连接异常"; + return Left(DeviceFailure(serverMessage)); + } catch (e) { + final cleanMessage = e.toString().replaceFirst('Exception: ', ''); + return Left(DeviceFailure(cleanMessage)); + } + } + + @override + Future>> getUserDevice( + String username, + ) async { + try { + var result = await _deviceHttpDatasource.getUserDevices(username); + return Right(result); + } on DioException catch (e) { + final String serverMessage = e.response?.data['msg'] ?? "网络连接异常"; + return Left(DeviceFailure(serverMessage)); + } catch (e) { + final cleanMessage = e.toString().replaceFirst('Exception: ', ''); + return Left(DeviceFailure(cleanMessage)); + } + } + + @override + Future> unBindDevice(String deviceId) { + // TODO: implement unBindDevice + throw UnimplementedError(); + } + + @override + Future> switchDevice( + String platform, + String deviceId, + ) async { + try { + var result = await _deviceHttpDatasource.switchDevice(platform, deviceId); + return Right(result); + } on DioException catch (e) { + final String serverMessage = e.response?.data['msg'] ?? "网络连接异常"; + return Left(DeviceFailure(serverMessage)); + } catch (e) { + final cleanMessage = e.toString().replaceFirst('Exception: ', ''); + return Left(DeviceFailure(cleanMessage)); + } + } +} diff --git a/lib/features/devices/domain/entities/device_entity.dart b/lib/features/devices/domain/entities/device_entity.dart new file mode 100644 index 00000000..7d160d4a --- /dev/null +++ b/lib/features/devices/domain/entities/device_entity.dart @@ -0,0 +1,65 @@ +import 'package:equatable/equatable.dart'; + +class DeviceEntity extends Equatable { + final String deviceName; + final int productId; + final String productName; + final int tenantId; + final String tenantName; + final int status; + final String? activeTime; + final String? deviceAlias; + final int isBind; // 是否被绑定过 + final int onlineStatus; // 在线状态 + + DeviceEntity({ + required this.deviceName, + required this.productId, + required this.productName, + required this.tenantId, + required this.tenantName, + this.status = 0, + this.activeTime, + this.deviceAlias, + this.isBind = 0, + this.onlineStatus = 0, + }); + + // 方便从后端 JSON 转换 + factory DeviceEntity.fromJson(Map json) { + return DeviceEntity( + deviceName: json['deviceName'], + productId: json['productId'] ?? -1, + productName: json['productName'] ?? "割草机产品MC700", + tenantId: json['tenantId'], + tenantName: json['tenantName'], + status: json['status'] ?? 0, + activeTime: json['activeTime'], + deviceAlias: json['deviceAlias'], + isBind: json['isBind'] ?? 0, + onlineStatus: json['onlineStatus'] ?? 0, + ); + } + + // 获取显示的名称(优先别名,没有则用设备名) + String get displayName => (deviceAlias != null && deviceAlias!.isNotEmpty) + ? deviceAlias! + : (deviceName ?? "未知设备"); + + // 辅助方法:判断是否在线 + bool get isOnline => onlineStatus == 1; + + @override + List get props => [ + deviceName, + productId, + productName, + tenantId, + tenantName, + status, + activeTime, + deviceAlias, + isBind, + onlineStatus, + ]; +} diff --git a/lib/features/devices/domain/errors/device_failure.dart b/lib/features/devices/domain/errors/device_failure.dart new file mode 100644 index 00000000..4978aa76 --- /dev/null +++ b/lib/features/devices/domain/errors/device_failure.dart @@ -0,0 +1,18 @@ +import '../../../../core/error/failure.dart'; + +class DeviceFailure extends Failure implements Exception { + final int? code; + DeviceFailure(super.message, {this.code}); +} + +class DeviceNotConnectedFailure extends DeviceFailure { + DeviceNotConnectedFailure(super.message); +} + +class DeviceOfflineFailure extends DeviceFailure { + DeviceOfflineFailure(super.message); +} + +class DeviceNotBoundFailure extends DeviceFailure { + DeviceNotBoundFailure(super.message); +} diff --git a/lib/features/devices/domain/repositories/device_repository.dart b/lib/features/devices/domain/repositories/device_repository.dart new file mode 100644 index 00000000..c963e6d0 --- /dev/null +++ b/lib/features/devices/domain/repositories/device_repository.dart @@ -0,0 +1,18 @@ +import 'package:fpdart/fpdart.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'; + +abstract class DeviceRepository { + Future>> getUserDevice( + String userName, + ); + Future> bindDevice( + String deviceId, + String deviceAlias, + ); + Future> unBindDevice(String deviceId); + Future> switchDevice( + String platform, + String deviceId, + ); +} diff --git a/lib/features/devices/domain/usecases/bind_device_usecase.dart b/lib/features/devices/domain/usecases/bind_device_usecase.dart new file mode 100644 index 00000000..fc4723c2 --- /dev/null +++ b/lib/features/devices/domain/usecases/bind_device_usecase.dart @@ -0,0 +1,25 @@ +import 'package:fpdart/fpdart.dart'; +import 'package:maibu_satabot_v2/core/domain/usecases/base_usecase.dart'; +import 'package:maibu_satabot_v2/features/devices/domain/errors/device_failure.dart'; +import 'package:maibu_satabot_v2/features/devices/domain/repositories/device_repository.dart'; + +class BindDeviceUseCase implements BaseUseCase { + final DeviceRepository deviceRepository; + + BindDeviceUseCase(this.deviceRepository); + + @override + Future> call(BindDeviceParams params) async { + return await deviceRepository.bindDevice( + params.deviceId, + params.deviceAlias, + ); + } +} + +class BindDeviceParams { + final String deviceId; + final String deviceAlias; + + BindDeviceParams(this.deviceId, this.deviceAlias); +} diff --git a/lib/features/devices/domain/usecases/get_user_device_usecase.dart b/lib/features/devices/domain/usecases/get_user_device_usecase.dart new file mode 100644 index 00000000..85dc5b2b --- /dev/null +++ b/lib/features/devices/domain/usecases/get_user_device_usecase.dart @@ -0,0 +1,25 @@ +import 'package:fpdart/fpdart.dart'; +import 'package:maibu_satabot_v2/core/domain/usecases/base_usecase.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/repositories/device_repository.dart'; + +class GetUserDeviceUseCase + implements BaseUseCase, GetUserDeviceParams> { + final DeviceRepository repository; + + GetUserDeviceUseCase(this.repository); + + @override + Future>> call( + GetUserDeviceParams params, + ) async { + return await repository.getUserDevice(params.tenantName); + } +} + +class GetUserDeviceParams { + final String tenantName; + + GetUserDeviceParams(this.tenantName); +} diff --git a/lib/features/devices/domain/usecases/switch_device_usecase.dart b/lib/features/devices/domain/usecases/switch_device_usecase.dart new file mode 100644 index 00000000..179a086e --- /dev/null +++ b/lib/features/devices/domain/usecases/switch_device_usecase.dart @@ -0,0 +1,25 @@ +import 'package:fpdart/fpdart.dart'; +import 'package:maibu_satabot_v2/core/domain/usecases/base_usecase.dart'; +import 'package:maibu_satabot_v2/features/devices/domain/errors/device_failure.dart'; +import 'package:maibu_satabot_v2/features/devices/domain/repositories/device_repository.dart'; + +class SwitchDeviceUseCase implements BaseUseCase { + final DeviceRepository deviceRepository; + + SwitchDeviceUseCase(this.deviceRepository); + + @override + Future> call(SwitchDeviceParams params) async { + return await deviceRepository.switchDevice( + params.platform, + params.deviceId, + ); + } +} + +class SwitchDeviceParams { + final String platform; + final String deviceId; + + SwitchDeviceParams(this.platform, this.deviceId); +} diff --git a/lib/features/devices/domain/usecases/unbind_device_usecase.dart b/lib/features/devices/domain/usecases/unbind_device_usecase.dart new file mode 100644 index 00000000..5fce4d75 --- /dev/null +++ b/lib/features/devices/domain/usecases/unbind_device_usecase.dart @@ -0,0 +1,21 @@ +import 'package:fpdart/fpdart.dart'; +import 'package:maibu_satabot_v2/core/domain/usecases/base_usecase.dart'; +import 'package:maibu_satabot_v2/core/error/failure.dart'; +import 'package:maibu_satabot_v2/features/devices/domain/repositories/device_repository.dart'; + +class UnbindDeviceUseCase implements BaseUseCase { + final DeviceRepository repository; + + UnbindDeviceUseCase(this.repository); + + @override + Future> call(UnbindDeviceParams params) async { + return await repository.unBindDevice(params.deviceId); + } +} + +class UnbindDeviceParams { + final String deviceId; + + UnbindDeviceParams(this.deviceId); +} diff --git a/lib/features/devices/presentation/bloc/devices_cubit.dart b/lib/features/devices/presentation/bloc/devices_cubit.dart new file mode 100644 index 00000000..c29bf8ea --- /dev/null +++ b/lib/features/devices/presentation/bloc/devices_cubit.dart @@ -0,0 +1,78 @@ +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:maibu_satabot_v2/features/devices/domain/entities/device_entity.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 'devices_state.dart'; + +class DevicesCubit extends Cubit { + final GetUserDeviceUseCase _getUserDeviceUseCase; + + final DeviceRepository repository; + + DevicesCubit(this.repository, this._getUserDeviceUseCase) + : super(const DevicesState()); + + // 获取所有设备列表 + Future fetchAllDevices(String username) async { + emit(state.copyWith(isLoading: true)); + try { + // 模拟网络请求获取列表 + var resultEither = await _getUserDeviceUseCase.call( + GetUserDeviceParams(username), + ); + + resultEither.fold( + (failure) => emit( + state.copyWith( + isLoading: false, + errorMessage: failure.message, // 假设你的 Failure 类有 message 字段 + ), + ), + (deviceList) { + emit( + state.copyWith( + devices: deviceList, + selectedDevice: deviceList.isNotEmpty ? deviceList.first : null, + isLoading: false, + ), + ); + }, + ); + } catch (e) { + emit(state.copyWith(isLoading: false, errorMessage: e.toString())); + } + } + + // 切换当前选中的设备 + void selectDevice(DeviceEntity device) { + emit(state.copyWith(selectedDevice: device)); + } + + // 更新单个设备的状态(例如从 Tcp 收到实时电量更新) + void updateDeviceStatus(DeviceEntity updatedDevice) { + final newList = state.devices.map((d) { + return d.deviceName == updatedDevice.deviceName ? updatedDevice : d; + }).toList(); + + // 如果更新的是当前选中的设备,也要同步更新 selectedDevice + final newSelected = + state.selectedDevice?.deviceName == updatedDevice.deviceName + ? updatedDevice + : state.selectedDevice; + + emit(state.copyWith(devices: newList, selectedDevice: newSelected)); + } + + Future switchDevice(DeviceEntity device) async { + // 保持现有列表,只改 loading + emit(state.copyWith(isLoading: true)); + + final result = await repository.switchDevice("app", device.deviceName); + + result.fold((l) { + emit(state.copyWith(isLoading: false, errorMessage: l.message)); + selectDevice(device); + }, (r) => emit(state.copyWith(isLoading: false, selectedDevice: device))); + } +} diff --git a/lib/features/devices/presentation/bloc/devices_state.dart b/lib/features/devices/presentation/bloc/devices_state.dart new file mode 100644 index 00000000..331f243c --- /dev/null +++ b/lib/features/devices/presentation/bloc/devices_state.dart @@ -0,0 +1,34 @@ +import 'package:equatable/equatable.dart'; +import 'package:maibu_satabot_v2/features/devices/domain/entities/device_entity.dart'; + +class DevicesState extends Equatable { + final List devices; // 名下所有设备列表 + final DeviceEntity? selectedDevice; // 当前主界面选中的/操作的设备 + final bool isLoading; // 是否正在加载 + final String? errorMessage; // 错误信息 + + const DevicesState({ + this.devices = const [], + this.selectedDevice, + this.isLoading = false, + this.errorMessage, + }); + + // 使用 copyWith 方便局部更新状态 + DevicesState copyWith({ + List? devices, + DeviceEntity? selectedDevice, + bool? isLoading, + String? errorMessage, + }) { + return DevicesState( + devices: devices ?? this.devices, + selectedDevice: selectedDevice ?? this.selectedDevice, + isLoading: isLoading ?? this.isLoading, + errorMessage: errorMessage, // 错误信息通常每次更新都要重新赋值或清空 + ); + } + + @override + List get props => [devices, selectedDevice, isLoading, errorMessage]; +} diff --git a/lib/features/home/presentation/pages/home_page.dart b/lib/features/home/presentation/pages/home_page.dart index 5e26a0af..bfe4f9b1 100644 --- a/lib/features/home/presentation/pages/home_page.dart +++ b/lib/features/home/presentation/pages/home_page.dart @@ -1,18 +1,44 @@ import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:maibu_satabot_v2/core/app/app_user_cubit.dart'; +import 'package:maibu_satabot_v2/features/devices/presentation/bloc/devices_cubit.dart'; -import '../../../../core/app/app_cubit.dart'; -import '../../../../core/di/injection.dart'; +import '../widgets/ImmersionHeader.dart'; +import '../widgets/quick_actions_grid.dart'; +import '../widgets/work_params_card.dart'; class HomePage extends StatelessWidget { - // final User user; - // const HomePage({super.key, required this.user}); + const HomePage({super.key}); @override Widget build(BuildContext context) { - final user = sl().state.user; + // 同时监听用户和设备状态 + final userState = context.watch().state; + final deviceState = context.watch().state; + final currentDevice = deviceState.selectedDevice; + + if (currentDevice == null) + return const Scaffold(body: Center(child: Text("加载中..."))); + return Scaffold( - appBar: AppBar(title: const Text('Home')), - body: Center(child: Text('Welcome ${user?.username}')), + backgroundColor: const Color(0xFFF7F7F7), + body: CustomScrollView( + physics: const BouncingScrollPhysics(), + slivers: [ + // 1. 沉浸式顶部展示区 + SliverToBoxAdapter(child: ImmersionHeader(device: currentDevice)), + + // 2. 功能网格(保持原有逻辑,但背景建议改为纯白) + const SliverToBoxAdapter(child: QuickActionsGrid()), + + // 3. 作业参数卡片 + SliverToBoxAdapter(child: WorkParamsCard()), + + // 4. 地图区域 + // SliverToBoxAdapter(child: _buildMapSection()), + const SliverToBoxAdapter(child: SizedBox(height: 120)), + ], + ), ); } } diff --git a/lib/features/home/presentation/pages/route_plan_page.dart b/lib/features/home/presentation/pages/route_plan_page.dart new file mode 100644 index 00000000..9734c69a --- /dev/null +++ b/lib/features/home/presentation/pages/route_plan_page.dart @@ -0,0 +1,28 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +import '../../../../core/app/app_user_cubit.dart'; +import '../../../devices/presentation/bloc/devices_cubit.dart'; + +class RoutePlanPage extends StatelessWidget { + const RoutePlanPage({super.key}); + + @override + Widget build(BuildContext context) { + // 同时监听用户和设备状态 + final userState = context.read().state; + final deviceState = context.read().state; + final currentDevice = deviceState.selectedDevice; + + if (currentDevice == null) + return const Scaffold(body: Center(child: Text("加载中..."))); + + return Scaffold( + backgroundColor: const Color(0xFFF7F7F7), + body: CustomScrollView( + physics: const BouncingScrollPhysics(), + slivers: [], + ), + ); + } +} diff --git a/lib/features/home/presentation/pages/running_status_page.dart b/lib/features/home/presentation/pages/running_status_page.dart new file mode 100644 index 00000000..302620aa --- /dev/null +++ b/lib/features/home/presentation/pages/running_status_page.dart @@ -0,0 +1,28 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +import '../../../../core/app/app_user_cubit.dart'; +import '../../../devices/presentation/bloc/devices_cubit.dart'; + +class RunningStatusPage extends StatelessWidget { + const RunningStatusPage({super.key}); + + @override + Widget build(BuildContext context) { + // 同时监听用户和设备状态 + final userState = context.read().state; + final deviceState = context.read().state; + final currentDevice = deviceState.selectedDevice; + + if (currentDevice == null) + return const Scaffold(body: Center(child: Text("加载中..."))); + + return Scaffold( + backgroundColor: const Color(0xFFF7F7F7), + body: CustomScrollView( + physics: const BouncingScrollPhysics(), + slivers: [], + ), + ); + } +} diff --git a/lib/features/home/presentation/routes/home_routes.dart b/lib/features/home/presentation/routes/home_routes.dart new file mode 100644 index 00000000..c7d697d8 --- /dev/null +++ b/lib/features/home/presentation/routes/home_routes.dart @@ -0,0 +1,44 @@ +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:go_router/go_router.dart'; +import 'package:maibu_satabot_v2/features/home/presentation/pages/home_page.dart'; + +import '../../../../core/di/injection.dart'; +import '../../../../core/router/route_paths.dart'; +import '../../../remote_control/presentation/bloc/remote_control_cubit.dart'; +import '../../../remote_control/presentation/pages/remote_control_page.dart'; +import '../pages/route_plan_page.dart'; +import '../pages/running_status_page.dart'; + +class HomeRoutes { + /// 1. 全屏功能页面(不带导航栏) + /// 在 createRouter 的根 routes 中使用 ...HomeRoutes.routes 引入 + static List get routes => [ + GoRoute( + path: RoutePaths.remoteControl, + builder: (context, state) => BlocProvider( + // 每次进入该路由,都会创建一个全新的 Cubit 并开启循环 + create: (context) => sl()..startControlLoop(), + child: const RemoteControlPage(), + ), + ), + GoRoute( + path: RoutePaths.routePlan, + builder: (context, state) => const RoutePlanPage(), + ), + GoRoute( + path: RoutePaths.runningStatus, + builder: (context, state) => const RunningStatusPage(), + ), + ]; + + /// 2. 首页 Tab 分支(带导航栏) + /// 仅保留真正的首页入口 + static StatefulShellBranch get branch => StatefulShellBranch( + routes: [ + GoRoute( + path: RoutePaths.home, + builder: (context, state) => const HomePage(), + ), + ], + ); +} diff --git a/lib/features/home/presentation/widgets/ImmersionHeader.dart b/lib/features/home/presentation/widgets/ImmersionHeader.dart new file mode 100644 index 00000000..633fc029 --- /dev/null +++ b/lib/features/home/presentation/widgets/ImmersionHeader.dart @@ -0,0 +1,410 @@ +import 'package:cc_ui_kit/cc_ui_kit.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:google_fonts/google_fonts.dart'; +import 'package:maibu_satabot_v2/features/devices/domain/entities/device_entity.dart'; + +import '../../../../core/app/app_user_cubit.dart'; +import '../../../devices/presentation/bloc/devices_cubit.dart'; +import '../../../devices/presentation/bloc/devices_state.dart'; +// 导入你的主题文件以获取 offWhite +// import 'package:maibu_satabot_v2/core/theme/app_theme.dart'; + +class ImmersionHeader extends StatelessWidget { + final DeviceEntity device; + const ImmersionHeader({super.key, required this.device}); + + @override + Widget build(BuildContext context) { + return Container( + width: double.infinity, + height: 380, + // 1. 修改这里:去掉 color: Colors.white,改用透明或背景色 + decoration: const BoxDecoration( + color: Colors.transparent, // 设为透明,直接透出 Scaffold 的 offWhite + ), + child: Stack( + children: [ + // 背景文字占位 (SataBot) + Positioned( + top: 140, + left: 0, + right: 0, + child: Text( + 'SataBot', + textAlign: TextAlign.center, + style: GoogleFonts.roboto( + fontSize: 100, + fontWeight: FontWeight.w900, + // 2. 既然背景变深了,背景字可以稍微再淡一点点 + color: Colors.black.withOpacity(0.08), + ), + ), + ), + + // 机器大图 + Transform.translate( + offset: const Offset(20, 90), // 正数向右移动(例如 20 像素),负数向左 + child: Center( + child: Image.asset( + 'assets/images/car.png', + width: 380, + fit: BoxFit.contain, + ), + ), + ), + + // 顶部状态信息 + SafeArea( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 10), + child: Row( + // 1. 依然保持顶部对齐 + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + // --- 左侧:标题 + 进度条 + 状态标签 (封装成一个 Column) --- + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, // 尽可能收缩高度 + children: [ + // 设备名称和电量 + Text( + device.displayName, + style: GoogleFonts.roboto( + fontSize: 22, + fontWeight: FontWeight.w900, + ), + ), + const SizedBox(height: 4), + Row( + children: [ + Text( + '${device.onlineStatus == 1 ? "100" : "--"} %', + style: GoogleFonts.roboto( + fontSize: 24, + fontWeight: FontWeight.w900, + ), + ), + const Icon( + Icons.chevron_right, + size: 20, + color: Colors.grey, + ), + ], + ), + + const SizedBox(height: 8), + + // 2. 进度条现在紧跟在电量下面,不会被右侧挤走 + _buildProgressBar(), + + const SizedBox(height: 12), + + // 3. 状态标签也紧跟其后 + _buildStatusTag(device.isOnline), + ], + ), + ), + + // --- 右侧:图标栏 --- + Column( + mainAxisSize: MainAxisSize.min, + children: [ + _buildCircleIcon(Icons.sync, () { + // 1. 触发 Cubit 请求最新设备列表 + // 假设你的 username 存储在 AuthCubit 或类似的全局状态中 + final username = + context.read().state.user?.username ?? + ""; + context.read().fetchAllDevices(username); + + // 2. 弹出窗口(窗口内部会根据状态显示转圈或列表) + _showDeviceSwitcher(context); + }), + const SizedBox(height: 20), // 这里你改大的间距,只会让两个图标拉开 + _buildCircleIcon(Icons.bluetooth, null, isAccent: true), + ], + ), + ], + ), + ), + ), + ], + ), + ); + } + + Widget _buildStatusTag(bool isOnline) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), + decoration: BoxDecoration( + // 在 offWhite 背景下,标签用纯白色会显得更精致 + color: Colors.white, + borderRadius: BorderRadius.circular(20), + boxShadow: [ + BoxShadow(color: Colors.black.withOpacity(0.02), blurRadius: 4), + ], + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + CircleAvatar( + radius: 3, + backgroundColor: isOnline ? Colors.green : Colors.grey, + ), + const SizedBox(width: 6), + Text( + isOnline ? "在线" : "离线", + style: const TextStyle(fontSize: 12, color: Colors.black54), + ), + ], + ), + ); + } + + // 辅助方法:进度条 + Widget _buildProgressBar() { + return Container( + width: 120, + height: 6, + decoration: BoxDecoration( + color: Colors.blueAccent, + borderRadius: BorderRadius.circular(5), + ), + ); + } + + Widget _buildCircleIcon( + IconData icon, + VoidCallback? voidCallback, { // 将回调函数放在这里 + bool isAccent = false, + }) { + return GestureDetector( + onTap: voidCallback, // 绑定点击事件 + child: Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + shape: BoxShape.circle, + color: isAccent ? Colors.blue.withOpacity(0.1) : Colors.white, + boxShadow: [ + if (!isAccent) + BoxShadow( + color: Colors.black.withOpacity(0.02), + blurRadius: 4, + offset: const Offset(0, 2), // 稍微增加偏移感 + ), + ], + ), + child: Icon( + icon, + size: 30, + color: isAccent ? Colors.blue : Colors.black54, + ), + ), + ); + } + + /// 设备切换窗口 + void _showDeviceSwitcher(BuildContext context) { + showModalBottomSheet( + context: context, + isScrollControlled: true, + useRootNavigator: true, // 💡 解决被三段导航栏遮挡的问题 + backgroundColor: Colors.transparent, + builder: (modalContext) { + return Container( + height: MediaQuery.of(context).size.height * 0.75, + decoration: const BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.vertical(top: Radius.circular(20)), + ), + child: Column( + children: [ + // 优化的控制条 + Container( + margin: const EdgeInsets.symmetric(vertical: 12), + width: 36, + height: 5, + decoration: BoxDecoration( + color: Colors.grey[300], + borderRadius: BorderRadius.circular(10), + ), + ), + const Text( + "切换设备", + style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold), + ), + const SizedBox(height: 10), + Container( + height: 0.5, + margin: const EdgeInsets.symmetric(horizontal: 20), + color: Colors.grey.withOpacity(0.1), + ), + + // 💡 动态内容区 + Expanded( + child: BlocBuilder( + builder: (context, state) { + // 1. 如果正在加载(查询或切换中),显示转圈 + if (state.isLoading) { + return Center( + child: Column( + crossAxisAlignment: CrossAxisAlignment.center, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const CircularProgressIndicator(color: Colors.blue), + const SizedBox(height: 16), + Text( + "处理中...", + style: TextStyle(color: Colors.grey[600]), + ), + ], + ), + ); + } + + // 2. 列表展示 + if (state.devices.isEmpty) { + return const Center(child: Text("暂无可用设备")); + } + + return ListView.builder( + // 💡 底部留出安全距离,防止最后一条滚不上来 + padding: EdgeInsets.only( + bottom: MediaQuery.of(context).padding.bottom + 20, + top: 10, + ), + itemCount: state.devices.length, + itemBuilder: (context, index) { + // 传入当前选中的 ID 方便做 UI 区分 + final isSelected = + state.selectedDevice?.deviceName == + state.devices[index].deviceName; + return _buildDeviceItem( + state.devices[index], + context, + isSelected, + ); + }, + ); + }, + ), + ), + ], + ), + ); + }, + ); + } + + Widget _buildDeviceItem( + DeviceEntity device, + BuildContext context, + bool isSelected, + ) { + return GestureDetector( + onTap: () { + print("跳转到详情页: ${device.deviceAlias}"); + // TODO: Navigator.push(...) + }, + child: Container( + margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Colors.white, + // 💡 如果是当前选中设备,边框颜色略深 + border: Border.all( + color: isSelected + ? Colors.blue.withOpacity(0.5) + : Colors.grey.withOpacity(0.3), + width: isSelected ? 1.5 : 1, + ), + borderRadius: BorderRadius.circular(12), + boxShadow: [ + if (isSelected) + BoxShadow(color: Colors.blue.withOpacity(0.05), blurRadius: 4), + ], + ), + child: Row( + children: [ + // 设备图片 + Transform.translate( + offset: const Offset(10, 0), + child: Image.asset( + 'assets/images/car.png', + width: 100, + height: 80, + ), + ), + const SizedBox(width: 12), + // 设备信息 + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + Icons.circle, + color: device.isOnline ? Colors.green : Colors.grey, + size: 12, + ), + const SizedBox(width: 4), + Text( + device.deviceAlias ?? '未知设备', + style: const TextStyle(fontWeight: FontWeight.bold), + ), + ], + ), + const Text( + "点击查看详情", + style: TextStyle(color: Colors.grey, fontSize: 12), + ), + ], + ), + ), + // 按钮组 + Column( + children: [ + CCPrimaryImageButton( + onPressed: isSelected + ? null + : () async { + // 💡 执行切换逻辑 + await context.read().switchDevice( + device, + ); + // 切换成功后,UI 会自动更新(因为 BlocBuilder 在监听),我们可以关闭弹窗 + if (context.mounted) { + Navigator.pop(context); + } + }, + text: isSelected ? "使用中" : "切换", + width: 80, + height: 30, + fontSize: 14, + // 如果是当前设备,按钮颜色变灰 + backgroundColor: isSelected ? Colors.grey : Colors.black, + ), + const SizedBox(height: 8), + CCPrimaryButton( + onPressed: () { + print("通过按钮进入详情"); + }, + text: "详情", + width: 80, + height: 30, + fontSize: 14, + backgroundColor: Colors.white, + textColor: Colors.black, + ), + ], + ), + ], + ), + ), + ); + } +} diff --git a/lib/features/home/presentation/widgets/device_status_card.dart b/lib/features/home/presentation/widgets/device_status_card.dart new file mode 100644 index 00000000..80a62714 --- /dev/null +++ b/lib/features/home/presentation/widgets/device_status_card.dart @@ -0,0 +1,86 @@ +import 'package:flutter/material.dart'; + +class DeviceStatusCard extends StatelessWidget { + const DeviceStatusCard({super.key}); + + @override + Widget build(BuildContext context) { + return Container( + margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(28), + ), + child: Row( + children: [ + // 设备图片/占位 + ClipRRect( + borderRadius: BorderRadius.circular(16), // 保持和原代码一致的圆角 + child: Transform.translate( + offset: const Offset(10, 0), // 向左偏移10像素,Y轴不变 + child: Image.asset( + 'assets/images/car.png', + width: 120, + height: 80, + fit: BoxFit.cover, // 确保图片填满80x80的区域 + ), + ), + ), + const SizedBox(width: 16), + // 信息区 + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const Text( + '未知设备', + style: TextStyle( + fontSize: 20, + fontWeight: FontWeight.bold, + ), + ), + Icon(Icons.swap_horiz, color: Colors.grey[400]), + ], + ), + const SizedBox(height: 8), + Row( + children: [ + _buildTag('离线', Colors.grey[400]!), + const Spacer(), + const Icon(Icons.battery_3_bar_rounded, size: 18), + const Text( + ' 0%', + style: TextStyle(fontWeight: FontWeight.w500), + ), + ], + ), + ], + ), + ), + ], + ), + ); + } + + Widget _buildTag(String text, Color color) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), + decoration: BoxDecoration( + color: color.withOpacity(0.2), + borderRadius: BorderRadius.circular(20), + ), + child: Text( + text, + style: TextStyle( + color: color, + fontSize: 12, + fontWeight: FontWeight.bold, + ), + ), + ); + } +} diff --git a/lib/features/home/presentation/widgets/quick_actions_grid.dart b/lib/features/home/presentation/widgets/quick_actions_grid.dart new file mode 100644 index 00000000..96a6b042 --- /dev/null +++ b/lib/features/home/presentation/widgets/quick_actions_grid.dart @@ -0,0 +1,82 @@ +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; +import 'package:maibu_satabot_v2/core/router/route_paths.dart'; + +class QuickActionsGrid extends StatelessWidget { + const QuickActionsGrid({super.key}); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: Row( + children: [ + _buildActionItem( + icon: Icons.videogame_asset_outlined, + label: '远程遥控', + iconColor: Colors.blue, + onTap: () => context.push(RoutePaths.remoteControl), + ), + _buildActionItem( + icon: Icons.near_me_outlined, + label: '路径规划', + iconColor: Colors.purple, + onTap: () => context.push(RoutePaths.routePlan), + ), + _buildActionItem( + icon: Icons.insights_rounded, + label: '机器状态', + iconColor: Colors.orange, + onTap: () => context.push(RoutePaths.runningStatus), + ), + ], + ), + ); + } + + // 💡 改造后的构建函数,支持命名参数和点击事件 + Widget _buildActionItem({ + required IconData icon, + required String label, + required Color iconColor, + required VoidCallback onTap, + }) { + return Expanded( + child: Container( + margin: const EdgeInsets.symmetric(horizontal: 4), + // 💡 使用 Material 和 InkWell 组合来实现点击效果 + child: Material( + color: Colors.white, + borderRadius: BorderRadius.circular(20), + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(20), // 确保水波纹不超出圆角 + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 16), + child: Column( + children: [ + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: iconColor.withOpacity(0.1), + borderRadius: BorderRadius.circular(16), + ), + child: Icon(icon, color: iconColor), + ), + const SizedBox(height: 8), + Text( + label, + style: const TextStyle( + fontSize: 13, + fontWeight: FontWeight.w500, + ), + ), + ], + ), + ), + ), + ), + ), + ); + } +} diff --git a/lib/features/home/presentation/widgets/work_params_card.dart b/lib/features/home/presentation/widgets/work_params_card.dart new file mode 100644 index 00000000..73720169 --- /dev/null +++ b/lib/features/home/presentation/widgets/work_params_card.dart @@ -0,0 +1,147 @@ +import 'package:flutter/material.dart'; + +class WorkParamsCard extends StatelessWidget { + const WorkParamsCard({super.key}); + + @override + Widget build(BuildContext context) { + return Container( + margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(24), + // 极淡的阴影 + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.02), + blurRadius: 10, + offset: const Offset(0, 4), + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // 标题行 + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Row( + children: [ + // 左侧蓝色装饰条 + Container( + width: 4, + height: 16, + decoration: BoxDecoration( + color: Colors.blueAccent, + borderRadius: BorderRadius.circular(2), + ), + ), + const SizedBox(width: 8), + const Text( + '作业参数', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + color: Colors.black, + ), + ), + ], + ), + // 刷新状态 + Row( + children: [ + Icon(Icons.refresh, size: 14, color: Colors.grey[400]), + const SizedBox(width: 4), + Text( + '刚刚更新', + style: TextStyle(fontSize: 12, color: Colors.grey[400]), + ), + ], + ), + ], + ), + const SizedBox(height: 24), + + // 数据展示行 + IntrinsicHeight( + // 关键:使分割线高度自动充满 + child: Row( + children: [ + _buildParamItem('作业面积', '0', '亩'), + _buildDivider(), + _buildParamItem('作业里程', '0', 'km'), + _buildDivider(), + _buildParamItem('作业时长', '0', 'h'), + ], + ), + ), + ], + ), + ); + } + + // 构建单个参数项 + Widget _buildParamItem(String label, String value, String unit) { + return Expanded( + child: Column( + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + label == '作业面积' + ? Icons.aspect_ratio + : label == '作业里程' + ? Icons.local_shipping_outlined + : Icons.access_time, + size: 14, + color: Colors.black38, + ), + const SizedBox(width: 4), + Text( + label, + style: const TextStyle(fontSize: 12, color: Colors.black38), + ), + ], + ), + const SizedBox(height: 12), + RichText( + text: TextSpan( + children: [ + TextSpan( + text: value, + style: const TextStyle( + fontSize: 28, + fontWeight: FontWeight.w700, + color: Colors.black, + fontFamily: 'Inter', // 建议使用数字显示更漂亮的字体 + ), + ), + TextSpan( + text: ' $unit', + style: const TextStyle( + fontSize: 12, + fontWeight: FontWeight.normal, + color: Colors.black54, + ), + ), + ], + ), + ), + ], + ), + ); + } + + // 垂直分割线 + Widget _buildDivider() { + return VerticalDivider( + color: Colors.black.withOpacity(0.05), + thickness: 1, + indent: 10, + endIndent: 10, + ); + } +} diff --git a/lib/features/main_container/presentation/main_wrapper.dart b/lib/features/main_container/presentation/main_wrapper.dart new file mode 100644 index 00000000..9f9db71c --- /dev/null +++ b/lib/features/main_container/presentation/main_wrapper.dart @@ -0,0 +1,84 @@ +import 'dart:ui'; + +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; +import 'package:google_nav_bar/google_nav_bar.dart'; + +class MainWrapper extends StatelessWidget { + final StatefulNavigationShell navigationShell; + + const MainWrapper({super.key, required this.navigationShell}); + + @override + Widget build(BuildContext context) { + return Scaffold( + extendBody: true, // 必须开启,让内容流过导航栏下方 + body: navigationShell, + bottomNavigationBar: Container( + margin: const EdgeInsets.fromLTRB(20, 0, 20, 32), // 稍微调高底部间距 + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(32), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.12), // 强化阴影,增加悬浮感 + blurRadius: 30, + offset: const Offset(0, 10), + ), + ], + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(32), + child: BackdropFilter( + filter: ImageFilter.blur(sigmaX: 16, sigmaY: 16), // 增加模糊半径 + child: Container( + decoration: BoxDecoration( + // 降低白色透明度到 0.4,减少“灰蒙蒙”的白雾感,让背景颜色更亮地透出来 + color: Colors.white.withOpacity(0.4), + borderRadius: BorderRadius.circular(32), + border: Border.all( + // 使用极细的深色半透明边框勾勒轮廓 + color: Colors.black.withOpacity(0.08), + width: 0.5, + ), + ), + child: SafeArea( + top: false, + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 10, + vertical: 8, + ), + child: GNav( + rippleColor: Colors.transparent, + hoverColor: Colors.transparent, + gap: 10, + // 【关键修改】选中状态:纯黑背景 + 纯白图标/文字 + activeColor: Colors.white, + tabBackgroundColor: Colors.black, + // 未选中状态:深黑色 + color: Colors.black87, + iconSize: 24, + padding: const EdgeInsets.symmetric( + horizontal: 18, + vertical: 10, + ), + duration: const Duration(milliseconds: 300), + selectedIndex: navigationShell.currentIndex, + onTabChange: (index) { + navigationShell.goBranch(index); + }, + tabs: const [ + GButton(icon: Icons.grid_view_rounded, text: '状态'), + GButton(icon: Icons.auto_awesome_rounded, text: 'AI'), + GButton(icon: Icons.person_rounded, text: '我的'), + ], + ), + ), + ), + ), + ), + ), + ), + ); + } +} diff --git a/lib/features/my/presentation/pages/my_page.dart b/lib/features/my/presentation/pages/my_page.dart new file mode 100644 index 00000000..d05eb0af --- /dev/null +++ b/lib/features/my/presentation/pages/my_page.dart @@ -0,0 +1,10 @@ +import 'package:flutter/material.dart'; + +class MyPage extends StatelessWidget { + const MyPage({super.key}); + + @override + Widget build(BuildContext context) { + return Scaffold(body: Center(child: Text('Welcome My page'))); + } +} diff --git a/lib/features/my/presentation/routes/my_routes.dart b/lib/features/my/presentation/routes/my_routes.dart new file mode 100644 index 00000000..f5b5ef10 --- /dev/null +++ b/lib/features/my/presentation/routes/my_routes.dart @@ -0,0 +1,31 @@ +import 'package:go_router/go_router.dart'; +import 'package:maibu_satabot_v2/features/my/presentation/pages/my_page.dart'; + +import '../../../../core/router/route_paths.dart'; + +class MyRoutes { + /// 1. 全屏功能页面(不带导航栏) + /// 在 createRouter 的根 routes 中使用 ...HomeRoutes.routes 引入 + static List get routes => [ + // GoRoute( + // path: RoutePaths.remoteControl, + // builder: (context, state) => const RemoteControlPage(), // 需导入对应 Page + // ), + // GoRoute( + // path: RoutePaths.routePlan, + // builder: (context, state) => const RoutePlanPage(), + // ), + // GoRoute( + // path: RoutePaths.runningStatus, + // builder: (context, state) => const RunningStatusPage(), + // ), + ]; + + /// 2. 首页 Tab 分支(带导航栏) + /// 仅保留真正的首页入口 + static StatefulShellBranch get branch => StatefulShellBranch( + routes: [ + GoRoute(path: RoutePaths.my, builder: (context, state) => const MyPage()), + ], + ); +} diff --git a/lib/features/register/presentation/pages/register_page.dart b/lib/features/register/presentation/pages/register_page.dart deleted file mode 100644 index df5b4a05..00000000 --- a/lib/features/register/presentation/pages/register_page.dart +++ /dev/null @@ -1,15 +0,0 @@ -import 'package:flutter/material.dart'; - -class RegisterPage extends StatelessWidget { - // final User user; - - const RegisterPage({super.key}); - - @override - Widget build(BuildContext context) { - return Scaffold( - appBar: AppBar(title: const Text('Home')), - body: Center(child: Text('Welcome Register')), - ); - } -} diff --git a/lib/features/remote_control/data/datasources/remote_http_datasource.dart b/lib/features/remote_control/data/datasources/remote_http_datasource.dart new file mode 100644 index 00000000..4e18af21 --- /dev/null +++ b/lib/features/remote_control/data/datasources/remote_http_datasource.dart @@ -0,0 +1,20 @@ +import 'package:dio/dio.dart'; + +class RemoteHttpDatasource { + final Dio _dio; + + RemoteHttpDatasource(this._dio); + + /// 示例:获取控制授权 (对应之前逻辑中的权限申请) + Future> requestControlAuth(String robotId) async { + try { + final response = await _dio.post( + '/robot/auth/request', + data: {'robotId': robotId}, + ); + return response.data; + } catch (e) { + rethrow; + } + } +} diff --git a/lib/features/remote_control/data/datasources/remote_tcp_datasource.dart b/lib/features/remote_control/data/datasources/remote_tcp_datasource.dart new file mode 100644 index 00000000..e69de29b diff --git a/lib/features/remote_control/data/models/lawn_mover_protocol_model.dart b/lib/features/remote_control/data/models/lawn_mover_protocol_model.dart new file mode 100644 index 00000000..9d0b230c --- /dev/null +++ b/lib/features/remote_control/data/models/lawn_mover_protocol_model.dart @@ -0,0 +1,30 @@ +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, + ); +} diff --git a/lib/features/remote_control/data/repositories/remote_control_repository_impl.dart b/lib/features/remote_control/data/repositories/remote_control_repository_impl.dart new file mode 100644 index 00000000..ec9626b5 --- /dev/null +++ b/lib/features/remote_control/data/repositories/remote_control_repository_impl.dart @@ -0,0 +1,47 @@ +import 'dart:async'; + +import '../../../../core/network/protocol_decoder.dart'; +import '../../../../core/network/tcp/tcp_client.dart'; +import '../../../../core/protocol/machine_protocol_codec.dart'; +import '../../../../core/protocol/machine_protocol_constants.dart'; +import '../../domain/entities/machine_control_status_entity.dart'; +import '../../domain/repositories/remote_control_repository.dart'; +import '../../domain/usecase/diff_steer_usecase.dart'; + +class RemoteControlRepositoryImpl implements RemoteControlRepository { + final TcpClient _tcpClient; + final DiffSteerUseCase _diffSteer; + + RemoteControlRepositoryImpl(this._tcpClient, this._diffSteer); + + @override + void changeWebViewDirection(String direction) { + // TODO: implement changeWebViewDirection + } + + @override + void sendControlMachineCmd(MachineControlStatusEntity status) { + // 1. 调用算法:将摇杆坐标 (x, y) 转换为左右轮电机转速 + final speeds = _diffSteer.calculate(status.originX, status.originY); + + // 2. 调用 Codec:仅生成协议要求的 8 字节 Payload 负载数据 + final payload = MachineProtocolCodec.encodeRemoteControlPayload( + left: speeds['left']!, + right: speeds['right']!, + lift: status.chassisLift, + mower: status.mowerSpeed, + ignition: status.ignitionStatus, + emergency: status.isEmergency ? 1 : 0, + ); + + // 3. 调用 TcpClient:发送指令。 + // TcpClient.sendRaw 会自动帮你加上 [0xAB, 0xAA] 头和 [0xAA, 0xAB] 尾 + _tcpClient.sendRaw( + MachineProtocolConstants.cmdRemoteControl, // 这里通常是 0x00 + payload, + ); + } + + @override + Stream get responseStream => _tcpClient.packetStream; +} diff --git a/lib/features/remote_control/domain/entities/control_state_entity.dart b/lib/features/remote_control/domain/entities/control_state_entity.dart new file mode 100644 index 00000000..e69de29b diff --git a/lib/features/remote_control/domain/entities/machine_control_status_entity.dart b/lib/features/remote_control/domain/entities/machine_control_status_entity.dart new file mode 100644 index 00000000..2dc061ba --- /dev/null +++ b/lib/features/remote_control/domain/entities/machine_control_status_entity.dart @@ -0,0 +1,35 @@ +class MachineControlStatusEntity { + final int originX; + final int originY; + final int chassisLift; // 0:停, 1:上, 2:下 + final int mowerSpeed; // 0:停, 1:+, 2:- + final int ignitionStatus; // 0:停, 1:火, 2:熄 + final bool isEmergency; + + MachineControlStatusEntity({ + this.originX = 0, + this.originY = 0, + this.chassisLift = 0, + this.mowerSpeed = 0, + this.ignitionStatus = 0, + this.isEmergency = false, + }); + + MachineControlStatusEntity copyWith({ + int? x, + int? y, + int? lift, + int? mower, + int? ignition, + bool? emergency, + }) { + return MachineControlStatusEntity( + originX: x ?? originX, + originY: y ?? originY, + chassisLift: lift ?? chassisLift, + mowerSpeed: mower ?? mowerSpeed, + ignitionStatus: ignition ?? ignitionStatus, + isEmergency: emergency ?? isEmergency, + ); + } +} diff --git a/lib/features/remote_control/domain/repositories/remote_control_repository.dart b/lib/features/remote_control/domain/repositories/remote_control_repository.dart new file mode 100644 index 00000000..4bc0ab55 --- /dev/null +++ b/lib/features/remote_control/domain/repositories/remote_control_repository.dart @@ -0,0 +1,15 @@ +import '../../../../core/network/protocol_decoder.dart'; +import '../../domain/entities/machine_control_status_entity.dart'; + +abstract class RemoteControlRepository { + /// 高频发送遥控指令 (被 Cubit 的 100ms 定时器调用) + /// 负责将 Entity 转换为底层的 0x00 指令并发出 + void sendControlMachineCmd(MachineControlStatusEntity status); + + /// 暴露解析后的回包流 (如 0x12 权限申请) + /// UI 或 Cubit 监听此流来处理机器人主动推送的消息 + Stream get responseStream; + + /// WebView 视角控制 (逻辑层面的切换,不涉及 TCP) + void changeWebViewDirection(String direction); +} diff --git a/lib/features/remote_control/domain/usecase/diff_steer_usecase.dart b/lib/features/remote_control/domain/usecase/diff_steer_usecase.dart new file mode 100644 index 00000000..e9d92a4d --- /dev/null +++ b/lib/features/remote_control/domain/usecase/diff_steer_usecase.dart @@ -0,0 +1,32 @@ +class DiffSteerUseCase { + static const int polarityHigh = 0; + static const int polarityLow = 1; + + double _turnSpeedScale = 1500 / 100; + double _forwardSpeedScale = 3000 / 100; + int _speedAmplLimit = 3000; + + int _leftWheelDir = polarityHigh; + int _rightWheelDir = polarityHigh; + int _xDir = polarityHigh; + int _yDir = polarityHigh; + + Map calculate(int x, int y) { + int turnSpeed = _signOperator((x * _turnSpeedScale).toInt(), _xDir); + int forwardSpeed = _signOperator((y * _forwardSpeedScale).toInt(), _yDir); + + // 倒车补偿 + int adjustedTurn = (forwardSpeed >= 0 ? turnSpeed : -turnSpeed); + + int left = _signOperator(forwardSpeed + adjustedTurn, _leftWheelDir); + int right = _signOperator(forwardSpeed - adjustedTurn, _rightWheelDir); + + return { + 'left': left.clamp(-_speedAmplLimit, _speedAmplLimit), + 'right': right.clamp(-_speedAmplLimit, _speedAmplLimit), + }; + } + + int _signOperator(int num, int polarity) => + polarity != polarityHigh ? -num : num; +} diff --git a/lib/features/remote_control/presentation/bloc/remote_control_cubit.dart b/lib/features/remote_control/presentation/bloc/remote_control_cubit.dart new file mode 100644 index 00000000..179f9047 --- /dev/null +++ b/lib/features/remote_control/presentation/bloc/remote_control_cubit.dart @@ -0,0 +1,92 @@ +import 'dart:async'; + +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:maibu_satabot_v2/features/remote_control/presentation/bloc/remote_control_state.dart'; + +import '../../domain/entities/machine_control_status_entity.dart'; +import '../../domain/repositories/remote_control_repository.dart'; + +class RemoteControlCubit extends Cubit { + final RemoteControlRepository _repository; + Timer? _timer; + + RemoteControlCubit(this._repository) + : super(RemoteControlState(controlEntity: MachineControlStatusEntity())) { + _initPacketListener(); + } + + // 1. 初始化回包监听 (如 0x12 权限) + void _initPacketListener() { + _repository.responseStream.listen((packet) { + if (packet.command == 0x12) { + // 根据负载判断是否有权限,更新状态 + emit(state.copyWith(hasPermission: true)); + } + }); + } + + // 2. 开启 100ms 控制循环 (在进入遥控页面或点击“开始”时调用) + void startControlLoop() { + _timer?.cancel(); + _timer = Timer.periodic(const Duration(milliseconds: 100), (timer) { + // 核心调用:直接把 state 里的实体丢给 repository + _repository.sendControlMachineCmd(state.controlEntity); + }); + emit(state.copyWith(status: RemoteControlStatus.controlling)); + } + + // 3. 更新摇杆数据 + void updateJoystick(double x, double y) { + final updatedEntity = state.controlEntity.copyWith( + x: x.toInt(), + y: y.toInt(), + ); + emit(state.copyWith(controlEntity: updatedEntity)); + } + + // 4. 更新功能开关 (比如割刀速度、灯光、点火等) + void updateFunction({int? mower, int? lift, int? ignition, bool? emergency}) { + final updatedEntity = state.controlEntity.copyWith( + mower: mower, + lift: lift, + ignition: ignition, + emergency: emergency, + ); + emit(state.copyWith(controlEntity: updatedEntity)); + } + + void updateOriginY(int y) {} + + // 5. 停止控制循环 + void stopControlLoop() { + _timer?.cancel(); + _timer = null; + emit(state.copyWith(status: RemoteControlStatus.initial)); + } + + void toggleLock() {} + + void togglePermissionDialog(bool show) { + emit(state.copyWith(showPermissionRequestDialog: show)); + } + + void requestControlPermission() { + // 1. 关闭弹窗 + emit(state.copyWith(showPermissionRequestDialog: false)); + + // 2. 这里执行你发送 0x12 指令的逻辑 + // _sendProtocolData(0x12, ...); + } + + @override + Future close() { + _timer?.cancel(); // 退出页面时务必销毁定时器 + return super.close(); + } + + void updateChassisLift(int i) {} + + void updateEmergency(bool bool) {} + + void respondPermission(bool bool) {} +} diff --git a/lib/features/remote_control/presentation/bloc/remote_control_state.dart b/lib/features/remote_control/presentation/bloc/remote_control_state.dart new file mode 100644 index 00000000..82612b4c --- /dev/null +++ b/lib/features/remote_control/presentation/bloc/remote_control_state.dart @@ -0,0 +1,78 @@ +import 'package:equatable/equatable.dart'; + +import '../../domain/entities/machine_control_status_entity.dart'; + +enum RemoteControlStatus { initial, controlling, error } + +class RemoteControlState extends Equatable { + final RemoteControlStatus status; + final MachineControlStatusEntity controlEntity; // 之前的业务实体 + final String? errorMessage; + final bool hasPermission; // 是否获得了 0x12 权限 + final bool isEmergency; + final bool isLocked; + final int ping; + final int battery; + final String permissionPlatform; + final String currentPlatform; + final bool showPermissionRequestDialog; + + const RemoteControlState({ + this.status = RemoteControlStatus.initial, + required this.controlEntity, + this.errorMessage, + this.hasPermission = false, + this.isEmergency = false, + this.isLocked = false, + this.ping = 0, + this.battery = 0, + this.permissionPlatform = '', + this.currentPlatform = '', + this.showPermissionRequestDialog = false, + }); + + // 方便 UI 更新部分属性 + RemoteControlState copyWith({ + RemoteControlStatus? status, + MachineControlStatusEntity? controlEntity, + String? errorMessage, + bool? hasPermission, + bool? isEmergency, + bool? isLocked, + int? ping, + int? battery, + String? permissionPlatform, + String? currentPlatform, + bool? showPermissionRequestDialog, + }) { + return RemoteControlState( + status: status ?? this.status, + controlEntity: controlEntity ?? this.controlEntity, + errorMessage: errorMessage ?? this.errorMessage, + hasPermission: hasPermission ?? this.hasPermission, + isEmergency: isEmergency ?? this.isEmergency, + isLocked: isLocked ?? this.isLocked, + ping: ping ?? this.ping, + battery: battery ?? this.battery, + permissionPlatform: permissionPlatform ?? this.permissionPlatform, + currentPlatform: currentPlatform ?? this.currentPlatform, + showPermissionRequestDialog: + showPermissionRequestDialog ?? this.showPermissionRequestDialog, + ); + } + + @override + List get props => [ + status, + controlEntity, + errorMessage, + hasPermission, + isEmergency, + isLocked, + ping, + battery, + permissionPlatform, + currentPlatform, + showPermissionRequestDialog, + ]; +} diff --git a/lib/features/remote_control/presentation/pages/remote_control_page.dart b/lib/features/remote_control/presentation/pages/remote_control_page.dart new file mode 100644 index 00000000..9b10ff0f --- /dev/null +++ b/lib/features/remote_control/presentation/pages/remote_control_page.dart @@ -0,0 +1,167 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:go_router/go_router.dart'; +import 'package:maibu_satabot_v2/core/consts/tcp_consts.dart'; + +import '../../../../core/app/app_user_cubit.dart'; +import '../../../devices/presentation/bloc/devices_cubit.dart'; +import '../bloc/remote_control_cubit.dart'; +import '../bloc/remote_control_state.dart'; +import '../widgets/center_control_area.dart'; +import '../widgets/emergency_overlay.dart'; +import '../widgets/left_joystick_area.dart'; +import '../widgets/my_video_player.dart'; +import '../widgets/right_joystick_area.dart'; +import '../widgets/top_status_bar.dart'; // 假设路径 + +class RemoteControlPage extends StatefulWidget { + const RemoteControlPage({super.key}); + + @override + State createState() => _RemoteControlPageState(); +} + +class _RemoteControlPageState extends State { + @override + void initState() { + super.initState(); + // 强制横屏与沉浸式 + SystemChrome.setPreferredOrientations([ + DeviceOrientation.landscapeLeft, + DeviceOrientation.landscapeRight, + ]); + SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky); + } + + @override + void dispose() { + // 恢复竖屏 + SystemChrome.setPreferredOrientations([DeviceOrientation.portraitUp]); + SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + // 1. 监听全局状态 (实现方案二:掉线实时刷新) + final userState = context.watch().state; + final deviceState = context.watch().state; + final currentDevice = deviceState.selectedDevice; + + // 2. 监听局部遥控状态 (对应 Android Compose 的 collectAsState) + final remoteState = context.watch().state; + + // 如果设备突然掉线,显示遮罩 (对应 Android 的 finishEvent 逻辑) + if (currentDevice == null /*|| !currentDevice.isOnline*/ ) { + return _buildOfflineScaffold(); + } + + return Scaffold( + backgroundColor: Colors.black, + body: Stack( + children: [ + // 底层:视频流 (传入 device 和 user) + Positioned.fill( + child: MyVideoPlayer( + deviceIp: TCPConsts.TCP_IP, + device: currentDevice, + user: userState.user!, // 假设 AppUserCubit 存有 user 实体 + ), + ), + + // 中层:急停呼吸灯光晕 (对应 Android 的 EmergencyBreathingOverlay) + if (remoteState.isEmergency) + const Positioned.fill(child: EmergencyOverlay()), + + // 顶层:UI 控制层 + SafeArea( + child: Column( + children: [ + // 顶部胶囊状态条 + const TopStatusBar(), + + // 左右摇杆及中间控制区 + Expanded( + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 20, + vertical: 10, + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + // 左摇杆:前后控制 + LeftJoystickArea(isLocked: remoteState.isLocked), + + // 中间区:底盘升降/急停按钮 + const CenterControlArea(), + + // 右摇杆:左右控制 + RightJoystickArea(isLocked: remoteState.isLocked), + ], + ), + ), + ), + ], + ), + ), + + // 权限请求弹窗 (对应 Android 的 PermissionRequestDialog) + if (remoteState.showPermissionRequestDialog) + _buildPermissionDialog(context, remoteState), + ], + ), + ); + } + + Widget _buildOfflineScaffold() { + return Scaffold( + backgroundColor: Colors.black, + body: Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.signal_wifi_off, color: Colors.white, size: 60), + const SizedBox(height: 20), + const Text( + "设备已断开连接", + style: TextStyle(color: Colors.white, fontSize: 18), + ), + const SizedBox(height: 20), + ElevatedButton( + onPressed: () => context.pop(), + child: const Text("返回"), + ), + ], + ), + ), + ); + } + + Widget _buildPermissionDialog( + BuildContext context, + RemoteControlState state, + ) { + return Container( + color: Colors.black54, + child: AlertDialog( + title: const Text("权限变更"), + content: Text("${state.permissionPlatform}端正请求控制权,同意释放吗?"), + actions: [ + TextButton( + onPressed: () => + context.read().respondPermission(false), + child: const Text("拒绝"), + ), + TextButton( + onPressed: () => + context.read().respondPermission(true), + child: const Text("同意"), + ), + ], + ), + ); + } +} diff --git a/lib/features/remote_control/presentation/widgets/center_control_area.dart b/lib/features/remote_control/presentation/widgets/center_control_area.dart new file mode 100644 index 00000000..74e45a08 --- /dev/null +++ b/lib/features/remote_control/presentation/widgets/center_control_area.dart @@ -0,0 +1,93 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +import '../bloc/remote_control_cubit.dart'; +import 'emergency_stop_button.dart'; +import 'middle_expandslider.dart'; + +class CenterControlArea extends StatelessWidget { + const CenterControlArea({super.key}); + + @override + Widget build(BuildContext context) { + final remoteState = context.watch().state; + + // 如果处于急停激活状态,根据 Compose 逻辑,中间只显示急停状态 + if (remoteState.isEmergency) { + return const Column( + mainAxisSize: MainAxisSize.min, + children: [ + EmergencyStopButton(), // 刚才写的带 3 秒解除逻辑的按钮 + SizedBox(height: 16), + ], + ); + } + + return Container( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Row( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + // 左推杆:底盘升降 (对应 Compose 的第一个 MiddleExpandSlider) + _buildVerticalSlider( + label: "底盘", + onTop: () => + context.read().updateChassisLift(1), + onMiddle: () => + context.read().updateChassisLift(0), + onBottom: () => + context.read().updateChassisLift(2), + iconTop: Icons.expand_less, + iconMiddle: Icons.layers, + iconBottom: Icons.expand_more, + ), + + const SizedBox(width: 24), + + // 中间:急停按钮 + const EmergencyStopButton(), + + const SizedBox(width: 24), + + // 右推杆:备用/其他 (对应 Compose 的第二个 MiddleExpandSlider) + _buildVerticalSlider( + label: "云台", + onTop: () {}, // 预留接口 + onMiddle: () {}, + onBottom: () {}, + iconTop: Icons.keyboard_arrow_up, + iconMiddle: Icons.videocam, + iconBottom: Icons.keyboard_arrow_down, + ), + ], + ), + ); + } + + // 快捷构建推杆的方法 + Widget _buildVerticalSlider({ + required String label, + required VoidCallback onTop, + required VoidCallback onMiddle, + required VoidCallback onBottom, + required IconData iconTop, + required IconData iconMiddle, + required IconData iconBottom, + }) { + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + MiddleExpandSlider( + onTop: onTop, + onMiddle: onMiddle, + onBottom: onBottom, + iconTop: iconTop, + iconMiddle: iconMiddle, + iconBottom: iconBottom, + ), + const SizedBox(height: 8), + Text(label, style: const TextStyle(color: Colors.grey, fontSize: 12)), + ], + ); + } +} diff --git a/lib/features/remote_control/presentation/widgets/emergency_overlay.dart b/lib/features/remote_control/presentation/widgets/emergency_overlay.dart new file mode 100644 index 00000000..3235736d --- /dev/null +++ b/lib/features/remote_control/presentation/widgets/emergency_overlay.dart @@ -0,0 +1,63 @@ +import 'package:flutter/material.dart'; + +class EmergencyOverlay extends StatefulWidget { + const EmergencyOverlay({super.key}); + + @override + State createState() => _EmergencyOverlayState(); +} + +class _EmergencyOverlayState extends State + with SingleTickerProviderStateMixin { + late AnimationController _controller; + late Animation _opacityAnimation; + + @override + void initState() { + super.initState(); + // 设置动画循环时间,例如 600ms 闪烁一次 + _controller = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 600), + )..repeat(reverse: true); // 反转运行实现呼吸效果 + + _opacityAnimation = Tween( + begin: 0.0, + end: 0.5, + ).animate(CurvedAnimation(parent: _controller, curve: Curves.easeInOut)); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return AnimatedBuilder( + animation: _opacityAnimation, + builder: (context, child) { + return IgnorePointer( + // 极其重要:确保光晕不遮挡下方的点击事件 + child: Container( + decoration: BoxDecoration( + // 使用径向渐变,让四周红,中间透明 + border: Border.all( + color: Colors.red.withOpacity(_opacityAnimation.value), + width: 20, // 边框宽度决定了红边的厚度 + ), + boxShadow: [ + BoxShadow( + color: Colors.red.withOpacity(_opacityAnimation.value), + blurRadius: 40, + spreadRadius: 10, + ), + ], + ), + ), + ); + }, + ); + } +} diff --git a/lib/features/remote_control/presentation/widgets/emergency_stop_button.dart b/lib/features/remote_control/presentation/widgets/emergency_stop_button.dart new file mode 100644 index 00000000..c2f38383 --- /dev/null +++ b/lib/features/remote_control/presentation/widgets/emergency_stop_button.dart @@ -0,0 +1,131 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +import '../bloc/remote_control_cubit.dart'; + +class EmergencyStopButton extends StatefulWidget { + const EmergencyStopButton({super.key}); + + @override + State createState() => _EmergencyStopButtonState(); +} + +class _EmergencyStopButtonState extends State + with TickerProviderStateMixin { + late AnimationController _progressController; + bool _isPressing = false; + + @override + void initState() { + super.initState(); + // 对应 Compose 中的 durationMs = 3000L + _progressController = AnimationController( + vsync: this, + duration: const Duration(seconds: 3), + ); + } + + @override + void dispose() { + _progressController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + // 监听 Cubit 中的急停状态 + final isEmergencyActive = context + .watch() + .state + .isEmergency; + + return GestureDetector( + // 1. 处理点击:仅在未急停时触发 + onTap: () { + if (!isEmergencyActive) { + context.read().updateEmergency(true); + HapticFeedback.heavyImpact(); // 震动反馈 + } + }, + // 2. 处理长按开始:仅在已急停时触发解除逻辑 + onLongPressStart: (_) { + if (isEmergencyActive) { + setState(() => _isPressing = true); + _progressController.forward(from: 0).then((_) { + if (_isPressing) { + // 进度走完且仍在按压 + context.read().updateEmergency(false); + HapticFeedback.vibrate(); + setState(() => _isPressing = false); + } + }); + } + }, + // 3. 处理松手:重置进度 + onLongPressEnd: (_) { + _isPressing = false; + _progressController.stop(); + _progressController.value = 0; + setState(() {}); + }, + child: Stack( + alignment: Alignment.center, + children: [ + // 对应 Compose 的 Canvas 绘制进度环 + if (_isPressing) + SizedBox( + width: 130, + height: 130, + child: CircularProgressIndicator( + value: _progressController.value, + strokeWidth: 10, + color: Colors.red.withOpacity(0.8), + backgroundColor: Colors.red.withOpacity(0.2), + ), + ), + + // 按钮本体 (对应 Android 的 120.dp Box) + Container( + width: 120, + height: 120, + decoration: BoxDecoration( + shape: BoxShape.circle, + // 根据状态切换颜色,对应 Color(0x80C53030) + color: isEmergencyActive + ? Colors.red.withOpacity(0.5) + : const Color(0x80C53030), + boxShadow: isEmergencyActive + ? [ + BoxShadow( + color: Colors.red.withOpacity(0.5), + blurRadius: 20, + ), + ] + : [], + ), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon( + Icons.report_problem_outlined, // 对应 remote_alert 图标 + color: Colors.white, + size: 45, + ), + const SizedBox(height: 6), + Text( + isEmergencyActive ? "急停中" : "急停", + style: const TextStyle( + color: Colors.white, + fontSize: 14, + fontWeight: FontWeight.w900, + ), + ), + ], + ), + ), + ], + ), + ); + } +} diff --git a/lib/features/remote_control/presentation/widgets/left_joystick_area.dart b/lib/features/remote_control/presentation/widgets/left_joystick_area.dart new file mode 100644 index 00000000..d920e3af --- /dev/null +++ b/lib/features/remote_control/presentation/widgets/left_joystick_area.dart @@ -0,0 +1,50 @@ +import 'package:cc_ui_kit/cc_ui_kit.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +import '../bloc/remote_control_cubit.dart'; + +class LeftJoystickArea extends StatelessWidget { + final bool isLocked; + + const LeftJoystickArea({super.key, required this.isLocked}); + + @override + Widget build(BuildContext context) { + return Column( + mainAxisSize: MainAxisSize.min, // 紧凑布局 + children: [ + // 使用 IgnorePointer 处理锁定逻辑,对应 Android 的 isLocked 判断 + IgnorePointer( + ignoring: isLocked, + child: AnimatedOpacity( + duration: const Duration(milliseconds: 300), + opacity: isLocked ? 0.3 : 1.0, // 锁定后变透明 + child: CCJoystick( + radius: 100, // 对应 size(200.dp) + axisHint: AxisHint.forwardBackward, + onValueChanged: (value) { + // 对应 viewModel.updateOriginY(y) + context.read().updateOriginY(value.y); + }, + onPress: () { + // 对应 VibrateOnce(current, 100) + HapticFeedback.mediumImpact(); + }, + ), + ), + ), + const SizedBox(height: 8), + const Text( + "前后控制", + style: TextStyle( + color: Colors.grey, + fontSize: 12, + fontWeight: FontWeight.w500, + ), + ), + ], + ); + } +} diff --git a/lib/features/remote_control/presentation/widgets/middle_expandslider.dart b/lib/features/remote_control/presentation/widgets/middle_expandslider.dart new file mode 100644 index 00000000..3a462fdb --- /dev/null +++ b/lib/features/remote_control/presentation/widgets/middle_expandslider.dart @@ -0,0 +1,132 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +class MiddleExpandSlider extends StatefulWidget { + final VoidCallback onTop; // 对应 Compose 的 onTop (例如:升) + final VoidCallback onMiddle; // 对应 Compose 的 onMiddle (例如:停止/复位) + final VoidCallback onBottom; // 对应 Compose 的 onBottom (例如:降) + final IconData iconTop; + final IconData iconMiddle; + final IconData iconBottom; + + const MiddleExpandSlider({ + super.key, + required this.onTop, + required this.onMiddle, + required this.onBottom, + required this.iconTop, + required this.iconMiddle, + required this.iconBottom, + }); + + @override + State createState() => _MiddleExpandSliderState(); +} + +class _MiddleExpandSliderState extends State { + // 0: Top, 1: Middle, 2: Bottom + int _currentIndex = 1; + + // 处理滑动更新逻辑 + void _handleDragUpdate(DragUpdateDetails details, double maxHeight) { + // 将 120 的高度分为三等份 + double localY = details.localPosition.dy; + int newIndex; + + if (localY < maxHeight / 3) { + newIndex = 0; + } else if (localY > (maxHeight / 3) * 2) { + newIndex = 2; + } else { + newIndex = 1; + } + + if (newIndex != _currentIndex) { + setState(() => _currentIndex = newIndex); + // 触发对应的指令回调 + if (_currentIndex == 0) widget.onTop(); + if (_currentIndex == 1) widget.onMiddle(); + if (_currentIndex == 2) widget.onBottom(); + + // 触感反馈:对应 Android 的 VibrateOnce(current, 20) + HapticFeedback.lightImpact(); + } + } + + // 对应 Compose 的松手回弹逻辑 + void _handleDragEnd() { + if (_currentIndex != 1) { + setState(() => _currentIndex = 1); + widget.onMiddle(); // 回到中间,停止动作 + HapticFeedback.selectionClick(); + } + } + + @override + Widget build(BuildContext context) { + const double sliderHeight = 120.0; + const double sliderWidth = 45.0; + + return GestureDetector( + onVerticalDragUpdate: (details) => + _handleDragUpdate(details, sliderHeight), + onVerticalDragEnd: (_) => _handleDragEnd(), + child: Container( + width: sliderWidth, + height: sliderHeight, + decoration: BoxDecoration( + color: Colors.grey.withOpacity( + 0.2, + ), // 对应 Color.Gray.copy(alpha = 0.4f) + borderRadius: BorderRadius.circular(12), + border: Border.all(color: Colors.white.withOpacity(0.2), width: 0.5), + ), + child: Stack( + alignment: Alignment.center, + children: [ + // 1. 背景图标层:提示用户上下功能 + Column( + mainAxisAlignment: MainAxisAlignment.spaceAround, + children: [ + Icon(widget.iconTop, color: Colors.white12, size: 20), + Icon(widget.iconMiddle, color: Colors.white12, size: 20), + Icon(widget.iconBottom, color: Colors.white12, size: 20), + ], + ), + + // 2. 活动滑块:对应 Compose 中的蓝色选中状态 + AnimatedPositioned( + duration: const Duration(milliseconds: 150), + curve: Curves.easeOutBack, // 增加一点点弹簧感 + top: _currentIndex == 0 ? 5 : (_currentIndex == 1 ? 40 : 75), + child: Container( + width: 38, + height: 40, + decoration: BoxDecoration( + color: const Color(0xCC0078D4), // 对应 Compose 里的蓝色 + borderRadius: BorderRadius.circular(8), + boxShadow: [ + BoxShadow( + color: const Color(0xCC0078D4).withOpacity(0.4), + blurRadius: 8, + spreadRadius: 1, + ), + ], + ), + child: Icon( + _currentIndex == 0 + ? widget.iconTop + : (_currentIndex == 1 + ? widget.iconMiddle + : widget.iconBottom), + color: Colors.white, + size: 22, + ), + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/features/remote_control/presentation/widgets/my_video_player.dart b/lib/features/remote_control/presentation/widgets/my_video_player.dart new file mode 100644 index 00000000..5bc74bd4 --- /dev/null +++ b/lib/features/remote_control/presentation/widgets/my_video_player.dart @@ -0,0 +1,111 @@ +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:flutter_inappwebview/flutter_inappwebview.dart'; +import 'package:maibu_satabot_v2/core/domain/entities/user_entity.dart'; +import 'package:maibu_satabot_v2/features/devices/domain/entities/device_entity.dart'; + +class MyVideoPlayer extends StatefulWidget { + final String deviceIp; + final DeviceEntity device; + final UserEntity user; + const MyVideoPlayer({ + Key? key, + required this.deviceIp, + required this.device, + required this.user, + }) : super(key: key); + + @override + State createState() => _MyVideoPlayerState(); +} + +class _MyVideoPlayerState extends State { + // 1. 定义本地服务器 + InAppLocalhostServer? _localServer; + InAppWebViewController? _webViewController; + bool _isServerRunning = false; + var actualPort; + + @override + void initState() { + super.initState(); + _startServer(); + } + + // 2. 启动服务器 (适用于 Windows, Android, iOS) + Future _startServer() async { + // 1. 手动找一个系统分配的空闲端口 + int availablePort = 0; + try { + // 绑定到端口 0,系统会随机分配一个 + var socket = await ServerSocket.bind(InternetAddress.loopbackIPv4, 0); + availablePort = socket.port; + await socket.close(); // 立即释放,给 LocalhostServer 用 + } catch (e) { + availablePort = 8080; // 万一失败,给个保底 + } + + // 2. 使用找到的明确端口启动 + _localServer = InAppLocalhostServer(port: availablePort); + await _localServer!.start(); + + // 确认端口(有些版本需要通过这种方式确认) + actualPort = availablePort; + + debugPrint("服务器启动在端口: $actualPort"); + + setState(() { + _isServerRunning = true; + }); + } + + @override + void dispose() { + _localServer?.close(); // 页面销毁时关闭服务器 + super.dispose(); + } + + @override + Widget build(BuildContext context) { + if (!_isServerRunning) { + return const Center(child: CircularProgressIndicator()); + } + + return InAppWebView( + // 3. 通过 localhost 地址访问,而不是 file:// + initialUrlRequest: URLRequest( + url: WebUri("http://localhost:$actualPort/assets/www/playwebrtc.html"), + ), + initialSettings: InAppWebViewSettings( + javaScriptEnabled: true, + mediaPlaybackRequiresUserGesture: false, + allowsInlineMediaPlayback: true, + // Windows/Android 开启硬件加速优化图传 + preferredContentMode: UserPreferredContentMode.DESKTOP, + ), + onWebViewCreated: (controller) => _webViewController = controller, + onPermissionRequest: (controller, request) async { + return PermissionResponse( + resources: request.resources, + action: PermissionResponseAction.GRANT, + ); + }, + onLoadStop: (controller, url) { + _initWebRTC( + widget.deviceIp, + widget.device.deviceName, + widget.user.token, + ); + }, + ); + } + + void _initWebRTC(String deviceIp, String deviceId, String token) { + final streamUrl = + "webrtc://$deviceIp/live/livestream/$deviceId?token=$token"; + _webViewController?.evaluateJavascript( + source: "setStreamUrl('$streamUrl')", + ); + } +} diff --git a/lib/features/remote_control/presentation/widgets/right_joystick_area.dart b/lib/features/remote_control/presentation/widgets/right_joystick_area.dart new file mode 100644 index 00000000..6c92c3bf --- /dev/null +++ b/lib/features/remote_control/presentation/widgets/right_joystick_area.dart @@ -0,0 +1,52 @@ +import 'package:cc_ui_kit/cc_ui_kit.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +import '../bloc/remote_control_cubit.dart'; + +class RightJoystickArea extends StatelessWidget { + final bool isLocked; + + const RightJoystickArea({super.key, required this.isLocked}); + + @override + Widget build(BuildContext context) { + return Column( + mainAxisSize: MainAxisSize.min, // 垂直方向紧凑布局 + children: [ + // 1. 交互锁定逻辑:对应 Compose 的 isLocked 判断 + IgnorePointer( + ignoring: isLocked, + child: AnimatedOpacity( + duration: const Duration(milliseconds: 300), + opacity: isLocked ? 0.3 : 1.0, // 锁定后变透明/灰色 + child: CCJoystick( + radius: 100, // 对应 size(200.dp) + axisHint: AxisHint.leftRight, // 关键:指定为左右控制 + onValueChanged: (value) { + // 对应 viewModel.updateOriginX(x) + context.read().updateOriginY(value.x); + }, + onPress: () { + // 对应 VibrateOnce(current, 100) + HapticFeedback.mediumImpact(); + }, + ), + ), + ), + const SizedBox(height: 8), + // 2. 底部文字:对应 Text("左右控制", color = Color.Gray) + const Text( + "左右控制", + style: TextStyle( + color: Colors.grey, + fontSize: 12, + fontWeight: FontWeight.w500, + ), + ), + const SizedBox(height: 2), + ], + ); + } +} diff --git a/lib/features/remote_control/presentation/widgets/status_chip.dart b/lib/features/remote_control/presentation/widgets/status_chip.dart new file mode 100644 index 00000000..878403a2 --- /dev/null +++ b/lib/features/remote_control/presentation/widgets/status_chip.dart @@ -0,0 +1,87 @@ +import 'package:flutter/material.dart'; + +class StatusChip extends StatefulWidget { + final String text; + final Color color; + final IconData icon; + final bool breathing; // 是否开启呼吸灯特效 + final VoidCallback? onTap; + + const StatusChip({ + super.key, + required this.text, + required this.color, + required this.icon, + this.breathing = false, + this.onTap, + }); + + @override + State createState() => _StatusChipState(); +} + +class _StatusChipState extends State + with SingleTickerProviderStateMixin { + late AnimationController _controller; + late Animation _opacityAnimation; + + @override + void initState() { + super.initState(); + _controller = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 1000), + ); + if (widget.breathing) _controller.repeat(reverse: true); + + _opacityAnimation = Tween( + begin: 0.4, + end: 1.0, + ).animate(CurvedAnimation(parent: _controller, curve: Curves.linear)); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return AnimatedBuilder( + animation: _opacityAnimation, + builder: (context, child) { + final currentAlpha = widget.breathing ? _opacityAnimation.value : 0.6; + return GestureDetector( + onTap: widget.onTap, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), + decoration: BoxDecoration( + color: widget.color.withOpacity(currentAlpha), + borderRadius: BorderRadius.circular(10), + border: Border.all( + color: widget.color.withOpacity(0.35), + width: 0.5, + ), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(widget.icon, color: Colors.white, size: 14), + const SizedBox(width: 6), + Text( + widget.text, + style: const TextStyle( + color: Colors.white, + fontSize: 12, + fontWeight: FontWeight.bold, + ), + ), + ], + ), + ), + ); + }, + ); + } +} diff --git a/lib/features/remote_control/presentation/widgets/top_status_bar.dart b/lib/features/remote_control/presentation/widgets/top_status_bar.dart new file mode 100644 index 00000000..8df07450 --- /dev/null +++ b/lib/features/remote_control/presentation/widgets/top_status_bar.dart @@ -0,0 +1,115 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:go_router/go_router.dart'; +import 'package:maibu_satabot_v2/features/remote_control/presentation/widgets/status_chip.dart'; + +import '../../../devices/presentation/bloc/devices_cubit.dart'; +import '../bloc/remote_control_cubit.dart'; + +class TopStatusBar extends StatelessWidget { + const TopStatusBar({super.key}); + + @override + Widget build(BuildContext context) { + // 监听全局设备状态 + final deviceState = context.watch().state; + final device = deviceState.selectedDevice; + + // 监听局部遥控状态 + final remoteState = context.watch().state; + + return Padding( + padding: const EdgeInsets.all(12.0), + child: Row( + children: [ + // 1. 返回按钮 (对应 SimpleSmallFunctionButton) + _buildIconButton(Icons.arrow_back_ios_new, () => context.pop()), + const SizedBox(width: 16), + + // 2. 控制状态 (对应 StatusChipLeft) + StatusChip( + text: remoteState.hasPermission ? "正在控制" : "未在控制", + color: remoteState.hasPermission + ? const Color(0xFF1DB954) + : Colors.red, + icon: Icons.eighteen_mp, + breathing: !remoteState.hasPermission, + onTap: () { + if (!remoteState.hasPermission) { + // 弹出请求权限对话框逻辑 + context.read().togglePermissionDialog(true); + } + }, + ), + const SizedBox(width: 8), + + // 3. 锁定状态 (对应 SmallFunctionButton) + _buildIconButton( + remoteState.isLocked ? Icons.lock : Icons.lock_open, + () => context.read().toggleLock(), + isSelected: remoteState.isLocked, + ), + const SizedBox(width: 8), + + // 4. 刷新按钮 + _buildIconButton(Icons.refresh, () { + // 刷新 WebView 逻辑 + }), + + const Spacer(), + + // 5. 信号延迟 (对应 pingStatusChip) + _buildPingChip(remoteState.ping), + const SizedBox(width: 8), + + // 6. 电量 (对应 StatusChipRight) + _buildBatteryChip(remoteState?.battery ?? 0), + ], + ), + ); + } + + // 对应 Android 里的 SimpleSmallFunctionButton 样式 + Widget _buildIconButton( + IconData icon, + VoidCallback onTap, { + bool isSelected = false, + }) { + return InkWell( + onTap: onTap, + child: Container( + width: 32, + height: 32, + decoration: BoxDecoration( + color: isSelected + ? const Color(0xCC0078D4) + : Colors.grey.withOpacity(0.4), + borderRadius: BorderRadius.circular(10), + border: Border.all(color: Colors.white.withOpacity(0.3), width: 0.5), + ), + child: Icon(icon, color: Colors.white, size: 18), + ), + ); + } + + Widget _buildPingChip(int ping) { + Color color = ping < 100 + ? Colors.green + : (ping < 200 ? Colors.orange : Colors.red); + return StatusChip( + text: "$ping ms", + color: color, + icon: Icons.network_check, + ); + } + + Widget _buildBatteryChip(int level) { + IconData icon = level > 80 + ? Icons.battery_full + : (level > 20 ? Icons.battery_3_bar : Icons.battery_alert); + Color color = level > 80 + ? Colors.green + : (level > 30 ? Colors.orange : Colors.red); + return StatusChip(text: "$level%", color: color, icon: icon); + } +} diff --git a/lib/main.dart b/lib/main.dart index 0d0f0851..e7201b37 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,10 +1,12 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:go_router/go_router.dart'; +import 'package:maibu_satabot_v2/core/app/app_user_cubit.dart'; +import 'package:maibu_satabot_v2/core/theme/AppTheme.dart'; import 'package:maibu_satabot_v2/features/auth/presentation/bloc/auth_cubit.dart'; +import 'package:maibu_satabot_v2/features/devices/presentation/bloc/devices_cubit.dart'; import 'core/di/injection.dart'; -import 'features/auth/presentation/bloc/login_bloc.dart'; import 'features/auth/presentation/bloc/login_cubit.dart'; void main() async { @@ -21,10 +23,20 @@ class MyApp extends StatelessWidget { sl().appStarted(); return MultiBlocProvider( providers: [ - BlocProvider(create: (_) => sl()), + //BlocProvider(create: (_) => sl()), + BlocProvider(create: (_) => sl()), BlocProvider(create: (_) => sl()), + BlocProvider( + create: (_) => + sl() + ..fetchAllDevices(sl().state.user!.username), + ), ], - child: MaterialApp.router(routerConfig: sl()), + child: MaterialApp.router( + title: 'Maibu Satabot', + theme: AppTheme.lightTheme, + routerConfig: sl(), + ), ); } } diff --git a/linux/runner/my_application.cc b/linux/runner/my_application.cc index dee1a331..b75859db 100644 --- a/linux/runner/my_application.cc +++ b/linux/runner/my_application.cc @@ -88,7 +88,7 @@ static gboolean my_application_local_command_line(GApplication* application, g_autoptr(GError) error = nullptr; if (!g_application_register(application, nullptr, &error)) { - g_warning("Failed to register: %s", error->message); + g_warning("Failed to ai: %s", error->message); *exit_status = 1; return TRUE; } diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index 41ea9bf5..9954c3b6 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -5,10 +5,14 @@ import FlutterMacOS import Foundation +import flutter_inappwebview_macos import isar_community_flutter_libs +import path_provider_foundation import shared_preferences_foundation func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { + InAppWebViewFlutterPlugin.register(with: registry.registrar(forPlugin: "InAppWebViewFlutterPlugin")) IsarFlutterLibsPlugin.register(with: registry.registrar(forPlugin: "IsarFlutterLibsPlugin")) + PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin")) SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) } diff --git a/packages/cc_ui_kit/lib/cc_ui_kit.dart b/packages/cc_ui_kit/lib/cc_ui_kit.dart index 504fbbbf..2a27a7fd 100644 --- a/packages/cc_ui_kit/lib/cc_ui_kit.dart +++ b/packages/cc_ui_kit/lib/cc_ui_kit.dart @@ -1,3 +1,5 @@ library cc_ui_kit; +export 'src/cc_joystick.dart'; export 'src/cc_primary_button.dart'; +export 'src/cc_primary_image_button.dart'; diff --git a/packages/cc_ui_kit/lib/src/cc_joystick.dart b/packages/cc_ui_kit/lib/src/cc_joystick.dart new file mode 100644 index 00000000..a6f90ffd --- /dev/null +++ b/packages/cc_ui_kit/lib/src/cc_joystick.dart @@ -0,0 +1,213 @@ +import 'dart:math'; + +import 'package:flutter/material.dart'; + +enum AxisHint { forwardBackward, leftRight } + +class JoystickValue { + final int x; // -100 ~ 100 + final int y; // -100 ~ 100 + JoystickValue(this.x, this.y); +} + +class CCJoystick extends StatefulWidget { + final AxisHint axisHint; + final Function(JoystickValue) onValueChanged; + final VoidCallback? onPress; + final double radius; + + const CCJoystick({ + super.key, + required this.axisHint, + required this.onValueChanged, + this.onPress, + this.radius = 80, + }); + + @override + State createState() => _CustomJoystickState(); +} + +class _CustomJoystickState extends State + with SingleTickerProviderStateMixin { + Offset _offset = Offset.zero; + late AnimationController _floatController; + + @override + void initState() { + super.initState(); + // 漂浮动画:对应 Compose 的 floatAnim + _floatController = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 800), + )..repeat(reverse: true); + } + + @override + void dispose() { + _floatController.dispose(); + super.dispose(); + } + + // 计算输出值并应用死区 + void _updateValue() { + int x = 0; + int y = 0; + + if (widget.axisHint == AxisHint.leftRight) { + x = ((_offset.dx / widget.radius) * 100).clamp(-100, 100).toInt(); + } else { + y = ((-_offset.dy / widget.radius) * 100).clamp(-100, 100).toInt(); + } + + widget.onValueChanged(JoystickValue(_applyDeadZone(x), _applyDeadZone(y))); + } + + int _applyDeadZone(int value, {int deadZone = 5}) { + return value.abs() < deadZone ? 0 : value; + } + + @override + Widget build(BuildContext context) { + return GestureDetector( + onPanStart: (_) => widget.onPress?.call(), + onPanUpdate: (details) { + setState(() { + Offset newOffset = _offset + details.delta; + double distance = newOffset.distance; + // 限制在圆圈内 + if (distance <= widget.radius) { + _offset = newOffset; + } else { + double angle = atan2(newOffset.dy, newOffset.dx); + _offset = Offset( + cos(angle) * widget.radius, + sin(angle) * widget.radius, + ); + } + }); + _updateValue(); + }, + onPanEnd: (_) { + setState(() => _offset = Offset.zero); // 归位 + _updateValue(); + }, + child: AnimatedBuilder( + animation: _floatController, + builder: (context, child) { + return CustomPaint( + size: Size(widget.radius * 2, widget.radius * 2), + painter: JoystickPainter( + offset: _offset, + radius: widget.radius, + axisHint: widget.axisHint, + floatValue: _floatController.value * 10 - 5, // -5f 到 5f + ), + ); + }, + ), + ); + } +} + +class JoystickPainter extends CustomPainter { + final Offset offset; + final double radius; + final AxisHint axisHint; + final double floatValue; + + JoystickPainter({ + required this.offset, + required this.radius, + required this.axisHint, + required this.floatValue, + }); + + @override + void paint(Canvas canvas, Size size) { + final center = Offset(size.width / 2, size.height / 2); + final paint = Paint(); + + // 1. 绘制底盘 (对应 Color(0x6600BFFF)) + paint + ..color = const Color(0x6600BFFF) + ..style = PaintingStyle.stroke + ..strokeWidth = 2; + canvas.drawCircle(center, radius + 8, paint); + + // 2. 绘制箭头 (对应 drawArrow 逻辑) + paint + ..color = Colors.cyan.withOpacity(0.5) + ..strokeCap = StrokeCap.round + ..strokeWidth = 4; + + const arrowSize = 20.0; + if (axisHint == AxisHint.forwardBackward) { + _drawArrow( + canvas, + center + Offset(0, -radius - arrowSize + floatValue), + "UP", + arrowSize, + paint, + ); + _drawArrow( + canvas, + center + Offset(0, radius + arrowSize - floatValue), + "DOWN", + arrowSize, + paint, + ); + } else { + _drawArrow( + canvas, + center + Offset(-radius - arrowSize + floatValue, 0), + "LEFT", + arrowSize, + paint, + ); + _drawArrow( + canvas, + center + Offset(radius + arrowSize - floatValue, 0), + "RIGHT", + arrowSize, + paint, + ); + } + + // 3. 绘制旋钮 (对应 Color(0x8000D4FF)) + paint + ..style = PaintingStyle.fill + ..color = const Color(0x8000D4FF); + canvas.drawCircle(center + offset, 35, paint); + } + + void _drawArrow( + Canvas canvas, + Offset pos, + String direction, + double size, + Paint paint, + ) { + switch (direction) { + case "UP": + canvas.drawLine(pos + Offset(-size / 2, size / 2), pos, paint); + canvas.drawLine(pos + Offset(size / 2, size / 2), pos, paint); + break; + case "DOWN": + canvas.drawLine(pos + Offset(-size / 2, -size / 2), pos, paint); + canvas.drawLine(pos + Offset(size / 2, -size / 2), pos, paint); + break; + case "LEFT": + canvas.drawLine(pos + Offset(size / 2, -size / 2), pos, paint); + canvas.drawLine(pos + Offset(size / 2, size / 2), pos, paint); + break; + case "RIGHT": + canvas.drawLine(pos + Offset(-size / 2, -size / 2), pos, paint); + canvas.drawLine(pos + Offset(-size / 2, size / 2), pos, paint); // 修正坐标 + break; + } + } + + @override + bool shouldRepaint(covariant JoystickPainter oldDelegate) => true; +} diff --git a/packages/cc_ui_kit/lib/src/cc_primary_button.dart b/packages/cc_ui_kit/lib/src/cc_primary_button.dart index 3473ab68..6ebbec66 100644 --- a/packages/cc_ui_kit/lib/src/cc_primary_button.dart +++ b/packages/cc_ui_kit/lib/src/cc_primary_button.dart @@ -2,16 +2,52 @@ import 'package:flutter/material.dart'; class CCPrimaryButton extends StatelessWidget { final String text; - final VoidCallback onPressed; + final VoidCallback? onPressed; + final double? width; + final double height; + final double fontSize; + final Color backgroundColor; // 新增:背景颜色 + final Color textColor; // 新增:字体颜色 const CCPrimaryButton({ super.key, required this.text, - required this.onPressed, + this.onPressed, + this.width = double.infinity, + this.height = 54.0, + this.fontSize = 16.0, + this.backgroundColor = Colors.black, // 默认黑色背景 + this.textColor = Colors.white, // 默认白色文字 }); @override Widget build(BuildContext context) { - return ElevatedButton(onPressed: onPressed, child: Text(text)); + // 💡 核心改动:如果父组件是 Row 且没有指定 width, + // 直接用 double.infinity 会崩溃。 + // 使用这种方式可以更加安全。 + return SizedBox( + width: width, + height: height, + child: ElevatedButton( + onPressed: onPressed, + style: ElevatedButton.styleFrom( + backgroundColor: backgroundColor, // 使用参数 + foregroundColor: textColor, // 使用参数 + disabledBackgroundColor: Colors.grey.shade300, + disabledForegroundColor: Colors.white, + elevation: 0, + padding: const EdgeInsets.symmetric(horizontal: 16), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + textStyle: TextStyle( + fontSize: fontSize, + fontWeight: FontWeight.w600, + letterSpacing: 1.2, + ), + ), + child: Text(text), + ), + ); } } diff --git a/packages/cc_ui_kit/lib/src/cc_primary_image_button.dart b/packages/cc_ui_kit/lib/src/cc_primary_image_button.dart new file mode 100644 index 00000000..d4dcdcda --- /dev/null +++ b/packages/cc_ui_kit/lib/src/cc_primary_image_button.dart @@ -0,0 +1,70 @@ +import 'package:flutter/material.dart'; + +class CCPrimaryImageButton extends StatelessWidget { + final String text; + final VoidCallback? onPressed; + final double? width; + final double height; + final double fontSize; + final Color backgroundColor; + final Color textColor; + final String? imagePath; // 新增:图片路径 (例如 'assets/images/car.png') + final double imageSize; // 新增:控制图片大小 + + const CCPrimaryImageButton({ + super.key, + required this.text, + this.onPressed, + this.width = double.infinity, + this.height = 54.0, + this.fontSize = 16.0, + this.backgroundColor = Colors.black, + this.textColor = Colors.white, + this.imagePath, // 可以为空,为空时不显示图片 + this.imageSize = 24.0, // 默认图片尺寸 + }); + + @override + Widget build(BuildContext context) { + return SizedBox( + width: width, + height: height, + child: ElevatedButton( + onPressed: onPressed, + style: ElevatedButton.styleFrom( + backgroundColor: backgroundColor, + foregroundColor: textColor, + disabledBackgroundColor: Colors.grey.shade300, + disabledForegroundColor: Colors.white, + elevation: 0, + padding: const EdgeInsets.symmetric(horizontal: 16), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + textStyle: TextStyle( + fontSize: fontSize, + fontWeight: FontWeight.w600, + letterSpacing: 1.2, + ), + ), + // 使用 Row 来组合图片和文字 + child: Row( + mainAxisSize: MainAxisSize.min, // 确保内容居中紧凑 + mainAxisAlignment: MainAxisAlignment.center, + children: [ + if (imagePath != null) ...[ + Image.asset( + imagePath!, + width: imageSize, + height: imageSize, + fit: BoxFit.contain, + ), + const SizedBox(width: 8), // 图片和文字之间的间距 + ], + Text(text), + ], + ), + ), + ); + } +} diff --git a/pubspec.lock b/pubspec.lock index 6ee14f0d..2abb4ae1 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -17,6 +17,14 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "8.2.0" + archive: + dependency: transitive + description: + name: archive + sha256: "2fde1607386ab523f7a36bb3e7edb43bd58e6edaf2ffb29d8a6d578b297fdbbd" + url: "https://pub.flutter-io.cn" + source: hosted + version: "4.0.7" args: dependency: transitive description: @@ -136,6 +144,14 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "2.0.4" + cli_util: + dependency: transitive + description: + name: cli_util + sha256: ff6785f7e9e3c38ac98b2fb035701789de90154024a75b6cb926445e83197d1c + url: "https://pub.flutter-io.cn" + source: hosted + version: "0.4.2" clock: dependency: transitive description: @@ -269,6 +285,78 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "9.1.1" + flutter_inappwebview: + dependency: "direct main" + description: + name: flutter_inappwebview + sha256: "80092d13d3e29b6227e25b67973c67c7210bd5e35c4b747ca908e31eb71a46d5" + url: "https://pub.flutter-io.cn" + source: hosted + version: "6.1.5" + flutter_inappwebview_android: + dependency: transitive + description: + name: flutter_inappwebview_android + sha256: "62557c15a5c2db5d195cb3892aab74fcaec266d7b86d59a6f0027abd672cddba" + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.1.3" + flutter_inappwebview_internal_annotations: + dependency: transitive + description: + name: flutter_inappwebview_internal_annotations + sha256: "787171d43f8af67864740b6f04166c13190aa74a1468a1f1f1e9ee5b90c359cd" + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.2.0" + flutter_inappwebview_ios: + dependency: transitive + description: + name: flutter_inappwebview_ios + sha256: "5818cf9b26cf0cbb0f62ff50772217d41ea8d3d9cc00279c45f8aabaa1b4025d" + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.1.2" + flutter_inappwebview_macos: + dependency: transitive + description: + name: flutter_inappwebview_macos + sha256: c1fbb86af1a3738e3541364d7d1866315ffb0468a1a77e34198c9be571287da1 + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.1.2" + flutter_inappwebview_platform_interface: + dependency: transitive + description: + name: flutter_inappwebview_platform_interface + sha256: cf5323e194096b6ede7a1ca808c3e0a078e4b33cc3f6338977d75b4024ba2500 + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.3.0+1" + flutter_inappwebview_web: + dependency: transitive + description: + name: flutter_inappwebview_web + sha256: "55f89c83b0a0d3b7893306b3bb545ba4770a4df018204917148ebb42dc14a598" + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.1.2" + flutter_inappwebview_windows: + dependency: transitive + description: + name: flutter_inappwebview_windows + sha256: "8b4d3a46078a2cdc636c4a3d10d10f2a16882f6be607962dbfff8874d1642055" + url: "https://pub.flutter-io.cn" + source: hosted + version: "0.6.0" + flutter_launcher_icons: + dependency: "direct dev" + description: + name: flutter_launcher_icons + sha256: "526faf84284b86a4cb36d20a5e45147747b7563d921373d4ee0559c54fcdbcea" + url: "https://pub.flutter-io.cn" + source: hosted + version: "0.13.1" flutter_lints: dependency: "direct dev" description: @@ -277,6 +365,14 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "6.0.0" + flutter_svg: + dependency: "direct main" + description: + name: flutter_svg + sha256: "87fbd7c534435b6c5d9d98b01e1fd527812b82e68ddd8bd35fc45ed0fa8f0a95" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.2.3" flutter_test: dependency: "direct dev" description: flutter @@ -327,6 +423,22 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "17.0.1" + google_fonts: + dependency: "direct main" + description: + name: google_fonts + sha256: ba03d03bcaa2f6cb7bd920e3b5027181db75ab524f8891c8bc3aa603885b8055 + url: "https://pub.flutter-io.cn" + source: hosted + version: "6.3.3" + google_nav_bar: + dependency: "direct main" + description: + name: google_nav_bar + sha256: bb12dd21514ee1b041ab3127673e2fd85e693337df308f7f2b75cd1e8e92eaf4 + url: "https://pub.flutter-io.cn" + source: hosted + version: "5.0.7" graphs: dependency: transitive description: @@ -335,6 +447,14 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "2.3.2" + http: + dependency: transitive + description: + name: http + sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412" + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.6.0" http_multi_server: dependency: transitive description: @@ -351,6 +471,14 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "4.1.2" + image: + dependency: transitive + description: + name: image + sha256: "492bd52f6c4fbb6ee41f781ff27765ce5f627910e1e0cbecfa3d9add5562604c" + url: "https://pub.flutter-io.cn" + source: hosted + version: "4.7.2" io: dependency: transitive description: @@ -495,6 +623,38 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "1.9.1" + path_parsing: + dependency: transitive + description: + name: path_parsing + sha256: "883402936929eac138ee0a45da5b0f2c80f89913e6dc3bf77eb65b84b409c6ca" + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.1.0" + path_provider: + dependency: transitive + description: + name: path_provider + sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.1.5" + path_provider_android: + dependency: transitive + description: + name: path_provider_android + sha256: f2c65e21139ce2c3dad46922be8272bb5963516045659e71bb16e151c93b580e + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.2.22" + path_provider_foundation: + dependency: transitive + description: + name: path_provider_foundation + sha256: "6d13aece7b3f5c5a9731eaf553ff9dcbc2eff41087fd2df587fd0fed9a3eb0c4" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.5.1" path_provider_linux: dependency: transitive description: @@ -519,6 +679,14 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "2.3.0" + petitparser: + dependency: transitive + description: + name: petitparser + sha256: "1a97266a94f7350d30ae522c0af07890c70b8e62c71e8e3920d1db4d23c057d1" + url: "https://pub.flutter-io.cn" + source: hosted + version: "7.0.1" platform: dependency: transitive description: @@ -543,6 +711,14 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "1.5.2" + posix: + dependency: transitive + description: + name: posix + sha256: "6323a5b0fa688b6a010df4905a56b00181479e6d10534cecfecede2aa55add61" + url: "https://pub.flutter-io.cn" + source: hosted + version: "6.0.3" provider: dependency: transitive description: @@ -732,6 +908,30 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "1.4.0" + vector_graphics: + dependency: transitive + description: + name: vector_graphics + sha256: a4f059dc26fc8295b5921376600a194c4ec7d55e72f2fe4c7d2831e103d461e6 + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.1.19" + vector_graphics_codec: + dependency: transitive + description: + name: vector_graphics_codec + sha256: "99fd9fbd34d9f9a32efd7b6a6aae14125d8237b10403b422a6a6dfeac2806146" + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.1.13" + vector_graphics_compiler: + dependency: transitive + description: + name: vector_graphics_compiler + sha256: d354a7ec6931e6047785f4db12a1f61ec3d43b207fc0790f863818543f8ff0dc + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.1.19" vector_math: dependency: transitive description: @@ -788,6 +988,14 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "1.1.0" + xml: + dependency: transitive + description: + name: xml + sha256: "971043b3a0d3da28727e40ed3e0b5d18b742fa5a68665cca88e74b7876d5e025" + url: "https://pub.flutter-io.cn" + source: hosted + version: "6.6.1" xxh3: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 73cbeb86..2c86d3d1 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -18,6 +18,14 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev # of the product and file versions while build-number is used as the build suffix. version: 1.0.0+1 +flutter_icons: + android: "launcher_icon" + ios: true + image_path: "assets/images/app_logo_gray.png" + # 适配安卓图标 + adaptive_icon_background: "#2e2e2e" + adaptive_icon_foreground: "assets/images/app_logo_gray.png" + environment: sdk: ^3.10.0 @@ -64,6 +72,18 @@ dependencies: # ===== 路由管理 ===== go_router: ^17.0.1 + # ===== 底部导航栏 ===== + google_nav_bar: ^5.0.7 + + # ===== 字体 ===== + google_fonts: ^6.2.1 + + # ===== WebView ===== + flutter_inappwebview: ^6.1.5 + + # ===== 图片 ===== + flutter_svg: ^2.2.3 + dev_dependencies: flutter_test: @@ -82,7 +102,7 @@ dev_dependencies: # 运行生成命令的底层工具 build_runner: any - + flutter_launcher_icons: ^0.13.1 # For information on the generic Dart part of this file, see the @@ -100,6 +120,9 @@ flutter: # assets: # - images/a_dot_burr.jpeg # - images/a_dot_ham.jpeg + assets: + - assets/images/ + - assets/www/ # An image asset can refer to one or more resolution-specific "variants", see # https://flutter.dev/to/resolution-aware-images diff --git a/test/widget_test.dart b/test/widget_test.dart index 4dbc9d97..8188a589 100644 --- a/test/widget_test.dart +++ b/test/widget_test.dart @@ -5,26 +5,15 @@ // gestures. You can also use WidgetTester to find child widgets in the widget // tree, read text, and verify that the values of widget properties are correct. -import 'package:flutter/material.dart'; -import 'package:flutter_test/flutter_test.dart'; - -import 'package:maibu_satabot_v2/main.dart'; - void main() { - testWidgets('Counter increments smoke test', (WidgetTester tester) async { - // Build our app and trigger a frame. - await tester.pumpWidget(const MyApp()); - - // Verify that our counter starts at 0. - expect(find.text('0'), findsOneWidget); - expect(find.text('1'), findsNothing); - - // Tap the '+' icon and trigger a frame. - await tester.tap(find.byIcon(Icons.add)); - await tester.pump(); - - // Verify that our counter has incremented. - expect(find.text('0'), findsNothing); - expect(find.text('1'), findsOneWidget); - }); + // test('bind device with token', () async { + // var dio = DioClient.create( + // "eyJhbGciOiJIUzUxMiJ9.eyJsb2dpbl91c2VyX2tleSI6IjliODA0MjY3LWUzNTEtNDhiZC04OTk2LTQzYTBkMGM1ODIxMyJ9.ilt4o9m0ayf12nmA-qXwkL7m2H098ZH2EbBOULFS8-W5DUowT_iFEuI8RIEt-oqrik7jPcwfNtFcqBJkoYKRxg", + // ); + // DeviceHttpDatasource deviceHttpDatasource = DeviceHttpDatasourceImpl(dio); + // await deviceHttpDatasource.bindDevice( + // "MC700PLUS-CN-JS-1760408682847-0000002A-A99999", + // "CC的小机器", + // ); + // }); } diff --git a/windows/flutter/generated_plugin_registrant.cc b/windows/flutter/generated_plugin_registrant.cc index a2823684..581c8d50 100644 --- a/windows/flutter/generated_plugin_registrant.cc +++ b/windows/flutter/generated_plugin_registrant.cc @@ -6,9 +6,12 @@ #include "generated_plugin_registrant.h" +#include #include void RegisterPlugins(flutter::PluginRegistry* registry) { + FlutterInappwebviewWindowsPluginCApiRegisterWithRegistrar( + registry->GetRegistrarForPlugin("FlutterInappwebviewWindowsPluginCApi")); IsarFlutterLibsPluginRegisterWithRegistrar( registry->GetRegistrarForPlugin("IsarFlutterLibsPlugin")); } diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake index 47b8c971..3e1a8f80 100644 --- a/windows/flutter/generated_plugins.cmake +++ b/windows/flutter/generated_plugins.cmake @@ -3,6 +3,7 @@ # list(APPEND FLUTTER_PLUGIN_LIST + flutter_inappwebview_windows isar_community_flutter_libs ) diff --git a/windows/runner/main.cpp b/windows/runner/main.cpp index f804b65e..0bbc4076 100644 --- a/windows/runner/main.cpp +++ b/windows/runner/main.cpp @@ -1,4 +1,4 @@ -#include +#include #include #include