第二次提交

This commit is contained in:
cc
2026-01-18 20:21:14 +08:00
parent 4346601481
commit 289e706337
129 changed files with 4615 additions and 135 deletions

View File

@@ -4,7 +4,7 @@
android:label="飒沓机器人"
android:name="${applicationName}"
android:usesCleartextTraffic="true"
android:icon="@mipmap/ic_launcher">
android:icon="@mipmap/launcher_icon">
<activity
android:name=".MainActivity"
android:exported="true"

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 109 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 192 KiB

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background"/>
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
</adaptive-icon>

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_background">#2e2e2e</color>
</resources>

BIN
assets/images/app_logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 957 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 959 KiB

BIN
assets/images/car.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.3 MiB

View File

@@ -0,0 +1,340 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>WebRTC 前后视角 + PIP + 虚化背景(Apple TV 风格)</title>
<style>
body {
margin: 0;
background: #000;
overflow: hidden;
}
canvas {
display: block;
width: 100vw;
height: 100vh;
touch-action: none;
}
</style>
</head>
<body>
<canvas id="canvas"></canvas>
<script src="jswebrtc.min.js"></script>
<script>
/* ================= 基础 ================= */
const canvas = document.getElementById('canvas');
const ctx = canvas.getContext('2d');
let video = null;
let player = null;
let drawing = false;
let streamUrl = 'webrtc://1.95.137.212/live/livestream/111';
/* ================= 配置 ================= */
const PIP_WIDTH_RATIO = 0.25;
const PIP_RADIUS_RATIO = 0.04;
/* ===== 虚化背景配置 ===== */
const BLUR_BG_ENABLED = true;
const BLUR_RADIUS_PX = 28;
const BLUR_DARK_ALPHA = 0.12; // 降低暗色强度,让过渡更自然
/* ================= PIP 状态 ================= */
function createPip(x, y) {
return { x, y, dragging:false, offsetX:0, offsetY:0, visible:true };
}
const pipState = {
left: createPip(10, 10),
right: createPip(0, 10),
active: null
};
/* ================= 主画面状态 ================= */
let mainView = 'front'; // front / back
/* ================= 工具函数 ================= */
function calcContain16by9(region) {
const r = 16 / 9;
let w = region.dw, h = region.dh;
if (w / h > r) w = h * r;
else h = w / r;
return { dx:(region.dw-w)/2, dy:(region.dh-h)/2, dw:w, dh:h };
}
function getPipSize() {
const w = canvas.width * PIP_WIDTH_RATIO;
return { w, h: w * 9 / 16 };
}
function hitTest(x, y, pip) {
const s = getPipSize();
return x >= pip.x && x <= pip.x + s.w &&
y >= pip.y && y <= pip.y + s.h;
}
function clamp(pip) {
const s = getPipSize();
pip.x = Math.max(0, Math.min(canvas.width - s.w, pip.x));
pip.y = Math.max(0, Math.min(canvas.height - s.h, pip.y));
}
/* ================= Canvas ================= */
function resizeCanvas() {
canvas.width = window.innerWidth;
canvas.height = window.innerHeight;
const s = getPipSize();
pipState.right.x = canvas.width - s.w - 10;
}
window.addEventListener('resize', resizeCanvas);
/* ================= 拖动 ================= */
canvas.addEventListener('pointerdown', e => {
const r = canvas.getBoundingClientRect();
const x = e.clientX - r.left;
const y = e.clientY - r.top;
for (const k of ['left','right']) {
const pip = pipState[k];
if (pip.visible && hitTest(x, y, pip)) {
pipState.active = pip;
pip.dragging = true;
pip.offsetX = x - pip.x;
pip.offsetY = y - pip.y;
canvas.setPointerCapture(e.pointerId);
break;
}
}
});
canvas.addEventListener('pointermove', e => {
const pip = pipState.active;
if (!pip || !pip.dragging) return;
const r = canvas.getBoundingClientRect();
pip.x = e.clientX - r.left - pip.offsetX;
pip.y = e.clientY - r.top - pip.offsetY;
clamp(pip);
});
canvas.addEventListener('pointerup', resetDrag);
canvas.addEventListener('pointercancel', resetDrag);
function resetDrag() {
if (pipState.active) pipState.active.dragging = false;
pipState.active = null;
}
/* ================= WebRTC ================= */
function createVideoElement() {
const v = document.createElement('video');
v.style.display = 'none';
v.playsInline = true;
v.muted = true;
document.body.appendChild(v);
return v;
}
function destroyPlayer() {
drawing = false;
if (player) {
try { player.destroy(); } catch(e) {}
player = null;
}
if (video) {
video.pause();
video.srcObject = null;
video.remove();
video = null;
}
}
function initPlayer() {
destroyPlayer();
video = createVideoElement();
console.log('[WebRTC] init:', streamUrl);
player = new JSWebrtc.Player(streamUrl, {
video,
autoplay: true,
onPlay: () => {
resizeCanvas();
drawing = true;
requestAnimationFrame(draw);
},
onError: err => {
console.error('[WebRTC] error', err);
}
});
}
/* ================= 圆角 ================= */
function roundRectPath(x, y, w, h, r) {
ctx.beginPath();
ctx.moveTo(x+r, y);
ctx.arcTo(x+w, y, x+w, y+h, r);
ctx.arcTo(x+w, y+h, x, y+h, r);
ctx.arcTo(x, y+h, x, y, r);
ctx.arcTo(x, y, x+w, y, r);
ctx.closePath();
}
function drawBlurBackground(video) {
if (!BLUR_BG_ENABLED) return;
const vw = video.videoWidth;
const vh = video.videoHeight;
let sx = 0, sy = 0, sw = vw / 2, sh = vh / 2;
if (mainView === 'back') sx = vw / 2;
const main = calcContain16by9({ dw: canvas.width, dh: canvas.height });
/* ========= 1. 整屏强模糊背景 ========= */
ctx.save();
ctx.filter = `blur(${BLUR_RADIUS_PX}px)`;
ctx.drawImage(
video,
sx, sy, sw, sh,
0, 0, canvas.width, canvas.height
);
ctx.restore();
ctx.fillStyle = `rgba(0,0,0,${BLUR_DARK_ALPHA})`;
ctx.fillRect(0, 0, canvas.width, canvas.height);
/* ========= 2. 盖回清晰画面(关键) ========= */
ctx.save();
const cx = main.dx + main.dw / 2;
const cy = main.dy + main.dh / 2;
const base = Math.min(main.dw, main.dh);
// ✅ 完全清晰的“安全区”
const clearRadius = base * 0.42;
// ✅ 开始渐变的边界
const fadeRadius = base * 0.62;
const mask = ctx.createRadialGradient(
cx, cy, clearRadius,
cx, cy, fadeRadius
);
/*
0.0 ~ 1.0 的意义:
0 = 不透明(画清晰)
1 = 完全透明(露出模糊)
*/
// ★ 重点:前段完全没有任何渐变
mask.addColorStop(0.0, 'rgba(0,0,0,1)');
mask.addColorStop(0.65, 'rgba(0,0,0,1)');
// ★ 从这里才“开始虚”
mask.addColorStop(0.82, 'rgba(0,0,0,0.4)');
mask.addColorStop(1.0, 'rgba(0,0,0,0)');
ctx.globalCompositeOperation = 'destination-over';
ctx.fillStyle = mask;
ctx.fillRect(
main.dx - base,
main.dy - base,
main.dw + base * 2,
main.dh + base * 2
);
ctx.restore();
}
/* ================= PIP ================= */
function drawPip(pip, sx, sy, sw, sh) {
if (!pip.visible) return;
const s = getPipSize();
const r = s.w * PIP_RADIUS_RATIO;
ctx.save();
roundRectPath(pip.x, pip.y, s.w, s.h, r);
ctx.clip();
ctx.drawImage(video, sx, sy, sw, sh, pip.x, pip.y, s.w, s.h);
ctx.restore();
}
/* ================= 绘制 ================= */
function draw() {
if (!drawing || !video || video.readyState < video.HAVE_ENOUGH_DATA) {
requestAnimationFrame(draw);
return;
}
ctx.clearRect(0, 0, canvas.width, canvas.height);
const vw = video.videoWidth;
const vh = video.videoHeight;
/* ① 虚化背景:仅基于当前主视角内容 */
drawBlurBackground(video);
/* ② 主画面(清晰) */
const main = calcContain16by9({ dw: canvas.width, dh: canvas.height });
let sx = 0, sy = 0, sw = vw / 2, sh = vh / 2;
if (mainView === 'back') sx = vw / 2;
ctx.drawImage(
video,
sx, sy, sw, sh,
main.dx, main.dy, main.dw, main.dh
);
/* ③ PIP(小窗) */
drawPip(pipState.left, 0, vh/2, vw/2, vh/2);
drawPip(pipState.right, vw/2, vh/2, vw/2, vh/2);
requestAnimationFrame(draw);
}
/* ================= JS 接口 ================= */
window.setPipVisible = function({location, flag}) {
if (location === 'left') pipState.left.visible = !!flag;
if (location === 'right') pipState.right.visible = !!flag;
};
window.setMainView = function({view}) {
if (view === 'front' || view === 'back') mainView = view;
};
window.toggleMainView = function() {
mainView = (mainView === 'front') ? 'back' : 'front';
};
window.setStreamUrl = function({url}) {
if (!url) return;
streamUrl = url;
setTimeout(initPlayer, 80);
};
window.refreshSelf = function() {
location.reload();
};
/* ================= 启动 ================= */
resizeCanvas();
initPlayer();
</script>
</body>
</html>

