864 lines
26 KiB
TypeScript
864 lines
26 KiB
TypeScript
import React, { useEffect, useState, useCallback, useRef } from "react";
|
|
import {
|
|
View,
|
|
Text as RNText,
|
|
TextInput,
|
|
ScrollView,
|
|
StyleSheet,
|
|
ActivityIndicator,
|
|
useColorScheme,
|
|
Alert,
|
|
Pressable,
|
|
} from "react-native";
|
|
import { useFocusEffect } from "@react-navigation/native";
|
|
import { NativeStackScreenProps } from "@react-navigation/native-stack";
|
|
import { RootStackParamList } from "../App";
|
|
import {
|
|
Central,
|
|
ScannedPeripheral,
|
|
ConnectionStatus,
|
|
} from "@systemic-games/react-native-bluetooth-le";
|
|
import Icon from 'react-native-vector-icons/MaterialCommunityIcons';
|
|
import { useTranslation } from 'react-i18next';
|
|
import MyStatusbar from "./component/MyStatusbar";
|
|
import MyHeader from "./component/MyHeader";
|
|
|
|
|
|
type Props = NativeStackScreenProps<RootStackParamList, "Info">;
|
|
|
|
const powerServiceUuid = "fff1";
|
|
const powerWriteUuid = "fff2";
|
|
const powerNotifyUuid = "fff3";
|
|
|
|
const fullUUID = (uuid: string) =>
|
|
uuid.length === 4
|
|
? `0000${uuid}-0000-1000-8000-00805f9b34fb`
|
|
: uuid;
|
|
|
|
const Text = ({ children, style, ...props }: any) => {
|
|
return (
|
|
<RNText
|
|
style={[{ color: "black", fontSize: 16 }, style]}
|
|
{...props}
|
|
>
|
|
{children}
|
|
</RNText>
|
|
);
|
|
};
|
|
|
|
export default function InfoScreen({ route, navigation }: Props) {
|
|
const { t } = useTranslation();
|
|
const { peripheral } = route.params;
|
|
const deviceKey = peripheral.address || peripheral.systemId;
|
|
|
|
const [power, setPower] = useState<number>(0);
|
|
const [cadence, setCadence] = useState<number>(0);
|
|
const [leftbalance, setLeftBalance] = useState<number>(0);
|
|
const [rightbalance, setRightBalance] = useState<number>(0);
|
|
|
|
const [isConnected, setIsConnected] = useState(false);
|
|
const [isLoading, setIsLoading] = useState(true);
|
|
const [serial, setSerial] = useState(t('info.reading'));
|
|
const [firmware, setFirmware] = useState(t('info.reading'));
|
|
const [hardware, setHardware] = useState(t('info.reading'));
|
|
const [battery, setBattery] = useState(t('info.reading'));
|
|
const [powerTrim, setPowerTrim] = useState(t('info.reading'));
|
|
const [inputTrim, setInputTrim] = useState("");
|
|
|
|
const [readSuccessToast, setReadSuccessToast] = useState(false); // ✅ 读取成功提示
|
|
const [powerTrimLoading, setPowerTrimLoading] = useState(false); // 正在写入功率微调
|
|
const [powerTrimSuccessToast, setPowerTrimSuccessToast] = useState(false); // 功率微调写入成功
|
|
|
|
|
|
const notifySubscribedRef = useRef(false);
|
|
const disconnectingRef = useRef(false);
|
|
|
|
const prevConnectedRef = useRef(isConnected);
|
|
const isActiveRef = useRef(true);
|
|
|
|
//增加校准按钮相关状态
|
|
const [calibrating, setCalibrating] = useState<boolean>(false);
|
|
const [calibrationSuccess, setCalibrationSuccess] = useState<boolean>(false);
|
|
const [calibrationValue, setCalibrationValue] = useState<string>("");
|
|
const calibrationTimeoutRef = useRef<number | null>(null);
|
|
const isCalibratingRef = useRef<boolean>(false);
|
|
|
|
useFocusEffect(
|
|
React.useCallback(() => {
|
|
// 页面获得焦点
|
|
isActiveRef.current = true;
|
|
return () => {
|
|
// 页面失去焦点
|
|
isActiveRef.current = false;
|
|
};
|
|
}, [])
|
|
);
|
|
|
|
|
|
// ========== 监听连接状态变化,断开时提示重新连接 ==========
|
|
useEffect(() => {
|
|
if (prevConnectedRef.current && !isConnected && isActiveRef.current) {
|
|
Alert.alert(t('info.disconnectTitle'), t('info.disconnectMessage'), [
|
|
{ text: t('info.confirm'), onPress: () => navigation.goBack() },
|
|
]);
|
|
}
|
|
prevConnectedRef.current = isConnected;
|
|
}, [isConnected]);
|
|
|
|
// ========== 页面即将返回时,强制断开蓝牙 ==========
|
|
useEffect(() => {
|
|
const unsubscribe = navigation.addListener("beforeRemove", async (e) => {
|
|
if (disconnectingRef.current) return;
|
|
disconnectingRef.current = true;
|
|
|
|
const next = e.data.action?.type;
|
|
const action = e.data.action as any;
|
|
|
|
// ✅ 判断是否跳转到 Dfu 页面
|
|
if (next === "NAVIGATE" && action?.payload?.name === "Dfu") {
|
|
console.log("➡️ 跳转到 DFU,不断开蓝牙");
|
|
disconnectingRef.current = false;
|
|
return;
|
|
}
|
|
|
|
// 🔌 其他页面离开则断开蓝牙
|
|
console.log("📴 页面关闭,断开蓝牙...");
|
|
try {
|
|
await Central.disconnectPeripheral(peripheral);
|
|
console.log("✅ 已断开蓝牙");
|
|
} catch (err) {
|
|
console.warn("❌ 断开失败:", err);
|
|
} finally {
|
|
disconnectingRef.current = false;
|
|
}
|
|
});
|
|
return unsubscribe;
|
|
}, [navigation, peripheral]);
|
|
|
|
// ========== BLE notify 订阅FFF3 ==========
|
|
useEffect(() => {
|
|
const connectionHandler = async (ev: {
|
|
peripheral: ScannedPeripheral;
|
|
connectionStatus: ConnectionStatus;
|
|
}) => {
|
|
const addr = ev.peripheral.address || ev.peripheral.systemId;
|
|
if (addr !== deviceKey) return;
|
|
|
|
console.log("🔌 connection event:", ev.connectionStatus);
|
|
setIsConnected(
|
|
ev.connectionStatus === "connected" || ev.connectionStatus === "ready"
|
|
);
|
|
|
|
if (ev.connectionStatus === "ready" && !notifySubscribedRef.current) {
|
|
console.log("✅ device ready - subscribe notify FFF3");
|
|
try {
|
|
await Central.unsubscribeCharacteristic(
|
|
peripheral,
|
|
fullUUID(powerServiceUuid),
|
|
fullUUID(powerNotifyUuid)
|
|
).catch(() => { });
|
|
await Central.subscribeCharacteristic(
|
|
peripheral,
|
|
fullUUID(powerServiceUuid),
|
|
fullUUID(powerNotifyUuid),
|
|
(notifyEv) => {
|
|
try {
|
|
const raw = (notifyEv as any).value;
|
|
if (!raw) return;
|
|
const byteArray = raw instanceof Uint8Array
|
|
? raw
|
|
: new Uint8Array(raw);
|
|
if (byteArray[0] === 0x02 && byteArray.length >= 2) {
|
|
// 功率微调数据
|
|
setPowerTrim(byteArray[1].toString());
|
|
//setInputTrim(byteArray[1].toString());
|
|
}
|
|
else if (byteArray[0] === 0x05 && byteArray.length >= 3) {
|
|
// 校准响应数据 (05XXXX格式)
|
|
handleFFF3Response(byteArray);
|
|
}
|
|
} catch (err) {
|
|
console.warn("notify parse error", err);
|
|
}
|
|
}
|
|
);
|
|
notifySubscribedRef.current = true;
|
|
console.log("✅ notify 已订阅");
|
|
} catch (err) {
|
|
console.warn("❌ notify 订阅失败:", err);
|
|
}
|
|
}
|
|
};
|
|
|
|
|
|
|
|
Central.addListener("peripheralConnectionStatus", connectionHandler);
|
|
return () => {
|
|
Central.removeListener("peripheralConnectionStatus", connectionHandler);
|
|
notifySubscribedRef.current = false;
|
|
};
|
|
}, [deviceKey, peripheral]);
|
|
|
|
// ========== 首次连接并读取信息 ==========
|
|
useEffect(() => {
|
|
(async () => {
|
|
setIsLoading(true);
|
|
try {
|
|
await Central.connectPeripheral(peripheral);
|
|
await new Promise<void>((resolve) => setTimeout(resolve, 500));
|
|
|
|
const readStr = async (srv: string, char: string) => {
|
|
try {
|
|
const v = await Central.readCharacteristic(
|
|
peripheral,
|
|
fullUUID(srv),
|
|
fullUUID(char)
|
|
);
|
|
return v ? String.fromCharCode(...(v as any)) : "未知";
|
|
} catch {
|
|
return "未知";
|
|
}
|
|
};
|
|
|
|
setSerial(await readStr("180a", "2a25"));
|
|
setFirmware(await readStr("180a", "2a28"));
|
|
setHardware(await readStr("180a", "2a27"));
|
|
|
|
try {
|
|
const v = await Central.readCharacteristic(
|
|
peripheral,
|
|
fullUUID("180f"),
|
|
fullUUID("2a19")
|
|
);
|
|
if (v && (v as any).length) setBattery(`${(v as any)[0]}%`);
|
|
} catch {
|
|
setBattery("未知");
|
|
}
|
|
|
|
console.log("✅ info 读取完成");
|
|
|
|
|
|
|
|
try {
|
|
await Central.writeCharacteristic(
|
|
peripheral,
|
|
fullUUID(powerServiceUuid),
|
|
fullUUID(powerWriteUuid),
|
|
new Uint8Array([0x04]).buffer,
|
|
{ withoutResponse: false }
|
|
);
|
|
console.log("✅ 已发送 0x04,等待 notify 更新功率微调");
|
|
|
|
// notify 回调里已经订阅了,所以这里不用再重复订阅
|
|
// 可以稍等 300ms,确保 notify 回来后 UI 会更新
|
|
await new Promise<void>(resolve => setTimeout(() => resolve(), 300));
|
|
} catch (err) {
|
|
console.warn("❌ 首次读取功率微调失败", err);
|
|
}
|
|
|
|
} catch (e) {
|
|
console.warn("❌ 读取失败", e);
|
|
} finally {
|
|
setIsLoading(false);
|
|
|
|
// ✅ 显示读取成功提示 2 秒
|
|
setReadSuccessToast(true);
|
|
setTimeout(() => setReadSuccessToast(false), 2000);
|
|
|
|
}
|
|
})();
|
|
}, [peripheral]);
|
|
|
|
|
|
|
|
|
|
// ========== 订阅 1818 服务的 2A63 特性 (通知) ==========
|
|
useEffect(() => {
|
|
const subscribeToPowerData = async () => {
|
|
try {
|
|
// 确保设备已经连接
|
|
if (!isConnected) return;
|
|
|
|
console.log("✅ 订阅 2A63 特性通知...");
|
|
|
|
// 先取消之前的订阅(防止重复订阅)
|
|
await Central.unsubscribeCharacteristic(
|
|
peripheral,
|
|
fullUUID("1818"),
|
|
fullUUID("2a63")
|
|
).catch(() => { });
|
|
|
|
// 订阅 2A63 特性通知
|
|
await Central.subscribeCharacteristic(
|
|
peripheral,
|
|
fullUUID("1818"),
|
|
fullUUID("2a63"),
|
|
(notifyEv) => {
|
|
try {
|
|
const raw = notifyEv.value;
|
|
if (raw) {
|
|
// 将 ArrayBuffer 转换为字节数组
|
|
const byteArray = new Uint8Array(raw);
|
|
|
|
// 解析数据
|
|
parseData(byteArray);
|
|
}
|
|
} catch (err) {
|
|
console.warn("❌ 处理通知数据失败", err);
|
|
}
|
|
}
|
|
);
|
|
|
|
console.log("✅ 已订阅 2A63 特性通知");
|
|
} catch (err) {
|
|
console.warn("❌ 订阅 2A63 特性失败", err);
|
|
}
|
|
};
|
|
|
|
// 只在设备已连接时进行订阅
|
|
if (peripheral && isConnected) {
|
|
subscribeToPowerData();
|
|
}
|
|
|
|
// 清理订阅(当组件卸载或者连接状态变化时)
|
|
return () => {
|
|
if (peripheral && isConnected) {
|
|
Central.unsubscribeCharacteristic(peripheral, fullUUID("1818"), fullUUID("2a63")).catch(() => { });
|
|
}
|
|
};
|
|
}, [peripheral, isConnected]);
|
|
|
|
const cadenceStateRef = useRef({
|
|
lastCadenceCount: 0, // 上一次的踏频翻转次数,用于计算差值
|
|
lastCadenceTimestamp: 0, // 上一次的踏频时间戳(设备时间),用于计算差值
|
|
lastCadenceChangedTime: 0, // 最后一次踏频变化的时间(本地时间),用于检测超时
|
|
});
|
|
|
|
const parseData = (byteArray: Uint8Array) => {
|
|
// 当前时间(用于超时检测)
|
|
const currentTime = Date.now();
|
|
// ========== 1. 解析功率 ==========
|
|
// 字节2-3:功率值(小端序)
|
|
const powerValue = (byteArray[3] << 8) | byteArray[2];
|
|
setPower(powerValue);
|
|
|
|
// ========== 3. 解析踏频 ==========
|
|
// 字节5-6:踏频翻转次数(小端序)
|
|
const cadenceCount = (byteArray[6] << 8) | byteArray[5];
|
|
|
|
// 字节7-8:踏频时间戳(小端序)
|
|
const timestamp = (byteArray[8] << 8) | byteArray[7];
|
|
|
|
// 获取上一次的状态
|
|
const lastState = cadenceStateRef.current;
|
|
|
|
// 检测踏频翻转次数是否有变化
|
|
const hasCadenceChanged = cadenceCount !== lastState.lastCadenceCount;
|
|
|
|
if (hasCadenceChanged) {
|
|
// 踏频有变化,更新变化时间戳
|
|
cadenceStateRef.current.lastCadenceChangedTime = currentTime;
|
|
}
|
|
|
|
// 检查是否超时(3秒无变化)
|
|
if (lastState.lastCadenceChangedTime > 0 &&
|
|
currentTime - lastState.lastCadenceChangedTime > 3000) {
|
|
//setPower("0 W");
|
|
setCadence(0);
|
|
setLeftBalance(0);
|
|
setRightBalance(0);
|
|
cadenceStateRef.current = {
|
|
lastCadenceCount: 0,
|
|
lastCadenceTimestamp: 0,
|
|
lastCadenceChangedTime: 0,
|
|
};
|
|
return; // 超时后直接返回,不再计算踏频
|
|
}
|
|
|
|
// 如果是第一次收到踏频数据,只记录不计算
|
|
if (lastState.lastCadenceTimestamp === 0) {
|
|
cadenceStateRef.current = {
|
|
lastCadenceCount: cadenceCount,
|
|
lastCadenceTimestamp: timestamp,
|
|
lastCadenceChangedTime: hasCadenceChanged ? currentTime : 0,
|
|
};
|
|
setCadence(0);
|
|
return;
|
|
}
|
|
|
|
// 计算踏频RPM(只在踏频有变化时计算)
|
|
if (hasCadenceChanged) {
|
|
// 计算翻转次数差(处理16位计数器翻转)
|
|
let revDiff = cadenceCount - lastState.lastCadenceCount;
|
|
if (revDiff < 0) {
|
|
revDiff += 65536; // 16位计数器最大值65535
|
|
}
|
|
|
|
// 计算时间差(处理16位计数器翻转)
|
|
let timeDiff = timestamp - lastState.lastCadenceTimestamp;
|
|
if (timeDiff < 0) {
|
|
timeDiff += 65536; // 16位计数器最大值65535
|
|
}
|
|
|
|
// 计算踏频
|
|
if (timeDiff > 0 && revDiff > 0) {
|
|
// 假设时间单位是1/1024秒(蓝牙设备常用)
|
|
const timeInSeconds = timeDiff / 1024.0;
|
|
|
|
// RPM = (圈数 / 时间(秒)) * 60
|
|
const cadenceRPM = (revDiff / timeInSeconds) * 60;
|
|
|
|
// 限制合理范围
|
|
if (cadenceRPM > 10 && cadenceRPM < 250) {
|
|
setCadence(Math.round(cadenceRPM));
|
|
} else {
|
|
setCadence(0);
|
|
}
|
|
} else {
|
|
// 如果没有翻转,踏频为0
|
|
setCadence(0);
|
|
}
|
|
|
|
// ========== 解析左平衡 ==========
|
|
// 字节4:左平衡值,单位0.5%
|
|
const leftBalanceValue = byteArray[4];
|
|
const leftBalancePercent = Math.round(leftBalanceValue * 0.5 * 10) / 10;
|
|
setLeftBalance(leftBalancePercent);
|
|
// 计算右平衡:100% - 左平衡百分比
|
|
const rightBalancePercent = Math.round((100 - leftBalancePercent) * 10) / 10;
|
|
setRightBalance(rightBalancePercent);
|
|
|
|
// 更新踏频计算状态
|
|
cadenceStateRef.current = {
|
|
...cadenceStateRef.current,
|
|
lastCadenceCount: cadenceCount,
|
|
lastCadenceTimestamp: timestamp,
|
|
};
|
|
}
|
|
};
|
|
|
|
|
|
|
|
|
|
// ========== 写入功率微调 ==========
|
|
const updatePowerTrim = async () => {
|
|
const val = parseInt(inputTrim);
|
|
if (isNaN(val) || val < 50 || val > 200) {
|
|
Alert.alert(t('info.disconnectTitle'), t('info.trimRangeAlert'));
|
|
return;
|
|
}
|
|
if (val === Number(powerTrim)) {
|
|
console.log("⚙️ 相同数值,跳过发送");
|
|
return;
|
|
}
|
|
if (!isConnected) {
|
|
Alert.alert(t('info.disconnectTitle'), t('info.deviceNotConnected'));
|
|
return;
|
|
}
|
|
|
|
try {
|
|
setPowerTrimLoading(true);
|
|
|
|
console.log("🚀 写入功率微调", val);
|
|
await Central.writeCharacteristic(
|
|
peripheral,
|
|
fullUUID(powerServiceUuid),
|
|
fullUUID(powerWriteUuid),
|
|
new Uint8Array([0x02, val]).buffer,
|
|
{ withoutResponse: false }
|
|
);
|
|
await new Promise<void>((resolve) => setTimeout(() => resolve(), 150));
|
|
await Central.writeCharacteristic(
|
|
peripheral,
|
|
fullUUID(powerServiceUuid),
|
|
fullUUID(powerWriteUuid),
|
|
new Uint8Array([0x04]).buffer,
|
|
{ withoutResponse: false }
|
|
);
|
|
|
|
setPowerTrimLoading(false);
|
|
setPowerTrimSuccessToast(true);
|
|
setTimeout(() => setPowerTrimSuccessToast(false), 2000);
|
|
|
|
} catch (err) {
|
|
console.warn("❌ 写入失败", err);
|
|
Alert.alert(t('info.writeFailed'));
|
|
}
|
|
};
|
|
|
|
// ========== 校准按钮逻辑 ==========
|
|
const handleCalibration = async () => {
|
|
if (!isConnected) {
|
|
Alert.alert(t('info.disconnectTitle'), t('info.deviceNotConnected'));
|
|
return;
|
|
}
|
|
|
|
if (calibrating) {
|
|
return; // 正在校准中,不重复点击
|
|
}
|
|
|
|
try {
|
|
setCalibrating(true);
|
|
isCalibratingRef.current = true;
|
|
setCalibrationSuccess(false);
|
|
setCalibrationValue("");
|
|
|
|
console.log("🔧 开始校准,向FFF2写入05");
|
|
|
|
// 向FFF2写入05
|
|
await Central.writeCharacteristic(
|
|
peripheral,
|
|
fullUUID("FFF1"), // 服务UUID
|
|
fullUUID("FFF2"), // 写入特征UUID
|
|
new Uint8Array([0x05]).buffer,
|
|
{ withoutResponse: false }
|
|
);
|
|
|
|
console.log("✅ 已发送校准命令,等待响应...");
|
|
|
|
// 设置5秒超时
|
|
calibrationTimeoutRef.current = setTimeout(() => {
|
|
if (isCalibratingRef.current) {
|
|
console.log("❌ 校准超时,未收到响应");
|
|
Alert.alert(t('info.calibrationError'), t('info.calibrationTimeout'));
|
|
setCalibrating(false);
|
|
isCalibratingRef.current = false;
|
|
}
|
|
}, 5000);
|
|
|
|
} catch (err) {
|
|
console.warn("❌ 发送校准命令失败", err);
|
|
Alert.alert(t('info.error'), t('info.calibrationSendError'));
|
|
setCalibrating(false);
|
|
isCalibratingRef.current = false;
|
|
|
|
if (calibrationTimeoutRef.current) {
|
|
clearTimeout(calibrationTimeoutRef.current);
|
|
}
|
|
}
|
|
};
|
|
|
|
const handleFFF3Response = (byteArray: Uint8Array) => {
|
|
// 如果不是正在校准中,不处理
|
|
if (!isCalibratingRef.current) return;
|
|
|
|
// 清除超时定时器
|
|
if (calibrationTimeoutRef.current) {
|
|
clearTimeout(calibrationTimeoutRef.current);
|
|
calibrationTimeoutRef.current = null;
|
|
}
|
|
|
|
// 检查响应数据格式:05XXXX (05开头,后面至少2个字节)
|
|
// 假设响应是05 + 2字节的小端序数值
|
|
if (byteArray[0] === 0x05 && byteArray.length >= 3) {
|
|
// 解析XXXX(小端序):低位在前,高位在后
|
|
// byteArray[1] 是低位字节,byteArray[2] 是高位字节
|
|
const value = Math.round(((byteArray[2] << 8) | byteArray[1]) / 10); //除以10发送出来 不然数据容易乱跳 和码表端处理保持一致
|
|
console.log(`✅ 收到校准响应,值: ${value}`);
|
|
|
|
// 更新状态
|
|
setCalibrationValue(value.toString());
|
|
setCalibrationSuccess(true);
|
|
|
|
// 显示成功弹框
|
|
Alert.alert(
|
|
t('info.calibrationSuccess'),
|
|
`${t('info.calibrationValue') + ": "} ${value}`,
|
|
[
|
|
{
|
|
text: t('info.confirm'),
|
|
onPress: () => {
|
|
setCalibrating(false);
|
|
isCalibratingRef.current = false;
|
|
setCalibrationSuccess(false);
|
|
}
|
|
}
|
|
]
|
|
);
|
|
} else {
|
|
console.warn("❌ 校准响应格式错误", byteArray);
|
|
Alert.alert(t('info.calibrationError'), t('info.calibrationFormatError'));
|
|
setCalibrating(false);
|
|
isCalibratingRef.current = false;
|
|
}
|
|
};
|
|
|
|
useEffect(() => {
|
|
return () => {
|
|
// 清理校准超时定时器
|
|
if (calibrationTimeoutRef.current) {
|
|
clearTimeout(calibrationTimeoutRef.current);
|
|
calibrationTimeoutRef.current = null;
|
|
}
|
|
};
|
|
}, []);
|
|
|
|
|
|
// ========== UI ==========
|
|
return (
|
|
<View style={{flex:1, backgroundColor: "#f2f3f7"}}>
|
|
<MyStatusbar backgroundColor="#f2f3f7" dark></MyStatusbar>
|
|
<MyHeader title={t("info.title")} textColor="#333" backgroundColor="#f2f3f7" navigation={navigation}></MyHeader>
|
|
<ScrollView contentContainerStyle={{ padding: 20, gap: 8 }}>
|
|
|
|
<View style={styles.row}>
|
|
<Text>{t('info.bluetoothName') + ": "} {peripheral.name}</Text>
|
|
</View>
|
|
|
|
<View style={styles.row}>
|
|
<Text>{t('info.idNumber') + ": "} {serial}</Text>
|
|
</View>
|
|
|
|
<View style={styles.row}>
|
|
<Text>{t('info.firmwareVersion') + ": "} {firmware}</Text>
|
|
</View>
|
|
|
|
<View style={styles.row}>
|
|
<Text>{t('info.battery') + ": "} {battery}</Text>
|
|
</View>
|
|
|
|
<View style={styles.row}>
|
|
<Text>{t('info.connectionStatus') + ": "} {isConnected ? t('info.connected') : t('info.disconnected')}</Text>
|
|
</View>
|
|
|
|
{/* ====== 实时数据三卡片 ====== */}
|
|
<View style={styles.realtimeContainer}>
|
|
{/* 功率 */}
|
|
<View style={styles.realtimeCard}>
|
|
<Icon name="flash" size={22} color="#E7141E" />
|
|
<Text style={styles.realtimeLabel}>{t('info.power')}</Text>
|
|
<Text style={styles.realtimeValue}>{power}</Text>
|
|
</View>
|
|
|
|
{/* 踏频 */}
|
|
<View style={styles.realtimeCard}>
|
|
<Icon name="sync" size={22} color="#E7141E" />
|
|
<Text style={styles.realtimeLabel}>{t('info.cadence')}</Text>
|
|
<Text style={styles.realtimeValue}>{cadence}</Text>
|
|
</View>
|
|
|
|
{/* 左右平衡 */}
|
|
<View style={styles.realtimeCard}>
|
|
<Text style={styles.balanceHeader}>L / R</Text>
|
|
<Text style={styles.realtimeLabel}>{t('info.balance')}</Text>
|
|
<Text style={styles.realtimeValue}>
|
|
{leftbalance}/{rightbalance}
|
|
</Text>
|
|
</View>
|
|
</View>
|
|
|
|
|
|
|
|
{/* ========== 功率微调卡片部分 ========== */}
|
|
<View style={styles.cardContainer}>
|
|
<View style={styles.cardTitle}>
|
|
<Text style={[styles.cardTitleText, { textAlign: 'center' }]}>{t('info.powerTrimTitle')}</Text>
|
|
</View>
|
|
|
|
<View style={{
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
justifyContent: 'space-between',
|
|
marginBottom: 0,
|
|
paddingHorizontal: 10
|
|
}}>
|
|
<Text style={{ marginTop: 10, flex: 1, marginRight: 10 }}>{t('info.currentTrim') + ": "} {powerTrim}%</Text>
|
|
<TextInput
|
|
style={[styles.input, { flex: 1, marginTop: 10 }]}
|
|
value={inputTrim}
|
|
onChangeText={setInputTrim}
|
|
keyboardType="numeric"
|
|
onEndEditing={updatePowerTrim}
|
|
placeholder={t('info.trimPlaceholder')}
|
|
/>
|
|
</View>
|
|
|
|
<Pressable
|
|
onPress={updatePowerTrim}
|
|
style={styles.pressable}
|
|
disabled={isLoading || powerTrimLoading}
|
|
>
|
|
<Text style={{ color: "white" }}>{t('info.updateValue')}</Text>
|
|
</Pressable>
|
|
</View>
|
|
|
|
{/* ========== 新增校准按钮 ========== */}
|
|
<View style={{ marginTop: 0 }}>
|
|
<Pressable
|
|
onPress={handleCalibration}
|
|
style={[styles.pressable, calibrating && styles.disabledButton]}
|
|
disabled={calibrating || !isConnected}
|
|
>
|
|
<Text style={{ color: "white" }}>
|
|
{calibrating ? t('info.calibrating') : t('info.calibrateButton')}
|
|
</Text>
|
|
</Pressable>
|
|
</View>
|
|
|
|
|
|
|
|
<View style={{ marginTop: 0 }}>
|
|
<Pressable
|
|
onPress={() => {
|
|
if (!isConnected) {
|
|
Alert.alert(t('info.disconnectTitle'), t('info.reconnectMessage'), [
|
|
{
|
|
text: t('info.confirm'),
|
|
onPress: () => {
|
|
// 返回 ScanScreen
|
|
navigation.goBack();
|
|
},
|
|
},
|
|
]);
|
|
return;
|
|
}
|
|
// 蓝牙已连接,正常跳转 DFU
|
|
navigation.navigate("Dfu", {
|
|
deviceId: deviceKey,
|
|
name: peripheral.name,
|
|
firmware,
|
|
});
|
|
}}
|
|
style={styles.pressable}
|
|
disabled={isLoading || powerTrimLoading} // ❌ 禁用点击
|
|
>
|
|
<Text style={{ color: "white" }}>{t('info.firmwareUpgrade')}</Text>
|
|
</Pressable>
|
|
</View>
|
|
|
|
|
|
|
|
{isLoading && (
|
|
<View style={{ marginVertical: 60, alignItems: "center" }}>
|
|
<ActivityIndicator size={30} color="#E7141E" />
|
|
<Text style={{ marginTop: 10, fontSize: 16 }}>{t('info.readingInfo')}</Text>
|
|
</View>
|
|
)}
|
|
|
|
{/* ✅ 读取成功提示,固定在页面底部 */}
|
|
{readSuccessToast && (
|
|
<View style={{ marginVertical: 60, alignItems: "center" }}>
|
|
<Icon name="check-circle" size={30} color="#E7141E" />
|
|
<Text style={{ marginTop: 10, fontSize: 16 }}>{t('info.readSuccess')}</Text>
|
|
</View>
|
|
)}
|
|
|
|
{/* 正在写入功率微调 */}
|
|
{powerTrimLoading && (
|
|
<View style={{ marginVertical: 60, alignItems: "center" }}>
|
|
<ActivityIndicator size={30} color="#E7141E" />
|
|
<Text style={{ marginTop: 10, fontSize: 16 }}>{t('info.writingTrim')}</Text>
|
|
</View>
|
|
)}
|
|
|
|
{/* 功率微调写入成功 */}
|
|
{powerTrimSuccessToast && (
|
|
<View style={{ marginVertical: 60, alignItems: "center" }}>
|
|
<Icon name="check-circle" size={30} color="#E7141E" />
|
|
<Text style={{ marginTop: 10, fontSize: 16 }}>{t('info.trimUpdateSuccess')}</Text>
|
|
</View>
|
|
)}
|
|
|
|
</ScrollView>
|
|
</View>
|
|
);
|
|
}
|
|
|
|
const styles = StyleSheet.create({
|
|
row: {
|
|
borderBottomWidth: 1,
|
|
borderBottomColor: "#E7141E",
|
|
paddingBottom: 3, // 横线和文字保持一定间距
|
|
marginBottom: 8, // 行间距
|
|
},
|
|
input: {
|
|
borderWidth: 1,
|
|
borderColor: "#ccc",
|
|
padding: 6,
|
|
marginTop: 5,
|
|
borderRadius: 4,
|
|
},
|
|
pressable: {
|
|
padding: 10,
|
|
backgroundColor: "#E7141E",
|
|
marginTop: 10,
|
|
borderRadius: 6,
|
|
alignItems: "center",
|
|
},
|
|
disabledButton: {
|
|
backgroundColor: "#999", // 禁用时的颜色
|
|
opacity: 0.6,
|
|
},
|
|
|
|
cardContainer: {
|
|
borderWidth: 1,
|
|
borderColor: "#ddd",
|
|
borderRadius: 10,
|
|
marginTop: 10,
|
|
backgroundColor: "#fff",
|
|
overflow: 'hidden', // 确保圆角效果完整
|
|
shadowColor: "#000",
|
|
shadowOffset: { width: 0, height: 1 },
|
|
shadowOpacity: 0.1,
|
|
shadowRadius: 2,
|
|
elevation: 2, // Android阴影
|
|
},
|
|
cardTitle: {
|
|
backgroundColor: "#f5f5f5",
|
|
paddingVertical: 8,
|
|
paddingHorizontal: 15,
|
|
borderBottomWidth: 1,
|
|
borderBottomColor: "#eee",
|
|
},
|
|
cardTitleText: {
|
|
fontWeight: 'bold',
|
|
color: "#333",
|
|
},
|
|
|
|
realtimeContainer: {
|
|
flexDirection: "row",
|
|
justifyContent: "space-between",
|
|
marginVertical: 10,
|
|
},
|
|
|
|
realtimeCard: {
|
|
flex: 1,
|
|
marginHorizontal: 4,
|
|
paddingVertical: 12,
|
|
borderRadius: 10,
|
|
backgroundColor: "#fff",
|
|
alignItems: "center",
|
|
|
|
borderWidth: 1,
|
|
borderColor: "#E7141E",
|
|
|
|
shadowColor: "#000",
|
|
shadowOffset: { width: 0, height: 1 },
|
|
shadowOpacity: 0.1,
|
|
shadowRadius: 2,
|
|
elevation: 2,
|
|
},
|
|
|
|
realtimeLabel: {
|
|
marginTop: 6,
|
|
fontSize: 14,
|
|
color: "#666",
|
|
},
|
|
|
|
realtimeValue: {
|
|
marginTop: 4,
|
|
fontSize: 20,
|
|
fontWeight: "bold",
|
|
color: "#000",
|
|
},
|
|
|
|
balanceHeader: {
|
|
fontSize: 16,
|
|
fontWeight: "bold",
|
|
color: "#E7141E",
|
|
},
|
|
|
|
|
|
|
|
});
|