第二次提交
@@ -4,7 +4,7 @@
|
|||||||
android:label="飒沓机器人"
|
android:label="飒沓机器人"
|
||||||
android:name="${applicationName}"
|
android:name="${applicationName}"
|
||||||
android:usesCleartextTraffic="true"
|
android:usesCleartextTraffic="true"
|
||||||
android:icon="@mipmap/ic_launcher">
|
android:icon="@mipmap/launcher_icon">
|
||||||
<activity
|
<activity
|
||||||
android:name=".MainActivity"
|
android:name=".MainActivity"
|
||||||
android:exported="true"
|
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;
|
isa = XCBuildConfiguration;
|
||||||
buildSettings = {
|
buildSettings = {
|
||||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
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_ANALYZER_NONNULL = YES;
|
||||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
|
CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
|
||||||
CLANG_CXX_LIBRARY = "libc++";
|
CLANG_CXX_LIBRARY = "libc++";
|
||||||
@@ -484,7 +484,7 @@
|
|||||||
isa = XCBuildConfiguration;
|
isa = XCBuildConfiguration;
|
||||||
buildSettings = {
|
buildSettings = {
|
||||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
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_ANALYZER_NONNULL = YES;
|
||||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
|
CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
|
||||||
CLANG_CXX_LIBRARY = "libc++";
|
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 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
|
|
||||||
import '../domain/entities/user_entity.dart';
|
import '../domain/entities/user_entity.dart';
|
||||||
import 'app_state.dart';
|
import 'app_user_state.dart';
|
||||||
|
|
||||||
class AppCubit extends Cubit<AppState> {
|
class AppUserCubit extends Cubit<AppUserState> {
|
||||||
AppCubit() : super(const AppState());
|
AppUserCubit() : super(const AppUserState());
|
||||||
|
|
||||||
// 传入实体对象,而不是零散的 id
|
|
||||||
void setAuth(UserEntity user) {
|
void setAuth(UserEntity user) {
|
||||||
emit(state.copyWith(user));
|
emit(state.copyWith(user));
|
||||||
}
|
}
|
||||||
|
|
||||||
void clearAuth() {
|
void clearAuth() {
|
||||||
emit(const AppState());
|
emit(const AppUserState());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2,16 +2,16 @@ import 'package:equatable/equatable.dart';
|
|||||||
|
|
||||||
import '../domain/entities/user_entity.dart';
|
import '../domain/entities/user_entity.dart';
|
||||||
|
|
||||||
class AppState extends Equatable {
|
class AppUserState extends Equatable {
|
||||||
final UserEntity? user; // 直接存实体,包含 userId, username,token 等所有信息
|
final UserEntity? user; // 直接存实体,包含 userId, username,token 等所有信息
|
||||||
|
|
||||||
const AppState({this.user});
|
const AppUserState({this.user});
|
||||||
|
|
||||||
// 辅助属性:判断是否登录
|
// 辅助属性:判断是否登录
|
||||||
bool get isLoggedIn => user != null;
|
bool get isLoggedIn => user != null;
|
||||||
|
|
||||||
AppState copyWith(UserEntity? user) {
|
AppUserState copyWith(UserEntity? user) {
|
||||||
return AppState(user: user ?? this.user);
|
return AppUserState(user: user ?? this.user);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 必须重写 props,UI 才会只在数据真正变化时刷新
|
// 必须重写 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:dio/dio.dart';
|
||||||
import 'package:get_it/get_it.dart';
|
import 'package:get_it/get_it.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
|
import 'package:maibu_satabot_v2/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 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
import '../../features/auth/data/datasources/auth_http_datasource.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/auth_cubit.dart';
|
||||||
import '../../features/auth/presentation/bloc/login_bloc.dart';
|
import '../../features/auth/presentation/bloc/login_bloc.dart';
|
||||||
import '../../features/auth/presentation/bloc/login_cubit.dart';
|
import '../../features/auth/presentation/bloc/login_cubit.dart';
|
||||||
import '../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/dio_client.dart';
|
||||||
import '../network/net_message_dispatcher.dart';
|
import '../network/net_message_dispatcher.dart';
|
||||||
import '../network/tcp/tcp_client.dart';
|
import '../network/tcp/tcp_client.dart';
|
||||||
@@ -48,20 +58,31 @@ Future<void> init() async {
|
|||||||
sl.registerLazySingleton<AuthHttpDataSource>(
|
sl.registerLazySingleton<AuthHttpDataSource>(
|
||||||
() => AuthHttpDataSourceImpl(sl()),
|
() => AuthHttpDataSourceImpl(sl()),
|
||||||
);
|
);
|
||||||
|
sl.registerLazySingleton<DeviceHttpDatasource>(
|
||||||
|
() => DeviceHttpDatasourceImpl(sl()),
|
||||||
|
);
|
||||||
|
|
||||||
/// 3. 仓库 (Repository)
|
/// 3. 仓库 (Repository)
|
||||||
sl.registerLazySingleton<AuthRepository>(
|
sl.registerLazySingleton<AuthRepository>(
|
||||||
() => AuthRepositoryImpl(
|
() => AuthRepositoryImpl(
|
||||||
remote: sl(), // 自动寻找已注册的 LoginRemoteDataSource
|
httpRemote: sl(), // 自动寻找已注册的 LoginRemoteDataSource
|
||||||
localTokenStorage: sl(), // 自动寻找已注册的 TokenStorage
|
localTokenStorage: sl(), // 自动寻找已注册的 TokenStorage
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
sl.registerLazySingleton<DeviceRepository>(() => DeviceRepositoryImpl(sl()));
|
||||||
|
sl.registerLazySingleton<RemoteControlRepository>(
|
||||||
|
() => RemoteControlRepositoryImpl(sl(), sl()),
|
||||||
|
);
|
||||||
|
|
||||||
/// 4. 用例 (UseCase)
|
/// 4. 用例 (UseCase)
|
||||||
sl.registerLazySingleton(() => LoginUseCase(sl()));
|
sl.registerLazySingleton(() => LoginUseCase(sl()));
|
||||||
|
sl.registerLazySingleton(() => GetUserDeviceUseCase(sl()));
|
||||||
|
sl.registerLazySingleton(() => DiffSteerUseCase());
|
||||||
|
|
||||||
/// 5. 状态管理 (Cubit/Bloc)
|
/// 5. 状态管理 (Cubit/Bloc)
|
||||||
sl.registerLazySingleton(() => AppCubit()); // AuthCubit 依赖它,必须先注册
|
sl.registerLazySingleton(() => AppUserCubit()); // AuthCubit 依赖它,必须先注册
|
||||||
|
sl.registerLazySingleton(() => DevicesCubit(sl(), sl()));
|
||||||
|
sl.registerFactory(() => RemoteControlCubit(sl()));
|
||||||
|
|
||||||
/// 6. 认证 (Auth)
|
/// 6. 认证 (Auth)
|
||||||
// --- 关键修改点 1: AuthCubit 必须在 GoRouter 之前注册,并传入参数 ---
|
// --- 关键修改点 1: AuthCubit 必须在 GoRouter 之前注册,并传入参数 ---
|
||||||
@@ -69,7 +90,7 @@ Future<void> init() async {
|
|||||||
() => AuthCubit(
|
() => AuthCubit(
|
||||||
sl<UserStorage>(),
|
sl<UserStorage>(),
|
||||||
sl<TcpClient>(),
|
sl<TcpClient>(),
|
||||||
sl<AppCubit>(),
|
sl<AppUserCubit>(),
|
||||||
sl<NetMessageDispatcher>(),
|
sl<NetMessageDispatcher>(),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
|
import 'package:fpdart/fpdart.dart';
|
||||||
|
|
||||||
|
import '../../error/failure.dart';
|
||||||
|
|
||||||
abstract class BaseUseCase<Type, Params> {
|
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: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 {
|
class DioClient {
|
||||||
static Dio create() {
|
static Dio create() {
|
||||||
final dio = Dio(
|
final dio = Dio(
|
||||||
BaseOptions(
|
BaseOptions(
|
||||||
baseUrl: 'http://1.95.137.212:8081',
|
baseUrl: HttpApiConsts.baseUrl,
|
||||||
connectTimeout: const Duration(seconds: 5),
|
connectTimeout: const Duration(seconds: 5),
|
||||||
receiveTimeout: const Duration(seconds: 5),
|
receiveTimeout: const Duration(seconds: 5),
|
||||||
headers: {'Content-Type': 'application/json'},
|
headers: {'Content-Type': 'application/json'},
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
dio.interceptors.add(LogInterceptor(requestBody: true, responseBody: true));
|
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;
|
return dio;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
import 'dart:typed_data';
|
||||||
|
|
||||||
|
import 'package:flutter/cupertino.dart';
|
||||||
import 'package:maibu_satabot_v2/core/network/protocol_decoder.dart';
|
import 'package:maibu_satabot_v2/core/network/protocol_decoder.dart';
|
||||||
|
|
||||||
class TcpClient {
|
class TcpClient {
|
||||||
@@ -42,13 +44,23 @@ class TcpClient {
|
|||||||
|
|
||||||
void sendRaw(int cmd, List<int> payload) {
|
void sendRaw(int cmd, List<int> payload) {
|
||||||
if (_socket == null) return;
|
if (_socket == null) return;
|
||||||
// 组装:头 AB AA + 指令 + 数据 + 尾 AA AB
|
|
||||||
final data = [0xAB, 0xAA, cmd, ...payload, 0xAA, 0xAB];
|
final builder = BytesBuilder()
|
||||||
_socket!.add(data);
|
..addByte(0xAB)
|
||||||
|
..addByte(0xAA)
|
||||||
|
..addByte(cmd)
|
||||||
|
..add(payload)
|
||||||
|
..add([0x00, 0x00]) // CRC
|
||||||
|
..addByte(0xAA)
|
||||||
|
..addByte(0xAB);
|
||||||
|
|
||||||
|
_socket!.add(builder.takeBytes());
|
||||||
}
|
}
|
||||||
|
|
||||||
void disconnect() {
|
void disconnect() {
|
||||||
_socket?.destroy();
|
_socket?.destroy();
|
||||||
_socket = null;
|
_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:go_router/go_router.dart';
|
||||||
import 'package:maibu_satabot_v2/core/router/route_paths.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_cubit.dart';
|
||||||
import '../../features/auth/presentation/bloc/auth_state.dart';
|
import '../../features/auth/presentation/bloc/auth_state.dart';
|
||||||
import '../../features/auth/presentation/pages/login_page.dart';
|
import '../../features/main_container/presentation/main_wrapper.dart';
|
||||||
import '../../features/home/presentation/pages/home_page.dart';
|
|
||||||
import '../../features/register/presentation/pages/register_page.dart';
|
|
||||||
import 'go_router_refresh_stream.dart';
|
import 'go_router_refresh_stream.dart';
|
||||||
|
|
||||||
GoRouter createRouter(AuthCubit authCubit) {
|
GoRouter createRouter(AuthCubit authCubit) {
|
||||||
@@ -25,9 +27,27 @@ GoRouter createRouter(AuthCubit authCubit) {
|
|||||||
return null;
|
return null;
|
||||||
},
|
},
|
||||||
routes: [
|
routes: [
|
||||||
GoRoute(path: RoutePaths.login, builder: (_, __) => LoginPage()),
|
// 1. 外部路由(无底部导航栏)
|
||||||
GoRoute(path: RoutePaths.register, builder: (_, __) => RegisterPage()),
|
...AuthRoutes.routes,
|
||||||
GoRoute(path: RoutePaths.home, builder: (context, state) => HomePage()),
|
...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 {
|
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 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:dio/dio.dart';
|
||||||
|
import 'package:maibu_satabot_v2/core/consts/http_api_consts.dart';
|
||||||
|
|
||||||
import '../../models/user_model.dart';
|
import '../../models/user_model.dart';
|
||||||
import '../auth_http_datasource.dart';
|
import '../auth_http_datasource.dart';
|
||||||
@@ -11,7 +12,7 @@ class AuthHttpDataSourceImpl implements AuthHttpDataSource {
|
|||||||
@override
|
@override
|
||||||
Future<UserModel> login(String username, String password, sourceType) async {
|
Future<UserModel> login(String username, String password, sourceType) async {
|
||||||
final response = await dio.post(
|
final response = await dio.post(
|
||||||
'/login',
|
HttpApiConsts.loginUrl,
|
||||||
data: {
|
data: {
|
||||||
'username': username,
|
'username': username,
|
||||||
'password': password,
|
'password': password,
|
||||||
@@ -19,11 +20,21 @@ class AuthHttpDataSourceImpl implements AuthHttpDataSource {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
if (response.statusCode == 200) {
|
if (response.statusCode != 200) {
|
||||||
print('runtimeType = ${response.data.runtimeType}');
|
throw Exception('网络请求失败: ${response.statusCode}');
|
||||||
return UserModel.fromJson(response.data);
|
|
||||||
} else {
|
|
||||||
throw Exception(response.data['message'] ?? 'Login failed');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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';
|
import '../../../../core/domain/entities/user_entity.dart';
|
||||||
|
|
||||||
class UserModel extends UserEntity {
|
class UserModel extends UserEntity implements BaseModel {
|
||||||
UserModel({
|
UserModel({
|
||||||
required super.userId,
|
required super.userId,
|
||||||
required super.username,
|
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 'package:maibu_satabot_v2/core/storage/user_storage.dart';
|
||||||
|
|
||||||
import '../../../../core/domain/entities/user_entity.dart';
|
import '../../../../core/domain/entities/user_entity.dart';
|
||||||
|
import '../../domain/errors/auth_failure.dart';
|
||||||
import '../../domain/repositories/auth_repository.dart';
|
import '../../domain/repositories/auth_repository.dart';
|
||||||
import '../datasources/auth_http_datasource.dart';
|
import '../datasources/auth_http_datasource.dart';
|
||||||
|
|
||||||
class AuthRepositoryImpl implements AuthRepository {
|
class AuthRepositoryImpl implements AuthRepository {
|
||||||
final AuthHttpDataSource remote;
|
final AuthHttpDataSource httpRemote;
|
||||||
final UserStorage localTokenStorage;
|
final UserStorage localTokenStorage;
|
||||||
|
|
||||||
AuthRepositoryImpl({required this.remote, required this.localTokenStorage});
|
AuthRepositoryImpl({
|
||||||
|
required this.httpRemote,
|
||||||
|
required this.localTokenStorage,
|
||||||
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
// 注意 1:返回类型应该是 Domain 层的 UserEntity,而不是 UserModel
|
Future<Either<AuthFailure, UserEntity>> login(
|
||||||
Future<UserEntity> login(
|
|
||||||
String username,
|
String username,
|
||||||
String password,
|
String password,
|
||||||
int sourceType,
|
int sourceType,
|
||||||
) async {
|
) async {
|
||||||
try {
|
try {
|
||||||
// 1. 从数据源获取 Data Model
|
// 1. 从数据源获取 Data Model
|
||||||
final userModel = await remote.login(username, password, sourceType);
|
final userModel = await httpRemote.login(username, password, sourceType);
|
||||||
|
|
||||||
// 2. 职责核心:将 Model 转换为 Entity (解耦业务与 JSON 结构)
|
await localTokenStorage.saveUser(userModel);
|
||||||
if (userModel != null) {
|
|
||||||
await localTokenStorage.saveUser(userModel);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. 只返回纯粹的用户信息实体
|
return Right(userModel.toEntity());
|
||||||
return userModel.toEntity();
|
} on DioException catch (e) {
|
||||||
|
final String serverMessage = e.response?.data['msg'] ?? "网络连接异常";
|
||||||
|
return Left(AuthFailure(serverMessage));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// 3. 职责核心:异常翻译
|
final cleanMessage = e.toString().replaceFirst('Exception: ', '');
|
||||||
// 不直接抛出 Dio 异常,而是抛出业务层定义的自定义异常或返回 Failure
|
return Left(AuthFailure(cleanMessage));
|
||||||
throw Exception(e.toString());
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
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 '../../../../core/domain/entities/user_entity.dart';
|
||||||
|
import '../errors/auth_failure.dart';
|
||||||
|
|
||||||
abstract class AuthRepository {
|
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/entities/user_entity.dart';
|
||||||
import '../../../../core/domain/usecases/base_usecase.dart';
|
import '../../../../core/domain/usecases/base_usecase.dart';
|
||||||
|
import '../errors/auth_failure.dart';
|
||||||
import '../repositories/auth_repository.dart';
|
import '../repositories/auth_repository.dart';
|
||||||
|
|
||||||
class LoginUseCase implements BaseUseCase<UserEntity, LoginParams> {
|
class LoginUseCase implements BaseUseCase<UserEntity, LoginParams> {
|
||||||
@@ -8,8 +11,8 @@ class LoginUseCase implements BaseUseCase<UserEntity, LoginParams> {
|
|||||||
LoginUseCase(this.repository);
|
LoginUseCase(this.repository);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<UserEntity> call(LoginParams params) {
|
Future<Either<AuthFailure, UserEntity>> call(LoginParams params) async {
|
||||||
return repository.login(
|
return await repository.login(
|
||||||
params.username,
|
params.username,
|
||||||
params.password,
|
params.password,
|
||||||
params.sourceType,
|
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/domain/entities/user_entity.dart';
|
||||||
import 'package:maibu_satabot_v2/core/network/net_message_dispatcher.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/network/tcp/tcp_client.dart';
|
||||||
import '../../../../core/storage/user_storage.dart';
|
import '../../../../core/storage/user_storage.dart';
|
||||||
import 'auth_state.dart';
|
import 'auth_state.dart';
|
||||||
@@ -17,7 +17,7 @@ import 'auth_state.dart';
|
|||||||
class AuthCubit extends Cubit<AuthState> {
|
class AuthCubit extends Cubit<AuthState> {
|
||||||
final UserStorage storage;
|
final UserStorage storage;
|
||||||
final TcpClient tcp;
|
final TcpClient tcp;
|
||||||
final AppCubit appCubit;
|
final AppUserCubit appCubit;
|
||||||
final NetMessageDispatcher dispatcher;
|
final NetMessageDispatcher dispatcher;
|
||||||
StreamSubscription? _kickOutSub; // 新增:用于管理监听生命周期
|
StreamSubscription? _kickOutSub; // 新增:用于管理监听生命周期
|
||||||
|
|
||||||
|
|||||||
@@ -11,11 +11,13 @@ class LoginBloc extends Bloc<LoginEvent, LoginState> {
|
|||||||
on<LoginSubmitted>((event, emit) async {
|
on<LoginSubmitted>((event, emit) async {
|
||||||
emit(LoginLoading());
|
emit(LoginLoading());
|
||||||
try {
|
try {
|
||||||
final user = await loginUseCase(
|
final result = await loginUseCase(
|
||||||
LoginParams(event.username, event.password, event.sourceType),
|
LoginParams(event.username, event.password, event.sourceType),
|
||||||
);
|
);
|
||||||
print('✅ Got user: $user');
|
result.fold(
|
||||||
emit(LoginSuccess(user));
|
(failure) => emit(LoginFailure(failure.message)),
|
||||||
|
(user) => emit(LoginSuccess(user)),
|
||||||
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
emit(LoginFailure(e.toString()));
|
emit(LoginFailure(e.toString()));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,11 +17,13 @@ class LoginCubit extends Cubit<LoginState> {
|
|||||||
Future<void> login(String username, String password, sourceType) async {
|
Future<void> login(String username, String password, sourceType) async {
|
||||||
emit(LoginLoading());
|
emit(LoginLoading());
|
||||||
try {
|
try {
|
||||||
final resultUser = await loginUseCase.call(
|
final result = await loginUseCase.call(
|
||||||
LoginParams(username, password, sourceType),
|
LoginParams(username, password, sourceType),
|
||||||
);
|
);
|
||||||
authCubit.loginSuccess(resultUser);
|
result.fold((failure) => emit(LoginFailure(failure.message)), (user) {
|
||||||
emit(LoginSuccess(resultUser));
|
emit(LoginSuccess(user));
|
||||||
|
authCubit.loginSuccess(user);
|
||||||
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
emit(LoginFailure(e.toString()));
|
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_cubit.dart';
|
||||||
import '../bloc/login_state.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 _userCtrl = TextEditingController();
|
||||||
final _pwdCtrl = TextEditingController();
|
final _pwdCtrl = TextEditingController();
|
||||||
|
bool _isAgreed = false; // 协议勾选状态
|
||||||
|
bool _obscurePwd = true; // 密码可见性
|
||||||
|
|
||||||
LoginPage({super.key});
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_userCtrl.dispose();
|
||||||
|
_pwdCtrl.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(title: const Text('Login')),
|
backgroundColor: Colors.white,
|
||||||
|
// 保持 AppBar 简洁或直接去掉
|
||||||
|
appBar: AppBar(backgroundColor: Colors.white, elevation: 0),
|
||||||
body: BlocListener<LoginCubit, LoginState>(
|
body: BlocListener<LoginCubit, LoginState>(
|
||||||
listener: (context, state) {
|
listener: (context, state) {
|
||||||
if (state is LoginSuccess) {
|
if (state is LoginSuccess) {
|
||||||
// Navigator.pushReplacement(
|
|
||||||
// context,
|
|
||||||
// MaterialPageRoute(builder: (_) => HomePage(user: state.user)),
|
|
||||||
// );
|
|
||||||
context.go(RoutePaths.home);
|
context.go(RoutePaths.home);
|
||||||
|
} else if (state is LoginFailure) {
|
||||||
|
// 可以在这里弹出简洁的黑白提示框
|
||||||
|
ScaffoldMessenger.of(
|
||||||
|
context,
|
||||||
|
).showSnackBar(SnackBar(content: Text('登录失败,${state.message}')));
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
child: SingleChildScrollView(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 30.0),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
const SizedBox(height: 40),
|
||||||
|
const Text(
|
||||||
|
"账号登录",
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 28,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: Colors.black,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
const Text(
|
||||||
|
"请填写以下信息以验证身份",
|
||||||
|
style: TextStyle(fontSize: 15, color: Colors.grey),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 40),
|
||||||
|
|
||||||
|
// 账号输入框
|
||||||
|
_buildInputLabel("账号"),
|
||||||
|
TextField(
|
||||||
|
controller: _userCtrl,
|
||||||
|
cursorColor: Colors.black,
|
||||||
|
decoration: _inputDecoration(hint: "请输入用户名"),
|
||||||
|
),
|
||||||
|
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
|
||||||
|
// 密码输入框
|
||||||
|
_buildInputLabel("密码"),
|
||||||
|
TextField(
|
||||||
|
controller: _pwdCtrl,
|
||||||
|
obscureText: _obscurePwd,
|
||||||
|
cursorColor: Colors.black,
|
||||||
|
decoration: _inputDecoration(
|
||||||
|
hint: "请输入密码",
|
||||||
|
suffixIcon: IconButton(
|
||||||
|
icon: Icon(
|
||||||
|
_obscurePwd
|
||||||
|
? Icons.visibility_off_outlined
|
||||||
|
: Icons.visibility_outlined,
|
||||||
|
color: Colors.grey,
|
||||||
|
size: 20,
|
||||||
|
),
|
||||||
|
onPressed: () => setState(() => _obscurePwd = !_obscurePwd),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
|
||||||
|
// 协议勾选区域
|
||||||
|
_buildProtocolSection(),
|
||||||
|
|
||||||
|
const SizedBox(height: 40),
|
||||||
|
|
||||||
|
// 登录按钮 (使用你的 CCPrimaryButton 并增加状态控制)
|
||||||
|
SizedBox(
|
||||||
|
width: double.infinity,
|
||||||
|
child: BlocBuilder<LoginCubit, LoginState>(
|
||||||
|
builder: (context, state) {
|
||||||
|
bool isLoading = state is LoginLoading; // 假设你有 Loading 状态
|
||||||
|
|
||||||
|
return CCPrimaryButton(
|
||||||
|
onPressed: () {
|
||||||
|
if (!_isAgreed) {
|
||||||
|
// 如果没有勾选协议,弹出提示
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(content: Text('请先阅读并同意用户协议')),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (state is! LoginLoading) {
|
||||||
|
_handleLogin();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
text: state is LoginLoading ? '登录中...' : '登 录',
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 执行登录逻辑
|
||||||
|
void _handleLogin() {
|
||||||
|
context.read<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(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
TextField(controller: _userCtrl),
|
Text(
|
||||||
TextField(controller: _pwdCtrl, obscureText: true),
|
title,
|
||||||
CCPrimaryButton(
|
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
|
||||||
onPressed: () {
|
),
|
||||||
// context.read<LoginBloc>().add(
|
const Divider(height: 32),
|
||||||
// LoginSubmitted(_userCtrl.text, _pwdCtrl.text),
|
Expanded(
|
||||||
// );
|
child: SingleChildScrollView(
|
||||||
context.read<LoginCubit>().login(
|
child: Text(
|
||||||
_userCtrl.text,
|
"此处放置您的${title}详细内容...\n" * 20,
|
||||||
_pwdCtrl.text,
|
style: const TextStyle(color: Colors.black87, height: 1.5),
|
||||||
2,
|
),
|
||||||
);
|
),
|
||||||
},
|
),
|
||||||
text: 'Login',
|
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/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 '../widgets/ImmersionHeader.dart';
|
||||||
import '../../../../core/di/injection.dart';
|
import '../widgets/quick_actions_grid.dart';
|
||||||
|
import '../widgets/work_params_card.dart';
|
||||||
|
|
||||||
class HomePage extends StatelessWidget {
|
class HomePage extends StatelessWidget {
|
||||||
// final User user;
|
const HomePage({super.key});
|
||||||
// const HomePage({super.key, required this.user});
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
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(
|
return Scaffold(
|
||||||
appBar: AppBar(title: const Text('Home')),
|
backgroundColor: const Color(0xFFF7F7F7),
|
||||||
body: Center(child: Text('Welcome ${user?.username}')),
|
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;
|
||||||
|
}
|
||||||