View File

@@ -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 = [];
}
}
}

View File

@@ -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++";

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 295 B

After

Width:  |  Height:  |  Size: 933 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 406 B

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 450 B

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 282 B

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 462 B

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 704 B

After

Width:  |  Height:  |  Size: 9.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 406 B

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 586 B

After

Width:  |  Height:  |  Size: 8.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 862 B

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 862 B

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 762 B

After

Width:  |  Height:  |  Size: 7.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 29 KiB

View File

@@ -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<AppState> {
AppCubit() : super(const AppState());
class AppUserCubit extends Cubit<AppUserState> {
AppUserCubit() : super(const AppUserState());
// 传入实体对象,而不是零散的 id
void setAuth(UserEntity user) {
emit(state.copyWith(user));
}
void clearAuth() {
emit(const AppState());
emit(const AppUserState());
}
}

View File

@@ -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 才会只在数据真正变化时刷新

View File

@@ -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";
}

View File

@@ -0,0 +1,8 @@
import '../../features/devices/domain/entities/device_entity.dart';
abstract class BaseModel {
BaseModel.fromJson(Map<String, dynamic> json);
Map<String, dynamic> toJson();
toEntity();
BaseModel.fromEntity(DeviceEntity entity);
}

View File

@@ -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<void> init() async {
sl.registerLazySingleton<AuthHttpDataSource>(
() => AuthHttpDataSourceImpl(sl()),
);
sl.registerLazySingleton<DeviceHttpDatasource>(
() => DeviceHttpDatasourceImpl(sl()),
);
/// 3. 仓库 (Repository)
sl.registerLazySingleton<AuthRepository>(
() => AuthRepositoryImpl(
remote: sl(), // 自动寻找已注册的 LoginRemoteDataSource
httpRemote: sl(), // 自动寻找已注册的 LoginRemoteDataSource
localTokenStorage: sl(), // 自动寻找已注册的 TokenStorage
),
);
sl.registerLazySingleton<DeviceRepository>(() => DeviceRepositoryImpl(sl()));
sl.registerLazySingleton<RemoteControlRepository>(
() => 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<void> init() async {
() => AuthCubit(
sl<UserStorage>(),
sl<TcpClient>(),
sl<AppCubit>(),
sl<AppUserCubit>(),
sl<NetMessageDispatcher>(),
),
);

View File

@@ -1,5 +1,11 @@
import 'package:fpdart/fpdart.dart';
import '../../error/failure.dart';
abstract class BaseUseCase<Type, Params> {
Future<Type> call(Params params);
Future<Either<Failure, Type>> call(Params params);
}
class NoParams {}
class NoParams {
const NoParams();
}

View File

@@ -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<AppUserCubit>();
// 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;
}
}

