初版
This commit is contained in:
commit
8a60654731
82 files changed
+22637
No files matched your search
@@ -0,0 +1,173 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { View, Text, StyleSheet, Alert, BackHandler } from "react-native";
|
||||
import { NativeStackScreenProps } from "@react-navigation/native-stack";
|
||||
import { RootStackParamList } from "../App";
|
||||
import RNFS from "react-native-fs";
|
||||
import { startDfu, DfuProgressEvent, DfuStateEvent } from "@systemic-games/react-native-nordic-nrf5-dfu";
|
||||
|
||||
type Props = NativeStackScreenProps<RootStackParamList, "Dfu">;
|
||||
|
||||
interface DeviceInfo {
|
||||
hardware: number;
|
||||
latestFirmware: string;
|
||||
download: string;
|
||||
}
|
||||
|
||||
export default function DfuScreen({ route, navigation }: Props) {
|
||||
const { deviceId, name, firmware: deviceFirmware } = route.params;
|
||||
|
||||
const [progress, setProgress] = useState(0);
|
||||
const [state, setState] = useState("准备中...");
|
||||
const [error, setError] = useState<string>();
|
||||
const [latestVersion, setLatestVersion] = useState<string>("读取中...");
|
||||
const [isDfuRunning, setIsDfuRunning] = useState(false);
|
||||
|
||||
const mapDfuStateToChinese = (state: string): string => {
|
||||
switch (state) {
|
||||
case "connecting": return "连接中…";
|
||||
case "starting": return "初始化中…";
|
||||
case "enablingDfuMode": return "启用 DFU 模式…";
|
||||
case "uploading": return "上传固件中…";
|
||||
case "validating": return "校验固件…";
|
||||
case "disconnecting": return "断开连接…";
|
||||
case "completed": return "升级完成";
|
||||
case "aborted": return "已取消";
|
||||
case "failed":
|
||||
case "dfu_failed": return "升级失败";
|
||||
case "initializing": return "启动中…";
|
||||
case "errored": return "升级出错!";
|
||||
default: return state;
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
// ✅ 拦截所有导航返回(iOS + Android)
|
||||
useEffect(() => {
|
||||
const unsubscribe = navigation.addListener("beforeRemove", (e) => {
|
||||
if (!isDfuRunning) return;
|
||||
|
||||
// 阻止返回
|
||||
e.preventDefault();
|
||||
Alert.alert("请稍候", "正在升级,请勿返回或关闭应用!");
|
||||
});
|
||||
|
||||
return unsubscribe;
|
||||
}, [navigation, isDfuRunning]);
|
||||
|
||||
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
const runDfu = async () => {
|
||||
try {
|
||||
setIsDfuRunning(true);
|
||||
|
||||
const manifestUrl = "https://powerfun.oss-cn-shanghai.aliyuncs.com/yecongdfu/latest.json";
|
||||
const manifestPath = RNFS.CachesDirectoryPath + "/latest.json";
|
||||
|
||||
await RNFS.downloadFile({ fromUrl: manifestUrl, toFile: manifestPath }).promise;
|
||||
const manifestContent = await RNFS.readFile(manifestPath);
|
||||
const manifest = JSON.parse(manifestContent) as { devices: DeviceInfo[] };
|
||||
|
||||
const [deviceHWStr, deviceFWStr] = deviceFirmware.split(".");
|
||||
const deviceHW = parseInt(deviceHWStr);
|
||||
const deviceFW = parseInt(deviceFWStr);
|
||||
|
||||
const deviceInfo = manifest.devices.find(d => d.hardware === deviceHW);
|
||||
if (!deviceInfo) {
|
||||
setIsDfuRunning(false);
|
||||
Alert.alert("无法升级", `未找到硬件版本 ${deviceHW} 的固件`, [
|
||||
{ text: "确认", onPress: () => navigation.goBack() },
|
||||
]);
|
||||
return;
|
||||
}
|
||||
|
||||
setLatestVersion(deviceInfo.latestFirmware);
|
||||
|
||||
const [, latestFWStr] = deviceInfo.latestFirmware.split(".");
|
||||
const latestFW = parseInt(latestFWStr);
|
||||
|
||||
if (latestFW <= deviceFW) {
|
||||
setIsDfuRunning(false);
|
||||
Alert.alert("无需升级", "已是最新固件,无需升级", [
|
||||
{ text: "确认", onPress: () => navigation.goBack() },
|
||||
]);
|
||||
return;
|
||||
}
|
||||
|
||||
const localPath = RNFS.CachesDirectoryPath + "/firmware.zip";
|
||||
await RNFS.downloadFile({ fromUrl: deviceInfo.download, toFile: localPath }).promise;
|
||||
|
||||
await startDfu(deviceId, "file://" + localPath, {
|
||||
dfuStateListener: (ev: DfuStateEvent) => setState(ev.state),
|
||||
dfuProgressListener: (ev: DfuProgressEvent) => setProgress(ev.percent),
|
||||
});
|
||||
|
||||
setIsDfuRunning(false);
|
||||
Alert.alert("升级成功", "升级成功,请重连设备", [
|
||||
{ text: "确认", onPress: () => navigation.navigate("Home") },
|
||||
]);
|
||||
} catch (err: any) {
|
||||
setIsDfuRunning(false);
|
||||
setError(err.message || "DFU失败");
|
||||
Alert.alert("升级失败", err.message || "DFU失败", [
|
||||
{ text: "确认", onPress: () => navigation.goBack() },
|
||||
]);
|
||||
}
|
||||
};
|
||||
|
||||
runDfu();
|
||||
}, [deviceId, deviceFirmware, navigation]);
|
||||
|
||||
return (
|
||||
<View style={{ flex: 1, padding: 20 }}>
|
||||
<View style={styles.row}>
|
||||
<Text style={{ fontSize: 18 }}>蓝牙名称: {name}</Text>
|
||||
</View>
|
||||
<View style={styles.row}>
|
||||
<Text style={{ fontSize: 16 }}>最新版本: {latestVersion}</Text>
|
||||
</View>
|
||||
<View style={styles.row}>
|
||||
<Text style={{ fontSize: 16 }}>当前版本: {deviceFirmware}</Text>
|
||||
</View>
|
||||
<View style={styles.row}>
|
||||
<Text style={{ fontSize: 16 }}>升级状态: {mapDfuStateToChinese(state)}</Text>
|
||||
</View>
|
||||
|
||||
{/* 横向进度条 */}
|
||||
<View style={styles.progressContainer}>
|
||||
<View style={[styles.progressBar, { width: `${progress}%` }]} />
|
||||
<Text style={styles.progressText}>{progress}%</Text>
|
||||
</View>
|
||||
|
||||
{error && <Text style={{ color: "red", marginTop: 20 }}>{error}</Text>}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
row: {
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: "red",
|
||||
paddingBottom: 4,
|
||||
marginBottom: 8,
|
||||
},
|
||||
progressContainer: {
|
||||
height: 30,
|
||||
backgroundColor: "#eee",
|
||||
borderRadius: 15,
|
||||
overflow: "hidden",
|
||||
marginTop: 40,
|
||||
justifyContent: "center",
|
||||
},
|
||||
progressBar: {
|
||||
height: "100%",
|
||||
backgroundColor: "#E7141E",
|
||||
},
|
||||
progressText: {
|
||||
position: "absolute",
|
||||
alignSelf: "center",
|
||||
fontWeight: "bold",
|
||||
color: "#000",
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,69 @@
|
||||
import React from "react";
|
||||
import { View, Text, TouchableOpacity, StyleSheet, StatusBar } from "react-native";
|
||||
import { NativeStackScreenProps } from "@react-navigation/native-stack";
|
||||
import { RootStackParamList } from "../App";
|
||||
|
||||
type Props = NativeStackScreenProps<RootStackParamList, "Home">;
|
||||
|
||||
export default function HomeScreen({ navigation }: Props) {
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
{/* 设置状态栏颜色 */}
|
||||
<StatusBar backgroundColor="#E7141E" barStyle="light-content" />
|
||||
|
||||
{/* 中间按钮 */}
|
||||
<View style={styles.centerBox}>
|
||||
<TouchableOpacity
|
||||
style={styles.button}
|
||||
onPress={() => navigation.navigate("Scan")}
|
||||
>
|
||||
<Text style={styles.buttonText}>搜索设备</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
{/* 底部区域 */}
|
||||
<View style={styles.bottomBox}>
|
||||
<TouchableOpacity onPress={() => navigation.navigate("Privacy")}>
|
||||
<Text style={styles.privacyText}>隐私协议</Text>
|
||||
</TouchableOpacity>
|
||||
<Text style={styles.version}>版本号 v0.0.1</Text>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: "#fff",
|
||||
},
|
||||
centerBox: {
|
||||
flex: 1,
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
},
|
||||
button: {
|
||||
backgroundColor: "#E7141E", // 红色按钮
|
||||
paddingVertical: 15,
|
||||
paddingHorizontal: 40,
|
||||
borderRadius: 10,
|
||||
},
|
||||
buttonText: {
|
||||
color: "#fff",
|
||||
fontSize: 18,
|
||||
fontWeight: "bold",
|
||||
},
|
||||
bottomBox: {
|
||||
alignItems: "center",
|
||||
marginBottom: 20,
|
||||
},
|
||||
privacyText: {
|
||||
fontSize: 16,
|
||||
color: "#E7141E", // 红色字体
|
||||
marginBottom: 8,
|
||||
},
|
||||
version: {
|
||||
fontSize: 14,
|
||||
color: "#999",
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,417 @@
|
||||
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';
|
||||
|
||||
|
||||
|
||||
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) => {
|
||||
const isDark = useColorScheme() === "dark";
|
||||
return (
|
||||
<RNText
|
||||
style={[{ color: isDark ? "white" : "black", fontSize: 16 }, style]}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</RNText>
|
||||
);
|
||||
};
|
||||
|
||||
export default function InfoScreen({ route, navigation }: Props) {
|
||||
const { peripheral } = route.params;
|
||||
const deviceKey = peripheral.address || peripheral.systemId;
|
||||
|
||||
const [isConnected, setIsConnected] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [serial, setSerial] = useState("读取中...");
|
||||
const [firmware, setFirmware] = useState("读取中...");
|
||||
const [hardware, setHardware] = useState("读取中...");
|
||||
const [battery, setBattery] = useState("读取中...");
|
||||
const [powerTrim, setPowerTrim] = useState("读取中");
|
||||
const [inputTrim, setInputTrim] = useState("100");
|
||||
|
||||
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);
|
||||
|
||||
useFocusEffect(
|
||||
React.useCallback(() => {
|
||||
// 页面获得焦点
|
||||
isActiveRef.current = true;
|
||||
return () => {
|
||||
// 页面失去焦点
|
||||
isActiveRef.current = false;
|
||||
};
|
||||
}, [])
|
||||
);
|
||||
|
||||
|
||||
// ========== 监听连接状态变化,断开时提示重新连接 ==========
|
||||
useEffect(() => {
|
||||
if (prevConnectedRef.current && !isConnected && isActiveRef.current) {
|
||||
Alert.alert("提示", "请重新连接设备", [
|
||||
{ text: "确定", 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 订阅 ==========
|
||||
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 arr =
|
||||
raw instanceof Uint8Array
|
||||
? Array.from(raw)
|
||||
: Array.from(new Uint8Array(raw));
|
||||
if (arr[0] === 0x02 && arr.length >= 2) {
|
||||
setPowerTrim(arr[1].toString());
|
||||
setInputTrim(arr[1].toString());
|
||||
}
|
||||
} 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]);
|
||||
|
||||
// ========== 写入功率微调 ==========
|
||||
const updatePowerTrim = async () => {
|
||||
const val = parseInt(inputTrim);
|
||||
if (isNaN(val) || val < 50 || val > 200) {
|
||||
Alert.alert("提示", "请输入有效数字(50~200)");
|
||||
return;
|
||||
}
|
||||
if (val === Number(powerTrim)) {
|
||||
console.log("⚙️ 相同数值,跳过发送");
|
||||
return;
|
||||
}
|
||||
if (!isConnected) {
|
||||
Alert.alert("提示", "设备未连接");
|
||||
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("写入失败");
|
||||
}
|
||||
};
|
||||
|
||||
// ========== UI ==========
|
||||
return (
|
||||
<ScrollView contentContainerStyle={{ padding: 20, gap: 8 }}>
|
||||
<View style={styles.row}>
|
||||
<Text>蓝牙名称: {peripheral.name}</Text>
|
||||
</View>
|
||||
<View style={styles.row}>
|
||||
<Text>ID号: {serial}</Text>
|
||||
</View>
|
||||
<View style={styles.row}>
|
||||
<Text>固件版本: {firmware}</Text>
|
||||
</View>
|
||||
<View style={styles.row}>
|
||||
<Text>电量: {battery}</Text>
|
||||
</View>
|
||||
<View style={styles.row}>
|
||||
<Text>连接状态: {isConnected ? "已连接" : "未连接"}</Text>
|
||||
</View>
|
||||
|
||||
<View style={{ marginTop: 20 }}>
|
||||
<Text>功率微调: {powerTrim}%</Text>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
value={inputTrim}
|
||||
onChangeText={setInputTrim}
|
||||
keyboardType="numeric"
|
||||
onEndEditing={updatePowerTrim}
|
||||
/>
|
||||
<Pressable onPress={updatePowerTrim} style={styles.pressable} disabled={isLoading || powerTrimLoading}>
|
||||
<Text style={{ color: "white" }}>更新功率微调</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
<View style={{ marginTop: 30 }}>
|
||||
<Pressable
|
||||
onPress={() => {
|
||||
if (!isConnected) {
|
||||
Alert.alert("提示", "请重新连接设备", [
|
||||
{
|
||||
text: "确定",
|
||||
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" }}>升级固件</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
{isLoading && (
|
||||
<View style={{ marginVertical: 60, alignItems: "center" }}>
|
||||
<ActivityIndicator size={80} color="#E7141E" />
|
||||
<Text style={{ marginTop: 10, fontSize: 16 }}>正在读取信息...</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* ✅ 读取成功提示,固定在页面底部 */}
|
||||
{readSuccessToast && (
|
||||
<View style={{ marginVertical: 60, alignItems: "center" }}>
|
||||
<Icon name="check-circle" size={60} color="#E7141E" />
|
||||
<Text style={{ marginTop: 10, fontSize: 16 }}>读取成功!</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* 正在写入功率微调 */}
|
||||
{powerTrimLoading && (
|
||||
<View style={{ marginVertical: 60, alignItems: "center" }}>
|
||||
<ActivityIndicator size={60} color="#E7141E" />
|
||||
<Text style={{ marginTop: 10, fontSize: 16 }}>正在写入功率微调...</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* 功率微调写入成功 */}
|
||||
{powerTrimSuccessToast && (
|
||||
<View style={{ marginVertical: 60, alignItems: "center" }}>
|
||||
<Icon name="check-circle" size={60} color="#E7141E" />
|
||||
<Text style={{ marginTop: 10, fontSize: 16 }}>功率微调更新成功!</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
row: {
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: "#E7141E",
|
||||
paddingBottom: 4, // 横线和文字保持一定间距
|
||||
marginBottom: 8, // 行间距
|
||||
},
|
||||
input: {
|
||||
borderWidth: 1,
|
||||
borderColor: "#ccc",
|
||||
padding: 6,
|
||||
marginTop: 5,
|
||||
borderRadius: 4,
|
||||
},
|
||||
pressable: {
|
||||
padding: 10,
|
||||
backgroundColor: "#E7141E",
|
||||
marginTop: 10,
|
||||
borderRadius: 6,
|
||||
alignItems: "center",
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
import React from "react";
|
||||
import { View, Text, ScrollView, StyleSheet } from "react-native";
|
||||
|
||||
export default function PrivacyScreen() {
|
||||
return (
|
||||
<ScrollView style={styles.container}>
|
||||
<Text style={styles.title}>隐私协议</Text>
|
||||
<Text style={styles.text}>
|
||||
本应用尊重并保护所有使用服务用户的个人隐私权。为了给您提供更准确、更有个性化的服务,本应用会按照本隐私权政策的规定使用和披露您的个人信息。
|
||||
除本隐私权政策另有规定外,在未征得您事先许可的情况下,本应用不会将这些信息对外披露或向第三方提供。
|
||||
{"\n\n"}
|
||||
(这里可以根据实际需求写更多的隐私协议内容……)
|
||||
</Text>
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
padding: 20,
|
||||
backgroundColor: "#fff",
|
||||
},
|
||||
title: {
|
||||
fontSize: 22,
|
||||
fontWeight: "bold",
|
||||
marginBottom: 15,
|
||||
},
|
||||
text: {
|
||||
fontSize: 16,
|
||||
lineHeight: 24,
|
||||
color: "#333",
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,221 @@
|
||||
// src/ScanScreen.tsx
|
||||
import React, { useEffect, useState, useCallback, useRef } from "react";
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
FlatList,
|
||||
TouchableOpacity,
|
||||
StyleSheet,
|
||||
RefreshControl,
|
||||
} from "react-native";
|
||||
import {
|
||||
Central,
|
||||
CentralEventMap,
|
||||
ScannedPeripheral,
|
||||
} from "@systemic-games/react-native-bluetooth-le";
|
||||
import { NativeStackScreenProps } from "@react-navigation/native-stack";
|
||||
import { RootStackParamList } from "../App";
|
||||
import Icon from "react-native-vector-icons/MaterialCommunityIcons"; // 信号图标集
|
||||
|
||||
type Props = NativeStackScreenProps<RootStackParamList, "Scan">;
|
||||
type DeviceWithTimestamp = ScannedPeripheral & { lastSeen: number };
|
||||
|
||||
export default function ScanScreen({ navigation }: Props) {
|
||||
const [devices, setDevices] = useState<DeviceWithTimestamp[]>([]);
|
||||
const [scanning, setScanning] = useState(false);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
|
||||
const handlerRef = useRef<((payload: CentralEventMap["scannedPeripheral"]) => void) | null>(null);
|
||||
|
||||
// 将 RSSI 转换为信号格数(0-4)
|
||||
const getSignalLevel = (rssi?: number): number => {
|
||||
if (rssi === undefined) return 0;
|
||||
if (rssi >= -50) return 4; // 很强
|
||||
if (rssi >= -65) return 3; // 强
|
||||
if (rssi >= -80) return 2; // 一般
|
||||
if (rssi >= -90) return 1; // 弱
|
||||
return 0; // 无信号
|
||||
};
|
||||
|
||||
// 渲染信号图标(格数+颜色)
|
||||
const renderSignalIcon = (rssi?: number) => {
|
||||
const level = getSignalLevel(rssi);
|
||||
|
||||
let iconColor = "#aaa"; // 默认灰色
|
||||
switch (level) {
|
||||
case 4:
|
||||
iconColor = "#8BC34A"; // 亮绿
|
||||
break;
|
||||
case 3:
|
||||
iconColor = "#4CAF50"; // 深绿
|
||||
break;
|
||||
case 2:
|
||||
iconColor = "#FF9800"; // 橙色
|
||||
break;
|
||||
case 1:
|
||||
iconColor = "#F44336"; // 红色
|
||||
break;
|
||||
}
|
||||
|
||||
const iconName =
|
||||
level === 4
|
||||
? "wifi-strength-4"
|
||||
: level === 3
|
||||
? "wifi-strength-3"
|
||||
: level === 2
|
||||
? "wifi-strength-2"
|
||||
: level === 1
|
||||
? "wifi-strength-1"
|
||||
: "wifi-strength-outline";
|
||||
|
||||
return <Icon name={iconName} size={22} color={iconColor} />;
|
||||
};
|
||||
|
||||
// 更新或添加设备,并记录最后扫描时间
|
||||
const updatePeripherals = useCallback((p: ScannedPeripheral) => {
|
||||
if (!p?.name || !p.name.startsWith("POWERFUN")) return;
|
||||
if ((p.advertisementData?.rssi ?? -999) < -90) return; // 已过滤弱信号
|
||||
|
||||
setDevices((prev) => {
|
||||
const now = Date.now();
|
||||
const exists = prev.find((d) => d.systemId === p.systemId);
|
||||
if (exists) {
|
||||
return prev.map((d) =>
|
||||
d.systemId === p.systemId ? { ...p, lastSeen: now } : d
|
||||
);
|
||||
} else {
|
||||
return [...prev, { ...p, lastSeen: now }];
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
|
||||
// 清理消失或信号低的设备
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
const now = Date.now();
|
||||
setDevices((prev) =>
|
||||
prev.filter(
|
||||
(d) =>
|
||||
(d.advertisementData?.rssi ?? -999) >= -90 &&
|
||||
now - d.lastSeen < 3000
|
||||
)
|
||||
);
|
||||
}, 1000);
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
const stopScan = useCallback(() => {
|
||||
setScanning(false);
|
||||
try { Central.stopScan(); } catch {}
|
||||
if (handlerRef.current) {
|
||||
try { Central.removeListener("scannedPeripheral", handlerRef.current); } catch {}
|
||||
handlerRef.current = null;
|
||||
}
|
||||
try { Central.shutdown(); } catch {}
|
||||
}, []);
|
||||
|
||||
const startScan = useCallback(() => {
|
||||
stopScan();
|
||||
setDevices([]);
|
||||
setScanning(true);
|
||||
|
||||
const onScanned = (payload: CentralEventMap["scannedPeripheral"]) => {
|
||||
const p = payload?.peripheral;
|
||||
if (p?.name && p.advertisementData?.isConnectable) {
|
||||
updatePeripherals(p);
|
||||
}
|
||||
};
|
||||
handlerRef.current = onScanned;
|
||||
|
||||
try {
|
||||
Central.initialize();
|
||||
Central.addListener("scannedPeripheral", onScanned);
|
||||
Central.startScan([]);
|
||||
} catch (e) {
|
||||
console.warn("Central scan start error:", e);
|
||||
setScanning(false);
|
||||
handlerRef.current = null;
|
||||
try { Central.shutdown(); } catch {}
|
||||
}
|
||||
}, [stopScan, updatePeripherals]);
|
||||
|
||||
const onRefresh = useCallback(async () => {
|
||||
setRefreshing(true);
|
||||
stopScan();
|
||||
await new Promise<void>((resolve) => setTimeout(resolve, 200));
|
||||
startScan();
|
||||
setRefreshing(false);
|
||||
}, [stopScan, startScan]);
|
||||
|
||||
useEffect(() => {
|
||||
startScan();
|
||||
return () => { stopScan(); };
|
||||
}, [startScan, stopScan]);
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<FlatList
|
||||
data={devices}
|
||||
keyExtractor={(item) => item.systemId}
|
||||
renderItem={({ item }) => (
|
||||
<TouchableOpacity
|
||||
onPress={() => navigation.navigate("Info", { peripheral: item })}
|
||||
style={styles.deviceRow}
|
||||
>
|
||||
<View style={styles.deviceInfo}>
|
||||
<Text style={styles.deviceName}>{item.name || "[no name]"}</Text>
|
||||
<View style={styles.rssiRow}>
|
||||
{renderSignalIcon(item.advertisementData?.rssi)}
|
||||
<Text style={styles.rssiText}>
|
||||
{item.advertisementData?.rssi ?? "--"} dBm
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
ListEmptyComponent={() => (
|
||||
<View style={styles.emptyBox}>
|
||||
{scanning ? (
|
||||
<>
|
||||
<Text style={styles.emptyText}>搜索中...</Text>
|
||||
<Text style={styles.tips}>(请确保设备有电且被唤醒)</Text>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Text style={styles.emptyText}>暂无设备</Text>
|
||||
<Text style={styles.tips}>(请在设置中打开蓝牙)</Text>
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
refreshControl={<RefreshControl refreshing={refreshing} onRefresh={onRefresh} />}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: { flex: 1, backgroundColor: "#fff" },
|
||||
deviceRow: {
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
paddingVertical: 15,
|
||||
paddingHorizontal: 20,
|
||||
borderBottomWidth: 1,
|
||||
borderColor: "#eee",
|
||||
},
|
||||
deviceInfo: { flexDirection: "column" },
|
||||
deviceName: { fontSize: 16, fontWeight: "500" },
|
||||
rssiRow: { flexDirection: "row", alignItems: "center", marginTop: 4 },
|
||||
rssiText: { fontSize: 14, color: "#666", marginLeft: 6 },
|
||||
emptyBox: {
|
||||
flex: 1,
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
paddingHorizontal: 20,
|
||||
marginTop: 50,
|
||||
},
|
||||
emptyText: { fontSize: 18, fontWeight: "600", marginBottom: 8 },
|
||||
tips: { fontSize: 14, color: "#888", textAlign: "center" },
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
import React, { useEffect } from "react";
|
||||
import { View, Image, StyleSheet } from "react-native";
|
||||
import { NativeStackScreenProps } from "@react-navigation/native-stack";
|
||||
import { RootStackParamList } from "../App";
|
||||
|
||||
type Props = NativeStackScreenProps<RootStackParamList, "Splash">;
|
||||
|
||||
export default function SplashScreen({ navigation }: Props) {
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
navigation.replace("Home"); // ✅ 1.5秒后自动进入Home
|
||||
}, 1500);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}, [navigation]);
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<Image
|
||||
source={require("../assets/logo.png")} // ✅ 替换为你自己的图片
|
||||
style={styles.logo}
|
||||
resizeMode="contain"
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: "#E7141E", // ✅ 红色底
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
},
|
||||
logo: {
|
||||
width: 190, // ✅ 正方形图片
|
||||
height: 190,
|
||||
},
|
||||
});
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
declare module 'react-native-vector-icons/MaterialCommunityIcons';
|
||||
Reference in new issue
Block a user