第二次提交
@@ -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"
|
||||
|
||||
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 49 KiB |
|
After Width: | Height: | Size: 109 KiB |
|
After Width: | Height: | Size: 192 KiB |
@@ -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>
|
||||
BIN
android/app/src/main/res/mipmap-hdpi/launcher_icon.png
Normal file
|
After Width: | Height: | Size: 6.8 KiB |
BIN
android/app/src/main/res/mipmap-mdpi/launcher_icon.png
Normal file
|
After Width: | Height: | Size: 3.6 KiB |
BIN
android/app/src/main/res/mipmap-xhdpi/launcher_icon.png
Normal file
|
After Width: | Height: | Size: 11 KiB |
BIN
android/app/src/main/res/mipmap-xxhdpi/launcher_icon.png
Normal file
|
After Width: | Height: | Size: 22 KiB |
BIN
android/app/src/main/res/mipmap-xxxhdpi/launcher_icon.png
Normal file
|
After Width: | Height: | Size: 39 KiB |
4
android/app/src/main/res/values/colors.xml
Normal 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
|
After Width: | Height: | Size: 1.7 MiB |
BIN
assets/images/app_logo_black.png
Normal file
|
After Width: | Height: | Size: 957 KiB |
BIN
assets/images/app_logo_gray.png
Normal file
|
After Width: | Height: | Size: 1.0 MiB |
BIN
assets/images/app_logo_transparent.png
Normal file
|
After Width: | Height: | Size: 959 KiB |
BIN
assets/images/car.png
Normal file
|
After Width: | Height: | Size: 8.3 MiB |
340
assets/www/webrtc/playwebrtc.html
Normal 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>
|
||||
117
assets/www/webrtc/websocket.js
Normal 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 = [];
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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++";
|
||||
|
||||
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 1.0 MiB |
|
Before Width: | Height: | Size: 295 B After Width: | Height: | Size: 933 B |
|
Before Width: | Height: | Size: 406 B After Width: | Height: | Size: 2.7 KiB |
|
Before Width: | Height: | Size: 450 B After Width: | Height: | Size: 5.1 KiB |
|
Before Width: | Height: | Size: 282 B After Width: | Height: | Size: 1.5 KiB |
|
Before Width: | Height: | Size: 462 B After Width: | Height: | Size: 4.8 KiB |
|
Before Width: | Height: | Size: 704 B After Width: | Height: | Size: 9.3 KiB |
|
Before Width: | Height: | Size: 406 B After Width: | Height: | Size: 2.7 KiB |
|
Before Width: | Height: | Size: 586 B After Width: | Height: | Size: 8.1 KiB |
|
Before Width: | Height: | Size: 862 B After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 3.8 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 4.7 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 862 B After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 34 KiB |
|
After Width: | Height: | Size: 6.8 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 762 B After Width: | Height: | Size: 7.5 KiB |
|
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 29 KiB |
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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 才会只在数据真正变化时刷新
|
||||
17
lib/core/consts/http_api_consts.dart
Normal 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";
|
||||
}
|
||||
8
lib/core/data/base_model.dart
Normal 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);
|
||||
}
|
||||
@@ -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>(),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
28
lib/core/protocol/machine_protocol_codec.dart
Normal 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();
|
||||
}
|
||||
}
|
||||
18
lib/core/protocol/machine_protocol_constants.dart
Normal 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; // 心跳包
|
||||
}
|
||||
@@ -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,
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
85
lib/core/theme/AppTheme.dart
Normal 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),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
0
lib/core/utils/vibrate_util.dart
Normal file
10
lib/features/ai/presentation/pages/ai_page.dart
Normal 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')));
|
||||
}
|
||||
}
|
||||
31
lib/features/ai/presentation/routes/ai_routes.dart
Normal 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()),
|
||||
],
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
14
lib/features/auth/domain/errors/auth_failure.dart
Normal 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);
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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; // 新增:用于管理监听生命周期
|
||||
|
||||
|
||||
@@ -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()));
|
||||
}
|
||||
|
||||
@@ -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()));
|
||||
}
|
||||
|
||||
@@ -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')),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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: "我已了解",
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -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')),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
19
lib/features/auth/presentation/routes/auth_routes.dart
Normal 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(),
|
||||
),
|
||||
];
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
77
lib/features/devices/data/models/device_model.dart
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
65
lib/features/devices/domain/entities/device_entity.dart
Normal 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,
|
||||
];
|
||||
}
|
||||
18
lib/features/devices/domain/errors/device_failure.dart
Normal 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);
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
78
lib/features/devices/presentation/bloc/devices_cubit.dart
Normal 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)));
|
||||
}
|
||||
}
|
||||
34
lib/features/devices/presentation/bloc/devices_state.dart
Normal 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];
|
||||
}
|
||||
@@ -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)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
28
lib/features/home/presentation/pages/route_plan_page.dart
Normal 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: [],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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: [],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
44
lib/features/home/presentation/routes/home_routes.dart
Normal 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(),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
410
lib/features/home/presentation/widgets/ImmersionHeader.dart
Normal 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,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
147
lib/features/home/presentation/widgets/work_params_card.dart
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
84
lib/features/main_container/presentation/main_wrapper.dart
Normal 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: '我的'),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
10
lib/features/my/presentation/pages/my_page.dart
Normal 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')));
|
||||
}
|
||||
}
|
||||
31
lib/features/my/presentation/routes/my_routes.dart
Normal 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()),
|
||||
],
|
||||
);
|
||||
}
|
||||
@@ -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')),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||