View File

@@ -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<int> 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");
}
}

View File

@@ -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();
}
}

View File

@@ -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; // 心跳包
}

View File

@@ -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,
],
),
],
);
}

View File

@@ -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';
}

View File

@@ -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),
),
),
);
}
}

View File

View File

@@ -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')));
}
}

View File

@@ -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<RouteBase> 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()),
],
);
}

View File

@@ -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<UserModel> 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;
}
}

View File

@@ -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,

View File

@@ -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<UserEntity> login(
Future<Either<AuthFailure, UserEntity>> 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);
}
// 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));
}
}
}

View File

@@ -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);
}

View File

@@ -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<UserEntity> login(String username, String password, int sourceType);
Future<Either<AuthFailure, UserEntity>> login(
String username,
String password,
int sourceType,
);
}

View File

@@ -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<UserEntity, LoginParams> {
@@ -8,8 +11,8 @@ class LoginUseCase implements BaseUseCase<UserEntity, LoginParams> {
LoginUseCase(this.repository);
@override
Future<UserEntity> call(LoginParams params) {
return repository.login(
Future<Either<AuthFailure, UserEntity>> call(LoginParams params) async {
return await repository.login(
params.username,
params.password,
params.sourceType,

View File

@@ -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<AuthState> {
final UserStorage storage;
final TcpClient tcp;
final AppCubit appCubit;
final AppUserCubit appCubit;
final NetMessageDispatcher dispatcher;
StreamSubscription? _kickOutSub; // 新增:用于管理监听生命周期

View File

@@ -11,11 +11,13 @@ class LoginBloc extends Bloc<LoginEvent, LoginState> {
on<LoginSubmitted>((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()));
}

View File

@@ -17,11 +17,13 @@ class LoginCubit extends Cubit<LoginState> {
Future<void> 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()));
}

View File

@@ -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')),
);
}
}

View File

@@ -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<LoginPage> createState() => _LoginPageState();
}
class _LoginPageState extends State<LoginPage> {
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<LoginCubit, LoginState>(
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: [
TextField(controller: _userCtrl),
TextField(controller: _pwdCtrl, obscureText: true),
CCPrimaryButton(
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<LoginCubit, LoginState>(
builder: (context, state) {
bool isLoading = state is LoginLoading; // 假设你有 Loading 状态
return CCPrimaryButton(
onPressed: () {
// context.read<LoginBloc>().add(
// LoginSubmitted(_userCtrl.text, _pwdCtrl.text),
// );
context.read<LoginCubit>().login(
_userCtrl.text,
_pwdCtrl.text,
2,
if (!_isAgreed) {
// 如果没有勾选协议,弹出提示
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('请先阅读并同意用户协议')),
);
return;
}
if (state is! LoginLoading) {
_handleLogin();
}
},
text: state is LoginLoading ? '登录中...' : '登 录',
);
},
text: 'Login',
),
),
],
),
),
),
);
}
// 执行登录逻辑
void _handleLogin() {
context.read<LoginCubit>().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: [
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<LoginCubit, LoginState>(
builder: (context, state) {
return CCPrimaryButton(
onPressed: () => Navigator.pop(context),
text: "我已了解",
);
},
),
),
],
),

View File

@@ -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')),
);
}
}

View File

@@ -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<RouteBase>
static List<RouteBase> routes = [
GoRoute(
path: RoutePaths.login,
builder: (context, state) => const LoginPage(),
),
GoRoute(
path: RoutePaths.register,
builder: (context, state) => const RegisterPage(),
),
];
}

View File

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

View File

@@ -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<int> 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<List<DeviceEntity>> 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<DeviceEntity> devices = [];
for (var item in responseData['rows']) {
devices.add(DeviceEntity.fromJson(item));
}
return devices;
}
}
@override
Future<int> 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<int> unbindDevice(String deviceId) {
// TODO: implement unbindDevice
throw UnimplementedError();
}
}

View File

@@ -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<String, dynamic> 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<String, dynamic> 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,
);
}
}

View File

@@ -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<Either<DeviceFailure, int>> 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<Either<DeviceFailure, List<DeviceEntity>>> 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<Either<DeviceFailure, int>> unBindDevice(String deviceId) {
// TODO: implement unBindDevice
throw UnimplementedError();
}
@override
Future<Either<DeviceFailure, int>> 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));
}
}
}

View File

@@ -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<String, dynamic> 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<Object?> get props => [
deviceName,
productId,
productName,
tenantId,
tenantName,
status,
activeTime,
deviceAlias,
isBind,
onlineStatus,
];
}

View File

@@ -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);
}

View File

@@ -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<Either<DeviceFailure, List<DeviceEntity>>> getUserDevice(
String userName,
);
Future<Either<DeviceFailure, int>> bindDevice(
String deviceId,
String deviceAlias,
);
Future<Either<DeviceFailure, int>> unBindDevice(String deviceId);
Future<Either<DeviceFailure, int>> switchDevice(
String platform,
String deviceId,
);
}

View File

@@ -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<int, BindDeviceParams> {
final DeviceRepository deviceRepository;
BindDeviceUseCase(this.deviceRepository);
@override
Future<Either<DeviceFailure, int>> 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);
}

View File

@@ -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<List<DeviceEntity>, GetUserDeviceParams> {
final DeviceRepository repository;
GetUserDeviceUseCase(this.repository);
@override
Future<Either<DeviceFailure, List<DeviceEntity>>> call(
GetUserDeviceParams params,
) async {
return await repository.getUserDevice(params.tenantName);
}
}
class GetUserDeviceParams {
final String tenantName;
GetUserDeviceParams(this.tenantName);
}

View File

@@ -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<int, SwitchDeviceParams> {
final DeviceRepository deviceRepository;
SwitchDeviceUseCase(this.deviceRepository);
@override
Future<Either<DeviceFailure, int>> 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);
}

View File

@@ -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<int, UnbindDeviceParams> {
final DeviceRepository repository;
UnbindDeviceUseCase(this.repository);
@override
Future<Either<Failure, int>> call(UnbindDeviceParams params) async {
return await repository.unBindDevice(params.deviceId);
}
}
class UnbindDeviceParams {
final String deviceId;
UnbindDeviceParams(this.deviceId);
}

View File

@@ -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<DevicesState> {
final GetUserDeviceUseCase _getUserDeviceUseCase;
final DeviceRepository repository;
DevicesCubit(this.repository, this._getUserDeviceUseCase)
: super(const DevicesState());
// 获取所有设备列表
Future<void> 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<void> 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)));
}
}

View File

@@ -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<DeviceEntity> 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<DeviceEntity>? 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<Object?> get props => [devices, selectedDevice, isLoading, errorMessage];
}

View File

@@ -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<AppCubit>().state.user;
// 同时监听用户和设备状态
final userState = context.watch<AppUserCubit>().state;
final deviceState = context.watch<DevicesCubit>().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)),
],
),
);
}
}

View File

@@ -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<AppUserCubit>().state;
final deviceState = context.read<DevicesCubit>().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: [],
),
);
}
}

View File

@@ -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<AppUserCubit>().state;
final deviceState = context.read<DevicesCubit>().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: [],
),
);
}
}

View File

@@ -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<RouteBase> get routes => [
GoRoute(
path: RoutePaths.remoteControl,
builder: (context, state) => BlocProvider(
// 每次进入该路由,都会创建一个全新的 Cubit 并开启循环
create: (context) => sl<RemoteControlCubit>()..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(),
),
],
);
}

View File

@@ -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<AppUserCubit>().state.user?.username ??
"";
context.read<DevicesCubit>().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<DevicesCubit, DevicesState>(
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<DevicesCubit>().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,
),
],
),
],
),
),
);
}
}

View File

@@ -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,
),
),
);
}
}

View File

@@ -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,
),
),
],
),
),
),
),
),
);
}
}

View File

@@ -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,
);
}
}

View File

@@ -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: '我的'),
],
),
),
),
),
),
),
),
);
}
}

View File

@@ -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')));
}
}

View File

@@ -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<RouteBase> 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()),
],
);
}

View File

@@ -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')),
);
}
}

View File

@@ -0,0 +1,20 @@
import 'package:dio/dio.dart';
class RemoteHttpDatasource {
final Dio _dio;
RemoteHttpDatasource(this._dio);
/// 示例:获取控制授权 (对应之前逻辑中的权限申请)
Future<Map<String, dynamic>> requestControlAuth(String robotId) async {
try {
final response = await _dio.post(
'/robot/auth/request',
data: {'robotId': robotId},
);
return response.data;
} catch (e) {
rethrow;
}
}
}

View File

@@ -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,
);
}

View File

@@ -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<RawPacket> get responseStream => _tcpClient.packetStream;
}

Some files were not shown because too many files have changed in this diff Show More