Compare commits

...

9 Commits
1.0.0 ... main

Author SHA1 Message Date
8dd0edd2ce 20260603-① 适配桨频器,做了连接和设置页面,已经内测ok。
② 适配T5骑行台,做了相关页面。内测还不够充分,产品也不着急上市,所以此版更新中,应该需要隐藏掉。
③ 修复了之前 盘爪设备信息 读取时,有概率出现乱码的情况。读取时做了排序和延迟,修复了此问题。
④ 多语言我们这边做更新时,拉取的是仅有中英文的版本,当时你们那好像有更新更多语言?所以需要适配一下。
⑤ 蓝牙名搜索时,适配最新的固件名称:
盘爪为:PF-PM5-前缀,桨频器为PF-STK-前缀,骑行台为PF-T5-前缀。同时允许前缀POWERFUN-也能显示并连接。
2026-06-03 14:35:08 +08:00
Caiyanpeng
d3d6774448 ios版本号,图标bug,苹果16顶部 2025-12-25 18:02:03 +08:00
Caiyanpeng
790e8b0b98 Merge remote-tracking branch 'origin/main' 2025-12-25 17:05:03 +08:00
Caiyanpeng
41a2deaf04 ios 环境 2025-12-25 17:04:51 +08:00
1fa0b57355 版本号图标 2025-12-25 17:04:32 +08:00
5462cf5abe 按设计稿修改,中英文,ios图标问题,statusbar等 2025-12-25 16:16:01 +08:00
b6e6905d05 增加i18n需要的中英文对照表,但还没做功能 2025-12-19 11:58:52 +08:00
539c425a89 说明文字 2025-12-19 00:05:42 +08:00
d72b18f713 Initial commit of React Native app 2025-12-18 13:27:24 +08:00
50 changed files with 8312 additions and 1108 deletions

111
App.tsx
View File

@ -1,37 +1,91 @@
console.log("🔥 当前 App.tsx 已加载");
import * as React from "react"; import * as React from "react";
import { NavigationContainer } from "@react-navigation/native"; import { NavigationContainer } from "@react-navigation/native";
import { createNativeStackNavigator } from "@react-navigation/native-stack"; import { createNativeStackNavigator } from "@react-navigation/native-stack";
import HomeScreen from "./src/HomeScreen"; import HomeScreen from "./src/HomeScreen";
import ScanScreen from "./src/ScanScreen"; import ScanScreen from "./src/ScanScreen";
import ScanScreen2 from "./src/ScanScreen2";
import ScanScreen3 from "./src/ScanScreen3";
import InfoScreen from "./src/InfoScreen"; import InfoScreen from "./src/InfoScreen";
import DfuScreen from "./src/DfuScreen"; import DfuScreen from "./src/DfuScreen";
import PrivacyScreen from "./src/PrivacyScreen"; import PrivacyScreen from "./src/PrivacyScreen";
import SplashScreen from "./src/SplashScreen"; // ✅ 新增启动页 import SplashScreen from "./src/SplashScreen"; // ✅ 新增启动页
import SettingScreen from "./src/SettingScreen";
import './src/i18n'
import InfoScreen2 from "./src/InfoScreen2";
import InfoScreen3 from "./src/InfoScreen3";
import SpindownScreen from "./src/SpindownScreen";
import { decode } from "base-64";
// 不要 global.atob
// 不要 atob
const base64ToBytes = (base64: string): number[] => {
const binary = decode(base64);
const bytes: number[] = [];
for (let i = 0; i < binary.length; i++) {
bytes.push(binary.charCodeAt(i));
}
return bytes;
};
export type RootStackParamList = { export type RootStackParamList = {
Splash: undefined; Splash: undefined;
Home: undefined; Home: undefined;
Scan: undefined; Scan: undefined;
ScanScreen2: undefined;
ScanScreen3: undefined;
Info: { peripheral: any }; Info: { peripheral: any };
Dfu: { deviceId: string; name: string; firmware: string }; Info2: { peripheral: any };
Info3: { peripheral: any };
Spindown: { peripheral: any };
Dfu: {
deviceId: string;
systemId?: string;
address?: string | number;
name: string;
firmware: string;
};
Privacy: undefined; Privacy: undefined;
Setting: undefined;
}; };
const Stack = createNativeStackNavigator<RootStackParamList>(); const Stack = createNativeStackNavigator<RootStackParamList>();
export default function App() { export default function App() {
//linshi
React.useEffect(() => {
const test = async () => {
try {
console.log("🔥 App.tsx fetch test start");
const resp = await fetch("https://www.baidu.com");
console.log("🔥 App.tsx fetch status =", resp.status);
const text = await resp.text();
console.log("🔥 App.tsx fetch text length =", text.length);
} catch (e) {
console.log("❌ App.tsx fetch error =", e);
}
};
test();
}, []);
return ( return (
<NavigationContainer> <NavigationContainer>
<Stack.Navigator <Stack.Navigator
initialRouteName="Splash" initialRouteName="Splash"
// ✅ 统一所有导航栏样式
screenOptions={{ screenOptions={{
headerStyle: { backgroundColor: "#E7141E" }, // 顶部背景红色 animation : 'slide_from_right'
headerTintColor: "#fff", // 返回箭头和文字为白色 }}>
headerTitleStyle: { fontWeight: "bold", color: "#fff" }, // 标题白色+加粗 <Stack.Screen
}} name="Info2"
> component={InfoScreen2}
options={{ headerShown: false }}
/>
{/* 启动页(无标题) */} {/* 启动页(无标题) */}
<Stack.Screen <Stack.Screen
name="Splash" name="Splash"
@ -43,29 +97,62 @@ export default function App() {
<Stack.Screen <Stack.Screen
name="Home" name="Home"
component={HomeScreen} component={HomeScreen}
options={{ title: "" }} options={{ headerShown: false }}
/> />
{/* 其他页面默认显示标题栏 */} {/* 其他页面默认显示标题栏 */}
<Stack.Screen <Stack.Screen
name="Scan" name="Scan"
component={ScanScreen} component={ScanScreen}
options={{ title: "搜索设备" }} // options={{ title: "搜索设备" }}
options={{ headerShown: false }}
/>
<Stack.Screen
name="ScanScreen2"
component={ScanScreen2}
// options={{ title: "搜索设备" }}
options={{ headerShown: false }}
/>
<Stack.Screen
name="ScanScreen3"
component={ScanScreen3}
// options={{ title: "搜索设备" }}
options={{ headerShown: false }}
/> />
<Stack.Screen <Stack.Screen
name="Info" name="Info"
component={InfoScreen} component={InfoScreen}
options={{ title: "设备信息" }} // options={{ title: "设备详情" }}
options={{ headerShown: false }}
/>
<Stack.Screen
name="Info3"
component={InfoScreen3}
// options={{ title: "设备详情" }}
options={{ headerShown: false }}
/>
<Stack.Screen
name="Spindown"
component={SpindownScreen}
options={{ headerShown: false }}
/> />
<Stack.Screen <Stack.Screen
name="Dfu" name="Dfu"
component={DfuScreen} component={DfuScreen}
options={{ title: "固件升级" }} // options={{ title: "固件升级" }}
options={{ headerShown: false }}
/> />
<Stack.Screen <Stack.Screen
name="Privacy" name="Privacy"
component={PrivacyScreen} component={PrivacyScreen}
options={{ title: "隐私协议" }} // options={{ title: "隐私协议" }}
options={{ headerShown: false }}
/>
<Stack.Screen
name="Setting"
component={SettingScreen}
// options={{ title: "隐私协议" }}
options={{ headerShown: false }}
/> />
</Stack.Navigator> </Stack.Navigator>
</NavigationContainer> </NavigationContainer>

View File

@ -82,8 +82,8 @@ android {
applicationId "com.zhixingpai.powerfundfuapp" applicationId "com.zhixingpai.powerfundfuapp"
minSdkVersion rootProject.ext.minSdkVersion minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 1 versionCode 2
versionName "1.0.0" versionName "1.0.1"
} }
signingConfigs { signingConfigs {
debug { debug {

View File

@ -17,7 +17,8 @@
android:configChanges="keyboard|keyboardHidden|orientation|screenLayout|screenSize|smallestScreenSize|uiMode" android:configChanges="keyboard|keyboardHidden|orientation|screenLayout|screenSize|smallestScreenSize|uiMode"
android:launchMode="singleTask" android:launchMode="singleTask"
android:windowSoftInputMode="adjustResize" android:windowSoftInputMode="adjustResize"
android:exported="true"> android:exported="true"
android:screenOrientation="portrait">
<intent-filter> <intent-filter>
<action android:name="android.intent.action.MAIN" /> <action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" /> <category android:name="android.intent.category.LAUNCHER" />

View File

@ -0,0 +1,3 @@
<resources>
<string name="app_name">POWERFUN设置</string>
</resources>

View File

@ -0,0 +1,9 @@
<resources>
<!-- Base application theme. -->
<style name="AppTheme" parent="Theme.AppCompat.DayNight.NoActionBar">
<!-- Customize your theme here. -->
<item name="android:editTextBackground">@drawable/rn_edit_text_material</item>
</style>
</resources>

View File

@ -1,3 +1,3 @@
<resources> <resources>
<string name="app_name">POWERFUN设置</string> <string name="app_name">POWERFUN Settings</string>
</resources> </resources>

View File

@ -42,3 +42,7 @@ hermesEnabled=true
# This allows your app to draw behind system bars for an immersive UI. # This allows your app to draw behind system bars for an immersive UI.
# Note: Only works with ReactActivity and should not be used with custom Activity. # Note: Only works with ReactActivity and should not be used with custom Activity.
edgeToEdgeEnabled=false edgeToEdgeEnabled=false

0
android/gradlew vendored Normal file → Executable file
View File

Binary file not shown.

View File

@ -1,3 +1,4 @@
source 'https://cdn.cocoapods.org/'
# Resolve react_native_pods.rb with node to allow for hoisting # Resolve react_native_pods.rb with node to allow for hoisting
require Pod::Executable.execute_command('node', ['-p', require Pod::Executable.execute_command('node', ['-p',
'require.resolve( 'require.resolve(

View File

@ -2314,6 +2314,10 @@ PODS:
- React-perflogger (= 0.81.4) - React-perflogger (= 0.81.4)
- React-utils (= 0.81.4) - React-utils (= 0.81.4)
- SocketRocket - SocketRocket
- RNCAsyncStorage (2.2.0):
- React-Core
- RNDeviceInfo (15.0.2):
- React-Core
- RNFS (2.20.0): - RNFS (2.20.0):
- React-Core - React-Core
- RNScreens (4.16.0): - RNScreens (4.16.0):
@ -2452,6 +2456,8 @@ DEPENDENCIES:
- ReactAppDependencyProvider (from `build/generated/ios`) - ReactAppDependencyProvider (from `build/generated/ios`)
- ReactCodegen (from `build/generated/ios`) - ReactCodegen (from `build/generated/ios`)
- ReactCommon/turbomodule/core (from `../node_modules/react-native/ReactCommon`) - ReactCommon/turbomodule/core (from `../node_modules/react-native/ReactCommon`)
- "RNCAsyncStorage (from `../node_modules/@react-native-async-storage/async-storage`)"
- RNDeviceInfo (from `../node_modules/react-native-device-info`)
- RNFS (from `../node_modules/react-native-fs`) - RNFS (from `../node_modules/react-native-fs`)
- RNScreens (from `../node_modules/react-native-screens`) - RNScreens (from `../node_modules/react-native-screens`)
- RNVectorIcons (from `../node_modules/react-native-vector-icons`) - RNVectorIcons (from `../node_modules/react-native-vector-icons`)
@ -2612,6 +2618,10 @@ EXTERNAL SOURCES:
:path: build/generated/ios :path: build/generated/ios
ReactCommon: ReactCommon:
:path: "../node_modules/react-native/ReactCommon" :path: "../node_modules/react-native/ReactCommon"
RNCAsyncStorage:
:path: "../node_modules/@react-native-async-storage/async-storage"
RNDeviceInfo:
:path: "../node_modules/react-native-device-info"
RNFS: RNFS:
:path: "../node_modules/react-native-fs" :path: "../node_modules/react-native-fs"
RNScreens: RNScreens:
@ -2696,6 +2706,8 @@ SPEC CHECKSUMS:
ReactAppDependencyProvider: 433ddfb4536948630aadd5bd925aff8a632d2fe3 ReactAppDependencyProvider: 433ddfb4536948630aadd5bd925aff8a632d2fe3
ReactCodegen: 1d05923ad119796be9db37830d5e5dc76586aa00 ReactCodegen: 1d05923ad119796be9db37830d5e5dc76586aa00
ReactCommon: 394c6b92765cf6d211c2c3f7f6bc601dffb316a6 ReactCommon: 394c6b92765cf6d211c2c3f7f6bc601dffb316a6
RNCAsyncStorage: b44e8a4e798c3e1f56bffccd0f591f674fb9198f
RNDeviceInfo: 4c852998208b60dc192ae3529e5867817719ad1e
RNFS: 89de7d7f4c0f6bafa05343c578f61118c8282ed8 RNFS: 89de7d7f4c0f6bafa05343c578f61118c8282ed8
RNScreens: 35525ebfe219c8709da0d26aebbc9a5e02e1077b RNScreens: 35525ebfe219c8709da0d26aebbc9a5e02e1077b
RNVectorIcons: 9084b0cf37b3c5690b730c5627a21d750c9f517e RNVectorIcons: 9084b0cf37b3c5690b730c5627a21d750c9f517e
@ -2703,6 +2715,6 @@ SPEC CHECKSUMS:
Yoga: a3ed390a19db0459bd6839823a6ac6d9c6db198d Yoga: a3ed390a19db0459bd6839823a6ac6d9c6db198d
ZIPFoundation: b8c29ea7ae353b309bc810586181fd073cb3312c ZIPFoundation: b8c29ea7ae353b309bc810586181fd073cb3312c
PODFILE CHECKSUM: acd3fbdda50772beaab05ce909d5ed701116dee5 PODFILE CHECKSUM: a23d7c8b0484328e91cacd79617607a52e3cf7fb
COCOAPODS: 1.16.2 COCOAPODS: 1.16.2

View File

@ -11,6 +11,7 @@
13B07FBF1A68108700A75B9A /* Images.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 13B07FB51A68108700A75B9A /* Images.xcassets */; }; 13B07FBF1A68108700A75B9A /* Images.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 13B07FB51A68108700A75B9A /* Images.xcassets */; };
761780ED2CA45674006654EE /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 761780EC2CA45674006654EE /* AppDelegate.swift */; }; 761780ED2CA45674006654EE /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 761780EC2CA45674006654EE /* AppDelegate.swift */; };
81AB9BB82411601600AC10FF /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 81AB9BB72411601600AC10FF /* LaunchScreen.storyboard */; }; 81AB9BB82411601600AC10FF /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 81AB9BB72411601600AC10FF /* LaunchScreen.storyboard */; };
82A7EFF22EFD325200209B1F /* InfoPlist.strings in Resources */ = {isa = PBXBuildFile; fileRef = 82A7EFF02EFD325200209B1F /* InfoPlist.strings */; };
ADAFE5564C0102A1789FE063 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = 13B07FB81A68108700A75B9A /* PrivacyInfo.xcprivacy */; }; ADAFE5564C0102A1789FE063 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = 13B07FB81A68108700A75B9A /* PrivacyInfo.xcprivacy */; };
/* End PBXBuildFile section */ /* End PBXBuildFile section */
@ -24,6 +25,9 @@
5DCACB8F33CDC322A6C60F78 /* libPods-dfuapp.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-dfuapp.a"; sourceTree = BUILT_PRODUCTS_DIR; }; 5DCACB8F33CDC322A6C60F78 /* libPods-dfuapp.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-dfuapp.a"; sourceTree = BUILT_PRODUCTS_DIR; };
761780EC2CA45674006654EE /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = AppDelegate.swift; path = dfuapp/AppDelegate.swift; sourceTree = "<group>"; }; 761780EC2CA45674006654EE /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = AppDelegate.swift; path = dfuapp/AppDelegate.swift; sourceTree = "<group>"; };
81AB9BB72411601600AC10FF /* LaunchScreen.storyboard */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.storyboard; name = LaunchScreen.storyboard; path = dfuapp/LaunchScreen.storyboard; sourceTree = "<group>"; }; 81AB9BB72411601600AC10FF /* LaunchScreen.storyboard */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.storyboard; name = LaunchScreen.storyboard; path = dfuapp/LaunchScreen.storyboard; sourceTree = "<group>"; };
82A7EFF12EFD325200209B1F /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/InfoPlist.strings; sourceTree = "<group>"; };
82A7EFF32EFD325E00209B1F /* zh-Hans */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "zh-Hans"; path = "zh-Hans.lproj/InfoPlist.strings"; sourceTree = "<group>"; };
82A7EFF72EFD3C7000209B1F /* MaterialCommunityIcons.ttf */ = {isa = PBXFileReference; lastKnownFileType = file; path = MaterialCommunityIcons.ttf; sourceTree = "<group>"; };
ED297162215061F000B7C4FE /* JavaScriptCore.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = JavaScriptCore.framework; path = System/Library/Frameworks/JavaScriptCore.framework; sourceTree = SDKROOT; }; ED297162215061F000B7C4FE /* JavaScriptCore.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = JavaScriptCore.framework; path = System/Library/Frameworks/JavaScriptCore.framework; sourceTree = SDKROOT; };
/* End PBXFileReference section */ /* End PBXFileReference section */
@ -42,6 +46,8 @@
13B07FAE1A68108700A75B9A /* dfuapp */ = { 13B07FAE1A68108700A75B9A /* dfuapp */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
82A7EFF82EFD3C7000209B1F /* Fonts */,
82A7EFF02EFD325200209B1F /* InfoPlist.strings */,
13B07FB51A68108700A75B9A /* Images.xcassets */, 13B07FB51A68108700A75B9A /* Images.xcassets */,
761780EC2CA45674006654EE /* AppDelegate.swift */, 761780EC2CA45674006654EE /* AppDelegate.swift */,
13B07FB61A68108700A75B9A /* Info.plist */, 13B07FB61A68108700A75B9A /* Info.plist */,
@ -60,6 +66,14 @@
name = Frameworks; name = Frameworks;
sourceTree = "<group>"; sourceTree = "<group>";
}; };
82A7EFF82EFD3C7000209B1F /* Fonts */ = {
isa = PBXGroup;
children = (
82A7EFF72EFD3C7000209B1F /* MaterialCommunityIcons.ttf */,
);
path = Fonts;
sourceTree = "<group>";
};
832341AE1AAA6A7D00B99B32 /* Libraries */ = { 832341AE1AAA6A7D00B99B32 /* Libraries */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
@ -142,6 +156,7 @@
knownRegions = ( knownRegions = (
en, en,
Base, Base,
"zh-Hans",
); );
mainGroup = 83CBB9F61A601CBA00E9B192; mainGroup = 83CBB9F61A601CBA00E9B192;
productRefGroup = 83CBBA001A601CBA00E9B192 /* Products */; productRefGroup = 83CBBA001A601CBA00E9B192 /* Products */;
@ -159,6 +174,7 @@
buildActionMask = 2147483647; buildActionMask = 2147483647;
files = ( files = (
81AB9BB82411601600AC10FF /* LaunchScreen.storyboard in Resources */, 81AB9BB82411601600AC10FF /* LaunchScreen.storyboard in Resources */,
82A7EFF22EFD325200209B1F /* InfoPlist.strings in Resources */,
13B07FBF1A68108700A75B9A /* Images.xcassets in Resources */, 13B07FBF1A68108700A75B9A /* Images.xcassets in Resources */,
ADAFE5564C0102A1789FE063 /* PrivacyInfo.xcprivacy in Resources */, ADAFE5564C0102A1789FE063 /* PrivacyInfo.xcprivacy in Resources */,
); );
@ -191,10 +207,14 @@
inputFileListPaths = ( inputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-dfuapp/Pods-dfuapp-frameworks-${CONFIGURATION}-input-files.xcfilelist", "${PODS_ROOT}/Target Support Files/Pods-dfuapp/Pods-dfuapp-frameworks-${CONFIGURATION}-input-files.xcfilelist",
); );
inputPaths = (
);
name = "[CP] Embed Pods Frameworks"; name = "[CP] Embed Pods Frameworks";
outputFileListPaths = ( outputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-dfuapp/Pods-dfuapp-frameworks-${CONFIGURATION}-output-files.xcfilelist", "${PODS_ROOT}/Target Support Files/Pods-dfuapp/Pods-dfuapp-frameworks-${CONFIGURATION}-output-files.xcfilelist",
); );
outputPaths = (
);
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh; shellPath = /bin/sh;
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-dfuapp/Pods-dfuapp-frameworks.sh\"\n"; shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-dfuapp/Pods-dfuapp-frameworks.sh\"\n";
@ -230,10 +250,14 @@
inputFileListPaths = ( inputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-dfuapp/Pods-dfuapp-resources-${CONFIGURATION}-input-files.xcfilelist", "${PODS_ROOT}/Target Support Files/Pods-dfuapp/Pods-dfuapp-resources-${CONFIGURATION}-input-files.xcfilelist",
); );
inputPaths = (
);
name = "[CP] Copy Pods Resources"; name = "[CP] Copy Pods Resources";
outputFileListPaths = ( outputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-dfuapp/Pods-dfuapp-resources-${CONFIGURATION}-output-files.xcfilelist", "${PODS_ROOT}/Target Support Files/Pods-dfuapp/Pods-dfuapp-resources-${CONFIGURATION}-output-files.xcfilelist",
); );
outputPaths = (
);
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh; shellPath = /bin/sh;
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-dfuapp/Pods-dfuapp-resources.sh\"\n"; shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-dfuapp/Pods-dfuapp-resources.sh\"\n";
@ -252,6 +276,18 @@
}; };
/* End PBXSourcesBuildPhase section */ /* End PBXSourcesBuildPhase section */
/* Begin PBXVariantGroup section */
82A7EFF02EFD325200209B1F /* InfoPlist.strings */ = {
isa = PBXVariantGroup;
children = (
82A7EFF12EFD325200209B1F /* en */,
82A7EFF32EFD325E00209B1F /* zh-Hans */,
);
name = InfoPlist.strings;
sourceTree = "<group>";
};
/* End PBXVariantGroup section */
/* Begin XCBuildConfiguration section */ /* Begin XCBuildConfiguration section */
13B07F941A680F5B00A75B9A /* Debug */ = { 13B07F941A680F5B00A75B9A /* Debug */ = {
isa = XCBuildConfiguration; isa = XCBuildConfiguration;
@ -260,7 +296,7 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES; CLANG_ENABLE_MODULES = YES;
CURRENT_PROJECT_VERSION = 1; CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = B7ZA544T59; DEVELOPMENT_TEAM = PXHWD6972V;
ENABLE_BITCODE = NO; ENABLE_BITCODE = NO;
INFOPLIST_FILE = dfuapp/Info.plist; INFOPLIST_FILE = dfuapp/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = "POWERFUN设置"; INFOPLIST_KEY_CFBundleDisplayName = "POWERFUN设置";
@ -269,13 +305,13 @@
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
); );
MARKETING_VERSION = 1.0.0; MARKETING_VERSION = 1.0.1;
OTHER_LDFLAGS = ( OTHER_LDFLAGS = (
"$(inherited)", "$(inherited)",
"-ObjC", "-ObjC",
"-lc++", "-lc++",
); );
PRODUCT_BUNDLE_IDENTIFIER = com.zhixingpai.powerfundfuapp; PRODUCT_BUNDLE_IDENTIFIER = com.zhixingpai.powerfundfuapp123;
PRODUCT_NAME = dfuapp; PRODUCT_NAME = dfuapp;
SWIFT_OPTIMIZATION_LEVEL = "-Onone"; SWIFT_OPTIMIZATION_LEVEL = "-Onone";
SWIFT_VERSION = 5.0; SWIFT_VERSION = 5.0;
@ -290,7 +326,7 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES; CLANG_ENABLE_MODULES = YES;
CURRENT_PROJECT_VERSION = 1; CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = B7ZA544T59; DEVELOPMENT_TEAM = PXHWD6972V;
INFOPLIST_FILE = dfuapp/Info.plist; INFOPLIST_FILE = dfuapp/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = "POWERFUN设置"; INFOPLIST_KEY_CFBundleDisplayName = "POWERFUN设置";
IPHONEOS_DEPLOYMENT_TARGET = 15.1; IPHONEOS_DEPLOYMENT_TARGET = 15.1;
@ -298,13 +334,13 @@
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
); );
MARKETING_VERSION = 1.0.0; MARKETING_VERSION = 1.0.1;
OTHER_LDFLAGS = ( OTHER_LDFLAGS = (
"$(inherited)", "$(inherited)",
"-ObjC", "-ObjC",
"-lc++", "-lc++",
); );
PRODUCT_BUNDLE_IDENTIFIER = com.zhixingpai.powerfundfuapp; PRODUCT_BUNDLE_IDENTIFIER = com.zhixingpai.powerfundfuapp123;
PRODUCT_NAME = dfuapp; PRODUCT_NAME = dfuapp;
SWIFT_VERSION = 5.0; SWIFT_VERSION = 5.0;
VERSIONING_SYSTEM = "apple-generic"; VERSIONING_SYSTEM = "apple-generic";

View File

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="UTF-8"?>
<Workspace
version = "1.0">
</Workspace>

View File

@ -22,6 +22,8 @@
<string>????</string> <string>????</string>
<key>CFBundleVersion</key> <key>CFBundleVersion</key>
<string>$(CURRENT_PROJECT_VERSION)</string> <string>$(CURRENT_PROJECT_VERSION)</string>
<key>ITSAppUsesNonExemptEncryption</key>
<false/>
<key>LSRequiresIPhoneOS</key> <key>LSRequiresIPhoneOS</key>
<true/> <true/>
<key>NSAppTransportSecurity</key> <key>NSAppTransportSecurity</key>
@ -31,19 +33,18 @@
<key>NSAllowsLocalNetworking</key> <key>NSAllowsLocalNetworking</key>
<true/> <true/>
</dict> </dict>
<!-- iOS 13+ 需要的蓝牙权限 -->
<key>NSBluetoothAlwaysUsageDescription</key> <key>NSBluetoothAlwaysUsageDescription</key>
<string>我们需要使用蓝牙连接您的功率计设备</string> <string>我们需要使用蓝牙连接您的功率计设备</string>
<!-- iOS 12 及以下版本需要的蓝牙权限(向后兼容) -->
<key>NSBluetoothPeripheralUsageDescription</key> <key>NSBluetoothPeripheralUsageDescription</key>
<string>我们需要使用蓝牙连接您的功率计设备</string> <string>我们需要使用蓝牙连接您的功率计设备</string>
<!-- 位置权限Android 和某些 iOS 蓝牙扫描需要) -->
<key>NSLocationWhenInUseUsageDescription</key> <key>NSLocationWhenInUseUsageDescription</key>
<string>我们需要位置权限来扫描附近的蓝牙功率计设备</string> <string>我们需要位置权限来扫描附近的蓝牙功率计设备</string>
<key>RCTNewArchEnabled</key> <key>RCTNewArchEnabled</key>
<false/> <false/>
<key>UIAppFonts</key>
<array>
<string>MaterialCommunityIcons.ttf</string>
</array>
<key>UILaunchStoryboardName</key> <key>UILaunchStoryboardName</key>
<string>LaunchScreen</string> <string>LaunchScreen</string>
<key>UIRequiredDeviceCapabilities</key> <key>UIRequiredDeviceCapabilities</key>
@ -53,8 +54,6 @@
<key>UISupportedInterfaceOrientations</key> <key>UISupportedInterfaceOrientations</key>
<array> <array>
<string>UIInterfaceOrientationPortrait</string> <string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array> </array>
<key>UIViewControllerBasedStatusBarAppearance</key> <key>UIViewControllerBasedStatusBarAppearance</key>
<false/> <false/>

View File

@ -13,6 +13,14 @@
<string>3B52.1</string> <string>3B52.1</string>
</array> </array>
</dict> </dict>
<dict>
<key>NSPrivacyAccessedAPIType</key>
<string>NSPrivacyAccessedAPICategorySystemBootTime</string>
<key>NSPrivacyAccessedAPITypeReasons</key>
<array>
<string>35F9.1</string>
</array>
</dict>
<dict> <dict>
<key>NSPrivacyAccessedAPIType</key> <key>NSPrivacyAccessedAPIType</key>
<string>NSPrivacyAccessedAPICategoryUserDefaults</string> <string>NSPrivacyAccessedAPICategoryUserDefaults</string>
@ -23,10 +31,10 @@
</dict> </dict>
<dict> <dict>
<key>NSPrivacyAccessedAPIType</key> <key>NSPrivacyAccessedAPIType</key>
<string>NSPrivacyAccessedAPICategorySystemBootTime</string> <string>NSPrivacyAccessedAPICategoryDiskSpace</string>
<key>NSPrivacyAccessedAPITypeReasons</key> <key>NSPrivacyAccessedAPITypeReasons</key>
<array> <array>
<string>35F9.1</string> <string>85F4.1</string>
</array> </array>
</dict> </dict>
</array> </array>

View File

@ -0,0 +1,2 @@
CFBundleDisplayName = "POWERFUN Settings";
CFBundleName = "POWERFUN Settings";

View File

@ -0,0 +1,2 @@
CFBundleDisplayName = "POWERFUN设置";
CFBundleName = "POWERFUN设置";

742
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -4,6 +4,7 @@
"private": true, "private": true,
"scripts": { "scripts": {
"android": "react-native run-android", "android": "react-native run-android",
"clean-a": "cd android && gradlew clean",
"ios": "react-native run-ios", "ios": "react-native run-ios",
"lint": "eslint .", "lint": "eslint .",
"start": "react-native start", "start": "react-native start",
@ -11,27 +12,34 @@
"build-a": "set NODE_OPTIONS=--openssl-legacy-provider && cd android&&gradlew assembleRelease" "build-a": "set NODE_OPTIONS=--openssl-legacy-provider && cd android&&gradlew assembleRelease"
}, },
"dependencies": { "dependencies": {
"@react-native-async-storage/async-storage": "^2.2.0",
"@react-native/codegen": "0.81.4", "@react-native/codegen": "0.81.4",
"@react-native/new-app-screen": "0.81.4", "@react-native/new-app-screen": "0.81.4",
"@react-navigation/native": "^7.1.17", "@react-navigation/native": "^7.1.17",
"@react-navigation/native-stack": "^7.3.26", "@react-navigation/native-stack": "^7.3.26",
"@systemic-games/react-native-bluetooth-le": "^1.3.0", "@systemic-games/react-native-bluetooth-le": "^1.3.0",
"@systemic-games/react-native-nordic-nrf5-dfu": "^1.3.0", "@systemic-games/react-native-nordic-nrf5-dfu": "^1.3.0",
"@types/lodash": "^4.17.21",
"base-64": "^1.0.0", "base-64": "^1.0.0",
"i18next": "^25.7.3",
"react": "19.1.0", "react": "19.1.0",
"react-i18next": "^16.5.0",
"react-native": "0.81.4", "react-native": "0.81.4",
"react-native-device-info": "^15.0.1",
"react-native-fs": "^2.20.0", "react-native-fs": "^2.20.0",
"react-native-safe-area-context": "^5.6.1", "react-native-safe-area-context": "^5.6.1",
"react-native-safearea-height": "^1.0.8",
"react-native-screens": "^4.16.0", "react-native-screens": "^4.16.0",
"react-native-toast-message": "^2.3.3",
"react-native-vector-icons": "^10.3.0" "react-native-vector-icons": "^10.3.0"
}, },
"devDependencies": { "devDependencies": {
"@babel/core": "^7.25.2", "@babel/core": "^7.25.2",
"@babel/preset-env": "^7.25.3", "@babel/preset-env": "^7.25.3",
"@babel/runtime": "^7.25.0", "@babel/runtime": "^7.25.0",
"@react-native-community/cli": "20.0.0", "@react-native-community/cli": "15.0.0",
"@react-native-community/cli-platform-android": "20.0.0", "@react-native-community/cli-platform-android": "15.0.0",
"@react-native-community/cli-platform-ios": "20.0.0", "@react-native-community/cli-platform-ios": "15.0.0",
"@react-native/babel-preset": "0.81.4", "@react-native/babel-preset": "0.81.4",
"@react-native/eslint-config": "0.81.4", "@react-native/eslint-config": "0.81.4",
"@react-native/metro-config": "0.81.4", "@react-native/metro-config": "0.81.4",
@ -45,7 +53,10 @@
"prettier": "2.8.8", "prettier": "2.8.8",
"react-test-renderer": "19.1.0", "react-test-renderer": "19.1.0",
"typescript": "^5.8.3" "typescript": "^5.8.3"
}, },
"engines": { "engines": {
"node": ">=20" "node": ">=20"
} }

View File

@ -1,9 +1,18 @@
import React, { useEffect, useState } from "react"; import React, { useEffect, useState } from "react";
import { View, Text, StyleSheet, Alert, BackHandler } from "react-native"; import { View, Text, StyleSheet, Alert, Platform } from "react-native";
import { NativeStackScreenProps } from "@react-navigation/native-stack"; import { NativeStackScreenProps } from "@react-navigation/native-stack";
import { RootStackParamList } from "../App"; import { RootStackParamList } from "../App";
import RNFS from "react-native-fs"; import RNFS from "react-native-fs";
import { startDfu, DfuProgressEvent, DfuStateEvent } from "@systemic-games/react-native-nordic-nrf5-dfu"; import {
startDfu,
getDfuTargetId,
DfuProgressEvent,
DfuStateEvent,
} from "@systemic-games/react-native-nordic-nrf5-dfu";
import { useTranslation } from "react-i18next";
import { encode as btoa } from "base-64";
import MyStatusbar from "./component/MyStatusbar";
import MyHeader from "./component/MyHeader";
type Props = NativeStackScreenProps<RootStackParamList, "Dfu">; type Props = NativeStackScreenProps<RootStackParamList, "Dfu">;
@ -13,40 +22,98 @@ interface DeviceInfo {
download: string; download: string;
} }
interface ParsedFirmware {
hardware: number;
iteration: number;
build: string;
raw: string;
}
const bytesToBase64 = (bytes: Uint8Array): string => {
let binary = "";
const chunkSize = 0x8000;
for (let i = 0; i < bytes.length; i += chunkSize) {
const chunk = bytes.subarray(i, i + chunkSize);
binary += String.fromCharCode(...chunk);
}
return btoa(binary);
};
const parseFirmwareVersion = (text: string): ParsedFirmware => {
const raw = String(text ?? "").trim();
const parts = raw.split(".");
if (parts.length < 2) {
throw new Error(`固件版本格式不正确: ${raw}`);
}
const hardware = parseInt(parts[0], 10);
const iteration = parseInt(parts[1], 10);
const build = parts.length >= 3 ? parts[2] : "";
if (Number.isNaN(hardware) || Number.isNaN(iteration)) {
throw new Error(`固件版本无法解析: ${raw}`);
}
return {
hardware,
iteration,
build,
raw,
};
};
export default function DfuScreen({ route, navigation }: Props) { export default function DfuScreen({ route, navigation }: Props) {
const { deviceId, name, firmware: deviceFirmware } = route.params; const { t } = useTranslation();
const {
deviceId,
systemId,
address,
name,
firmware: deviceFirmware,
} = route.params;
const [progress, setProgress] = useState(0); const [progress, setProgress] = useState(0);
const [state, setState] = useState("准备中..."); const [state, setState] = useState("准备中...");
const [error, setError] = useState<string>(); const [error, setError] = useState<string>();
const [latestVersion, setLatestVersion] = useState<string>("读取中..."); const [latestVersion, setLatestVersion] = useState("读取中...");
const [isDfuRunning, setIsDfuRunning] = useState(false); const [isDfuRunning, setIsDfuRunning] = useState(false);
const mapDfuStateToChinese = (state: string): string => { const mapDfuStateToChinese = (s: string): string => {
switch (state) { switch (s) {
case "connecting": return "连接中…"; case "connecting":
case "starting": return "初始化中…"; return "连接中…";
case "enablingDfuMode": return "启用 DFU 模式…"; case "starting":
case "uploading": return "上传固件中…"; return "初始化中…";
case "validating": return "校验固件…"; case "enablingDfuMode":
case "disconnecting": return "断开连接…"; return "启用 DFU 模式…";
case "completed": return "升级完成"; case "uploading":
case "aborted": return "已取消"; return "上传固件中…";
case "validating":
return "校验固件…";
case "disconnecting":
return "断开连接…";
case "completed":
return "升级完成";
case "aborted":
return "已取消";
case "failed": case "failed":
case "dfu_failed": return "升级失败"; case "dfu_failed":
case "initializing": return "启动中…"; return "升级失败";
case "errored": return "升级出错!"; case "initializing":
default: return state; return "启动中…";
case "errored":
return "升级出错!";
default:
return s;
} }
}; };
// ✅ 拦截所有导航返回iOS + Android
useEffect(() => { useEffect(() => {
const unsubscribe = navigation.addListener("beforeRemove", (e) => { const unsubscribe = navigation.addListener("beforeRemove", (e) => {
if (!isDfuRunning) return; if (!isDfuRunning) return;
// 阻止返回
e.preventDefault(); e.preventDefault();
Alert.alert("请稍候", "正在升级,请勿返回或关闭应用!"); Alert.alert("请稍候", "正在升级,请勿返回或关闭应用!");
}); });
@ -54,40 +121,95 @@ export default function DfuScreen({ route, navigation }: Props) {
return unsubscribe; return unsubscribe;
}, [navigation, isDfuRunning]); }, [navigation, isDfuRunning]);
useEffect(() => { useEffect(() => {
const runDfu = async () => { const runDfu = async () => {
try { try {
setIsDfuRunning(true); setIsDfuRunning(true);
setError(undefined);
const manifestUrl = "https://powerfun.oss-cn-shanghai.aliyuncs.com/yecongdfu/latest.json"; const rawDeviceId = String(deviceId ?? "").trim();
const rawSystemId = String(systemId ?? rawDeviceId).trim();
const rawAddress =
typeof address === "number"
? address
: address !== undefined && address !== null
? Number(address)
: undefined;
const safeDeviceId = getDfuTargetId({
systemId: rawSystemId,
address: rawAddress,
});
const firmwareText = String(deviceFirmware ?? "").trim();
console.log("🔥 rawDeviceId =", rawDeviceId);
console.log("🔥 rawSystemId =", rawSystemId);
console.log("🔥 rawAddress =", rawAddress);
console.log("🔥 safeDeviceId =", safeDeviceId);
console.log("🔥 firmwareText =", JSON.stringify(firmwareText));
if (!safeDeviceId) {
throw new Error("无法生成 DFU 目标设备 ID");
}
const currentFw = parseFirmwareVersion(firmwareText);
console.log("🔥 currentFw =", currentFw);
const manifestUrl =
"https://powerfun.oss-cn-shanghai.aliyuncs.com/yecongdfu/latest.json";
const manifestPath = RNFS.CachesDirectoryPath + "/latest.json"; const manifestPath = RNFS.CachesDirectoryPath + "/latest.json";
await RNFS.downloadFile({ fromUrl: manifestUrl, toFile: manifestPath }).promise; console.log("🔥 before fetch manifest");
const manifestContent = await RNFS.readFile(manifestPath); const manifestResp = await fetch(manifestUrl);
const manifest = JSON.parse(manifestContent) as { devices: DeviceInfo[] }; console.log("🔥 manifest status =", manifestResp.status);
const [deviceHWStr, deviceFWStr] = deviceFirmware.split("."); if (!manifestResp.ok) {
const deviceHW = parseInt(deviceHWStr); throw new Error(`manifest 下载失败HTTP ${manifestResp.status}`);
const deviceFW = parseInt(deviceFWStr); }
const manifestText = await manifestResp.text();
console.log("🔥 manifest text =", manifestText);
await RNFS.writeFile(manifestPath, manifestText, "utf8");
console.log("🔥 manifest saved =", manifestPath);
let manifest: { devices: DeviceInfo[] };
try {
manifest = JSON.parse(manifestText) as { devices: DeviceInfo[] };
} catch (e) {
throw new Error("manifest 不是合法 JSON: " + manifestText.slice(0, 200));
}
const deviceInfo = manifest.devices.find(
(d) => d.hardware === currentFw.hardware
);
console.log("🔥 matched deviceInfo =", deviceInfo);
const deviceInfo = manifest.devices.find(d => d.hardware === deviceHW);
if (!deviceInfo) { if (!deviceInfo) {
setIsDfuRunning(false); setIsDfuRunning(false);
Alert.alert("无法升级", `未找到硬件版本 ${deviceHW} 的固件`, [ Alert.alert(
{ text: "确认", onPress: () => navigation.goBack() }, "无法升级",
]); `未找到 hardware=${currentFw.hardware} 的固件`,
[{ text: "确认", onPress: () => navigation.goBack() }]
);
return; return;
} }
setLatestVersion(deviceInfo.latestFirmware); setLatestVersion(deviceInfo.latestFirmware);
const [, latestFWStr] = deviceInfo.latestFirmware.split("."); const latestFw = parseFirmwareVersion(deviceInfo.latestFirmware);
const latestFW = parseInt(latestFWStr); console.log("🔥 latestFw =", latestFw);
if (latestFW <= deviceFW) { if (latestFw.hardware !== currentFw.hardware) {
throw new Error(
`服务器固件硬件号不匹配:当前 ${currentFw.hardware},服务器 ${latestFw.hardware}`
);
}
if (latestFw.iteration <= currentFw.iteration) {
setIsDfuRunning(false); setIsDfuRunning(false);
Alert.alert("无需升级", "已是最新固件,无需升级", [ Alert.alert("无需升级", "已是最新固件,无需升级", [
{ text: "确认", onPress: () => navigation.goBack() }, { text: "确认", onPress: () => navigation.goBack() },
@ -95,66 +217,142 @@ export default function DfuScreen({ route, navigation }: Props) {
return; return;
} }
const localPath = RNFS.CachesDirectoryPath + "/firmware.zip"; console.log("🔥 upgrade allowed", {
await RNFS.downloadFile({ fromUrl: deviceInfo.download, toFile: localPath }).promise; currentIteration: currentFw.iteration,
latestIteration: latestFw.iteration,
});
await startDfu(deviceId, "file://" + localPath, { const zipResp = await fetch(deviceInfo.download);
dfuStateListener: (ev: DfuStateEvent) => setState(ev.state), console.log("🔥 zip status =", zipResp.status);
dfuProgressListener: (ev: DfuProgressEvent) => setProgress(ev.percent),
if (!zipResp.ok) {
throw new Error(`固件包下载失败HTTP ${zipResp.status}`);
}
const zipArrayBuffer = await zipResp.arrayBuffer();
const zipBytes = new Uint8Array(zipArrayBuffer);
console.log("🔥 zip bytes =", zipBytes.length);
const zipBase64 = bytesToBase64(zipBytes);
const localPath = RNFS.CachesDirectoryPath + "/firmware.zip";
await RNFS.writeFile(localPath, zipBase64, "base64");
console.log("🔥 zip saved =", localPath);
const dfuFilePath =
Platform.OS === "android" ? "file://" + localPath : localPath;
console.log("🔥 before startDfu =", {
safeDeviceId,
dfuFilePath,
currentFirmware: currentFw.raw,
latestFirmware: latestFw.raw,
});
await startDfu(safeDeviceId, dfuFilePath, {
dfuStateListener: (ev: DfuStateEvent) => {
console.log("🔥 dfu state =", ev.state);
setState(ev.state);
},
dfuProgressListener: (ev: DfuProgressEvent) => {
console.log("🔥 dfu progress =", ev.percent);
setProgress(ev.percent);
},
}); });
setIsDfuRunning(false); setIsDfuRunning(false);
Alert.alert("升级成功", "升级成功,请重连设备", [ Alert.alert("升级成功", "升级成功,请重连设备", [
{ text: "确认", onPress: () => navigation.navigate("Home") }, {
]); text: "确认",
onPress: () => {
navigation.reset({
index: 0,
routes: [{ name: "Home" }],
});
},
},
]);
} catch (err: any) { } catch (err: any) {
console.log("❌ runDfu error =", err);
setIsDfuRunning(false); setIsDfuRunning(false);
setError(err.message || "DFU失败"); setError(err?.message || "DFU失败");
Alert.alert("升级失败", err.message || "DFU失败", [ Alert.alert("升级失败", err?.message || "DFU失败", [
{ text: "确认", onPress: () => navigation.goBack() }, { text: "确认", onPress: () => navigation.goBack() },
]); ]);
} }
}; };
runDfu(); runDfu();
}, [deviceId, deviceFirmware, navigation]); }, [deviceId, systemId, address, deviceFirmware, navigation]);
return ( return (
<View style={{ flex: 1, padding: 20 }}> <View style={styles.container}>
<MyStatusbar backgroundColor="#FFFFFF" dark />
<MyHeader
title="固件升级"
textColor="#333"
backgroundColor="#FFFFFF"
navigation={navigation}
/>
<View style={styles.content}>
<View style={styles.row}> <View style={styles.row}>
<Text style={{ fontSize: 18 }}>: {name}</Text> <Text style={styles.titleText}>: {name || "--"}</Text>
</View> </View>
<View style={styles.row}>
<Text style={{ fontSize: 16 }}>: {latestVersion}</Text> <View style={styles.row}>
</View> <Text style={styles.normalText}>: {latestVersion}</Text>
<View style={styles.row}> </View>
<Text style={{ fontSize: 16 }}>: {deviceFirmware}</Text>
</View> <View style={styles.row}>
<View style={styles.row}> <Text style={styles.normalText}>: {deviceFirmware || "--"}</Text>
<Text style={{ fontSize: 16 }}>: {mapDfuStateToChinese(state)}</Text> </View>
<View style={styles.row}>
<Text style={styles.normalText}>
: {mapDfuStateToChinese(state)}
</Text>
</View> </View>
{/* 横向进度条 */}
<View style={styles.progressContainer}> <View style={styles.progressContainer}>
<View style={[styles.progressBar, { width: `${progress}%` }]} /> <View style={[styles.progressBar, { width: `${progress}%` }]} />
<Text style={styles.progressText}>{progress}%</Text> <Text style={styles.progressText}>{progress}%</Text>
</View> </View>
{error && <Text style={{ color: "red", marginTop: 20 }}>{error}</Text>} {!!error && <Text style={styles.errorText}>{error}</Text>}
</View>
</View> </View>
); );
} }
const styles = StyleSheet.create({ const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: "#FFFFFF",
},
content: {
flex: 1,
padding: 20,
backgroundColor: "#FFFFFF",
},
row: { row: {
borderBottomWidth: 1, borderBottomWidth: 1,
borderBottomColor: "red", borderBottomColor: "#E7141E",
paddingBottom: 4, paddingBottom: 6,
marginBottom: 8, marginBottom: 12,
},
titleText: {
fontSize: 18,
color: "#111111",
fontWeight: "600",
},
normalText: {
fontSize: 16,
color: "#222222",
}, },
progressContainer: { progressContainer: {
height: 30, height: 30,
backgroundColor: "#eee", backgroundColor: "#EEEEEE",
borderRadius: 15, borderRadius: 15,
overflow: "hidden", overflow: "hidden",
marginTop: 40, marginTop: 40,
@ -168,6 +366,11 @@ const styles = StyleSheet.create({
position: "absolute", position: "absolute",
alignSelf: "center", alignSelf: "center",
fontWeight: "bold", fontWeight: "bold",
color: "#000", color: "#000000",
},
errorText: {
color: "red",
marginTop: 20,
fontSize: 14,
}, },
}); });

View File

@ -1,32 +1,47 @@
import React from "react"; import React from "react";
import { View, Text, TouchableOpacity, StyleSheet, StatusBar } from "react-native"; import { View, Text, TouchableOpacity, StyleSheet, StatusBar, Image } from "react-native";
import { NativeStackScreenProps } from "@react-navigation/native-stack"; import { NativeStackScreenProps,NativeStackNavigationProp } from "@react-navigation/native-stack";
import { RootStackParamList } from "../App"; import { RootStackParamList } from "../App";
import MyStatusbar from "./component/MyStatusbar";
import MyHeader from "./component/MyHeader";
import pxToDp from "./helper/pxToDp";
import DeviceInfo from "react-native-device-info";
import { useTranslation } from 'react-i18next';
type Props = NativeStackScreenProps<RootStackParamList, "Home">; type Props = NativeStackScreenProps<RootStackParamList>;
export default function HomeScreen({ navigation }: Props) { export default function HomeScreen({ navigation }: Props) {
const { t } = useTranslation();
return ( return (
<View style={styles.container}> <View style={styles.container}>
{/* 设置状态栏颜色 */} {/* 设置状态栏颜色 */}
<StatusBar backgroundColor="#E7141E" barStyle="light-content" /> <MyStatusbar backgroundColor="#E7141E" dark={false}></MyStatusbar>
<MyHeader title={t('home.title')} textColor="#fff" backgroundColor="#E7141E" hideBack navigation={navigation}
rightView={<TouchableOpacity style={{width:pxToDp(56),height:pxToDp(56),alignItems:'center',justifyContent:'center'}}
onPress={() => navigation.navigate("Setting")}>
<Image source={require('./img/Settings.png')} style={{width:pxToDp(48),height:pxToDp(44)}}></Image>
</TouchableOpacity>}></MyHeader>
{/* 中间按钮 */} {/* 中间按钮 */}
<View style={styles.centerBox}> <View style={styles.centerBox}>
<Image source={require("./img/Search.png")} style={{width:pxToDp(568),height:pxToDp(344),marginBottom:pxToDp(100)}}></Image>
<TouchableOpacity <TouchableOpacity
style={styles.button} style={styles.button}
onPress={() => navigation.navigate("Scan")} onPress={() => navigation.navigate("Scan")}
> >
<Text style={styles.buttonText}></Text> <Text style={styles.buttonText}>{t("home.powerMeter")}</Text>
</TouchableOpacity> </TouchableOpacity>
</View> <TouchableOpacity
style={[styles.button, { marginTop: pxToDp(20) }]}
{/* 底部区域 */} onPress={() => navigation.navigate("ScanScreen2")}
<View style={styles.bottomBox}> >
<TouchableOpacity onPress={() => navigation.navigate("Privacy")}> <Text style={styles.buttonText}>{t("home.paddle")}</Text>
<Text style={styles.privacyText}></Text> </TouchableOpacity>
<TouchableOpacity
style={[styles.button, { marginTop: pxToDp(20) }]}
onPress={() => navigation.navigate("ScanScreen3")}
>
<Text style={styles.buttonText}>{t("home.T5trainer")}</Text>
</TouchableOpacity> </TouchableOpacity>
<Text style={styles.version}> v0.0.1</Text>
</View> </View>
</View> </View>
); );
@ -39,18 +54,22 @@ const styles = StyleSheet.create({
}, },
centerBox: { centerBox: {
flex: 1, flex: 1,
justifyContent: "center", // justifyContent: "center",
alignItems: "center", alignItems: "center",
backgroundColor:'#fff',
paddingTop:pxToDp(250)
}, },
button: { button: {
backgroundColor: "#E7141E", // 红色按钮 backgroundColor: "#E7141E",
paddingVertical: 15, borderRadius: pxToDp(24),
paddingHorizontal: 40, width:pxToDp(300),
borderRadius: 10, height:pxToDp(96),
alignItems:'center',
justifyContent:'center'
}, },
buttonText: { buttonText: {
color: "#fff", color: "#fff",
fontSize: 18, fontSize: pxToDp(32),
fontWeight: "bold", fontWeight: "bold",
}, },
bottomBox: { bottomBox: {

File diff suppressed because it is too large Load Diff

1227
src/InfoScreen2.tsx Normal file

File diff suppressed because it is too large Load Diff

2049
src/InfoScreen3.tsx Normal file

File diff suppressed because it is too large Load Diff

View File

@ -1,80 +1,32 @@
import React from "react"; import React from "react";
import { View, Text, ScrollView, StyleSheet } from "react-native"; import { View, Text, ScrollView, StyleSheet } from "react-native";
import MyStatusbar from "./component/MyStatusbar";
import MyHeader from "./component/MyHeader";
import { NativeStackScreenProps,NativeStackNavigationProp } from "@react-navigation/native-stack";
import { RootStackParamList } from "../App";
import { useTranslation } from 'react-i18next';
export default function PrivacyScreen() { type Props = NativeStackScreenProps<RootStackParamList>;
export default function PrivacyScreen({navigation}:Props) {
const { t } = useTranslation();
return ( return (
<ScrollView style={styles.container}> <View style={styles.container}>
<Text style={styles.title}></Text> <MyStatusbar backgroundColor="#f2f3f7" dark></MyStatusbar>
<Text style={styles.text}> <MyHeader title={t("privacy.title")} textColor="#333" backgroundColor="#f2f3f7" navigation={navigation}></MyHeader>
使 POWERFUN APP 使 <ScrollView style={{ flex: 1, padding: 20 }}>
<Text style={styles.title}>{t("privacy.title")}</Text>
{"\n\n"} <Text style={styles.text}>{t("privacy.content")}</Text>
使
{"\n\n"}
1. POWERFUN APP
{"\n\n"}
2. 使使
{"\n"}
- Android ID
{"\n"}
-
{"\n"}
- APP
{"\n"}
- APP
{"\n\n"}
3. SDK 使
{"\n\n"}
1 SDK Bugly
{"\n"}
{"\n"}
ID WiFi
{"\n"}
使使
{"\n\n"}
2 SDK Aliyun OSS
{"\n"}
{"\n"}
使
{"\n\n"}
{"\n\n"}
{"\n\n"}
{"\n"}
-
{"\n"}
-
{"\n"}
-
{"\n\n"}
{"\n\n"}
{"\n"}
bike99@qq.com
{"\n"}
{"\n\n"}
{"\n"}
2019 7 1
</Text>
</ScrollView> </ScrollView>
</View>
); );
} }
const styles = StyleSheet.create({ const styles = StyleSheet.create({
container: { container: {
flex: 1, flex: 1,
padding: 20, // padding: 20,
backgroundColor: "#fff", backgroundColor: "#f2f3f7",
}, },
title: { title: {
fontSize: 22, fontSize: 22,
@ -85,5 +37,6 @@ const styles = StyleSheet.create({
fontSize: 16, fontSize: 16,
lineHeight: 24, lineHeight: 24,
color: "#333", color: "#333",
marginBottom: 50
}, },
}); });

View File

@ -1,4 +1,4 @@
// src/ScanScreen.tsx // src/ScanScreen.tsx此页面为功率计搜索页面
import React, { useEffect, useState, useCallback, useRef } from "react"; import React, { useEffect, useState, useCallback, useRef } from "react";
import { import {
View, View,
@ -17,8 +17,11 @@ import { NativeStackScreenProps } from "@react-navigation/native-stack";
import { RootStackParamList } from "../App"; import { RootStackParamList } from "../App";
import Icon from "react-native-vector-icons/MaterialCommunityIcons"; // 信号图标集 import Icon from "react-native-vector-icons/MaterialCommunityIcons"; // 信号图标集
type Props = NativeStackScreenProps<RootStackParamList, "Scan">; type Props = NativeStackScreenProps<RootStackParamList>;
type DeviceWithTimestamp = ScannedPeripheral & { lastSeen: number }; type DeviceWithTimestamp = ScannedPeripheral & { lastSeen: number };
import { useTranslation } from 'react-i18next';
import MyStatusbar from "./component/MyStatusbar";
import MyHeader from "./component/MyHeader";
export default function ScanScreen({ navigation }: Props) { export default function ScanScreen({ navigation }: Props) {
const [devices, setDevices] = useState<DeviceWithTimestamp[]>([]); const [devices, setDevices] = useState<DeviceWithTimestamp[]>([]);
@ -26,7 +29,7 @@ export default function ScanScreen({ navigation }: Props) {
const [refreshing, setRefreshing] = useState(false); const [refreshing, setRefreshing] = useState(false);
const handlerRef = useRef<((payload: CentralEventMap["scannedPeripheral"]) => void) | null>(null); const handlerRef = useRef<((payload: CentralEventMap["scannedPeripheral"]) => void) | null>(null);
const { t } = useTranslation();
// 将 RSSI 转换为信号格数0-4 // 将 RSSI 转换为信号格数0-4
const getSignalLevel = (rssi?: number): number => { const getSignalLevel = (rssi?: number): number => {
if (rssi === undefined) return 0; if (rssi === undefined) return 0;
@ -73,7 +76,7 @@ export default function ScanScreen({ navigation }: Props) {
// 更新或添加设备,并记录最后扫描时间 // 更新或添加设备,并记录最后扫描时间
const updatePeripherals = useCallback((p: ScannedPeripheral) => { const updatePeripherals = useCallback((p: ScannedPeripheral) => {
if (!p?.name || !p.name.startsWith("POWERFUN")) return; if (!p?.name || (!p.name.startsWith("POWERFUN") && !p.name.startsWith("PF-PM5"))) return;
if ((p.advertisementData?.rssi ?? -999) < -90) return; // 已过滤弱信号 if ((p.advertisementData?.rssi ?? -999) < -90) return; // 已过滤弱信号
setDevices((prev) => { setDevices((prev) => {
@ -154,6 +157,13 @@ export default function ScanScreen({ navigation }: Props) {
return ( return (
<View style={styles.container}> <View style={styles.container}>
<MyStatusbar backgroundColor="#f2f3f7" dark></MyStatusbar>
<MyHeader
title={t('scan.title')}
textColor="#333"
backgroundColor="#f2f3f7"
navigation={navigation}
></MyHeader>
<FlatList <FlatList
data={devices} data={devices}
keyExtractor={(item) => item.systemId} keyExtractor={(item) => item.systemId}
@ -163,7 +173,7 @@ export default function ScanScreen({ navigation }: Props) {
style={styles.deviceRow} style={styles.deviceRow}
> >
<View style={styles.deviceInfo}> <View style={styles.deviceInfo}>
<Text style={styles.deviceName}>{item.name || "[no name]"}</Text> <Text style={styles.deviceName}>{item.name || t("scan.noName")}</Text>
<View style={styles.rssiRow}> <View style={styles.rssiRow}>
{renderSignalIcon(item.advertisementData?.rssi)} {renderSignalIcon(item.advertisementData?.rssi)}
<Text style={styles.rssiText}> <Text style={styles.rssiText}>
@ -177,13 +187,13 @@ export default function ScanScreen({ navigation }: Props) {
<View style={styles.emptyBox}> <View style={styles.emptyBox}>
{scanning ? ( {scanning ? (
<> <>
<Text style={styles.emptyText}>...</Text> <Text style={styles.emptyText}>{t("scan.scanning")}</Text>
<Text style={styles.tips}></Text> <Text style={styles.tips}>{t("scan.tipScanning")}</Text>
</> </>
) : ( ) : (
<> <>
<Text style={styles.emptyText}></Text> <Text style={styles.emptyText}>{t("scan.noDevice")}</Text>
<Text style={styles.tips}></Text> <Text style={styles.tips}>{t("scan.tipBluetooth")}</Text>
</> </>
)} )}
</View> </View>

238
src/ScanScreen2.tsx Normal file
View File

@ -0,0 +1,238 @@
// 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>;
type DeviceWithTimestamp = ScannedPeripheral & { lastSeen: number };
import { useTranslation } from 'react-i18next';
import MyStatusbar from "./component/MyStatusbar";
import MyHeader from "./component/MyHeader";
export default function ScanScreen2({ 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);
const { t } = useTranslation();
// 将 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") && !p.name.startsWith("PF-STK"))) 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;
console.log("🔥 scanned raw peripheral =", JSON.stringify(p, null, 2));
console.log("🔥 scanned raw peripheral keys =", p ? Object.keys(p) : []);
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}>
<MyStatusbar backgroundColor="#f2f3f7" dark></MyStatusbar>
<MyHeader
title={t('paddleScan.title')}
textColor="#333"
backgroundColor="#f2f3f7"
navigation={navigation}
></MyHeader>
<FlatList
data={devices}
keyExtractor={(item) => item.systemId}
renderItem={({ item }) => (
<TouchableOpacity
onPress={() => {
console.log("🔥 navigate item =", JSON.stringify(item, null, 2));
console.log("🔥 navigate item keys =", Object.keys(item));
navigation.navigate("Info2", { peripheral: item });
}}
style={styles.deviceRow}
>
<View style={styles.deviceInfo}>
<Text style={styles.deviceName}>{item.name || t("scan.noName")}</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}>{t("paddleScan.scanning")}</Text>
<Text style={styles.tips}>{t("paddleScan.tipScanning")}</Text>
</>
) : (
<>
<Text style={styles.emptyText}>{t("paddleScan.noDevice")}</Text>
<Text style={styles.tips}>{t("paddleScan.tipBluetooth")}</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" },
});

231
src/ScanScreen3.tsx Normal file
View File

@ -0,0 +1,231 @@
// src/ScanScreen3.tsx此页面为T5骑行台搜索页面
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>;
type DeviceWithTimestamp = ScannedPeripheral & { lastSeen: number };
import { useTranslation } from 'react-i18next';
import MyStatusbar from "./component/MyStatusbar";
import MyHeader from "./component/MyHeader";
export default function ScanScreen3({ 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);
const { t } = useTranslation();
// 将 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") && !p.name.startsWith("PF-T5"))) 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}>
<MyStatusbar backgroundColor="#f2f3f7" dark></MyStatusbar>
<MyHeader
title={t('t5Scan.title')}
textColor="#333"
backgroundColor="#f2f3f7"
navigation={navigation}
></MyHeader>
<FlatList
data={devices}
keyExtractor={(item) => item.systemId}
renderItem={({ item }) => (
<TouchableOpacity
onPress={() => navigation.navigate("Info3", { peripheral: item })}
style={styles.deviceRow}
>
<View style={styles.deviceInfo}>
<Text style={styles.deviceName}>{item.name || t("scan.noName")}</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}>{t("t5Scan.scanning")}</Text>
<Text style={styles.tips}>{t("t5Scan.tipScanning")}</Text>
</>
) : (
<>
<Text style={styles.emptyText}>{t("t5Scan.noDevice")}</Text>
<Text style={styles.tips}>{t("t5Scan.tipBluetooth")}</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" },
});

143
src/SettingScreen.tsx Normal file
View File

@ -0,0 +1,143 @@
import React, { useState } from "react";
import { View, Text, ScrollView, StyleSheet, TouchableOpacity, Image } from "react-native";
import { useTranslation } from 'react-i18next';
import MyStatusbar from "./component/MyStatusbar";
import MyHeader from "./component/MyHeader";
import LanguageModal from "./component/LanguageModal.tsx";
import { NativeStackScreenProps } from "@react-navigation/native-stack";
import { RootStackParamList } from "../App";
import pxToDp from "./helper/pxToDp";
import DeviceInfo from "react-native-device-info";
import { LANGUAGES } from "./i18n";
type Props = NativeStackScreenProps<RootStackParamList>;
export default function SettingScreen({ navigation }: Props) {
const { t, i18n } = useTranslation();
const [languageModalVisible, setLanguageModalVisible] = useState(false);
// 获取当前语言显示名称
const getCurrentLanguageLabel = (): string => {
const currentLang = i18n.language;
const language = LANGUAGES.find(lang => lang.key === currentLang);
return language?.label || '中文';
};
return (
<View style={styles.container}>
<MyStatusbar backgroundColor="#f2f3f7" dark></MyStatusbar>
<MyHeader
title={t('settings.title')}
textColor="#333"
backgroundColor="#f2f3f7"
navigation={navigation}
></MyHeader>
<View style={{ flex: 1, alignItems: 'center', paddingTop: pxToDp(24) }}>
{/* 软件语言 */}
<TouchableOpacity
style={styles.settingItem}
onPress={() => setLanguageModalVisible(true)}
>
<View style={styles.settingItemLeft}>
<Image
style={styles.icon}
source={require("./img/_SoftwareLanguage.png")}
/>
<Text style={styles.settingLabel}>{t('settings.language')}</Text>
</View>
<View style={styles.settingItemRight}>
<Text style={styles.settingValue}>{getCurrentLanguageLabel()}</Text>
<Image
style={styles.arrow}
source={require("./img/Return2.png")}
/>
</View>
</TouchableOpacity>
{/* 隐私协议 */}
<TouchableOpacity
style={[styles.settingItem, { marginTop: pxToDp(20) }]}
onPress={() => navigation.navigate("Privacy")}
>
<View style={styles.settingItemLeft}>
<Image
style={styles.icon}
source={require("./img/_PrivacyAgreement.png")}
/>
<Text style={styles.settingLabel}>{t('settings.privacy')}</Text>
</View>
<View style={styles.settingItemRight}>
<Image
style={styles.arrow}
source={require("./img/Return2.png")}
/>
</View>
</TouchableOpacity>
{/* 版本号 */}
<View style={[styles.settingItem, { marginTop: pxToDp(20) }]}>
<View style={styles.settingItemLeft}>
<Image
style={styles.icon}
source={require("./img/_Versionnumber.png")}
/>
<Text style={styles.settingLabel}>{t('settings.version')}</Text>
</View>
<View style={styles.settingItemRight}>
<Text style={styles.settingValue}>{DeviceInfo.getVersion()}</Text>
</View>
</View>
</View>
{/* 语言切换弹窗 */}
<LanguageModal
visible={languageModalVisible}
onClose={() => setLanguageModalVisible(false)}
/>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: "#f2f3f7",
},
settingItem: {
width: pxToDp(670),
height: pxToDp(108),
borderRadius: pxToDp(24),
backgroundColor: '#fff',
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: pxToDp(32),
},
settingItemLeft: {
flexDirection: 'row',
alignItems: 'center',
},
settingItemRight: {
flexDirection: 'row',
alignItems: 'center',
},
icon: {
width: pxToDp(40),
height: pxToDp(40),
marginRight: pxToDp(12),
},
settingLabel: {
fontSize: pxToDp(30),
color: '#333',
},
settingValue: {
fontSize: pxToDp(30),
color: "#999",
marginRight: pxToDp(14),
},
arrow: {
width: pxToDp(14),
height: pxToDp(24),
},
});

796
src/SpindownScreen.tsx Normal file
View File

@ -0,0 +1,796 @@
import React, { useEffect, useMemo, useRef, useState } from "react";
import {
ActivityIndicator,
Alert,
ScrollView,
StyleSheet,
TouchableOpacity,
View,
Text as RNText,
} from "react-native";
import { NativeStackScreenProps } from "@react-navigation/native-stack";
import {
Central,
ConnectionStatus,
ScannedPeripheral,
} from "@systemic-games/react-native-bluetooth-le";
import Icon from "react-native-vector-icons/MaterialCommunityIcons";
import { RootStackParamList } from "../App";
import MyHeader from "./component/MyHeader";
import MyStatusbar from "./component/MyStatusbar";
import { useTranslation } from "react-i18next";
import { observeFtmsIndoorBikeData } from "./helper/ftmsIndoorBikeDataBus";
type Props = NativeStackScreenProps<RootStackParamList, "Spindown">;
type Phase = "reach36" | "wait18" | "calibrating" | "completed" | "error";
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 SpindownScreen({ route, navigation }: Props) {
const { peripheral } = route.params;
const { t } = useTranslation();
const deviceKey = useMemo(
() => peripheral.address || peripheral.systemId,
[peripheral.address, peripheral.systemId]
);
const [speedKph, setSpeedKph] = useState<number>(0);
const [isConnected, setIsConnected] = useState(false);
const [isFff3Ready, setIsFff3Ready] = useState(false);
const [isLoading, setIsLoading] = useState(true);
const [phase, setPhase] = useState<Phase>("reach36");
const [step1Done, setStep1Done] = useState(false);
const [step2Done, setStep2Done] = useState(false);
const [statusText, setStatusText] = useState(
t("spindown.statusReach36", { speed: 36 })
);
const [spindownSeconds, setSpindownSeconds] = useState<number | null>(null);
const notifySubscribedRef = useRef(false);
const spindownStartedRef = useRef(false);
const spindownModeEnabledRef = useRef(false);
const resolverRef = useRef<((value: Uint8Array) => void) | null>(null);
const rejecterRef = useRef<((reason?: any) => void) | null>(null);
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const mountedRef = useRef(true);
const hasConnectedOnceRef = useRef(false);
const hasNavigatedBackRef = useRef(false);
const skipDisconnectOnLeaveRef = useRef(false);
const isConnectedRef = useRef(false);
const isFff3ReadyRef = useRef(false);
const spindownResultPromiseRef = useRef<Promise<number> | null>(null);
const DEBUG = false;
const phaseRef = useRef<Phase>("reach36");
const lastUiUpdateRef = useRef(0);
useEffect(() => {
phaseRef.current = phase;
}, [phase]);
useEffect(() => {
isConnectedRef.current = isConnected;
}, [isConnected]);
useEffect(() => {
isFff3ReadyRef.current = isFff3Ready;
}, [isFff3Ready]);
// 新增:缓存最近收到的 0x09 消旋结果避免结果包先到、waiter 后挂导致丢包
const lastSpindownPacketRef = useRef<Uint8Array | null>(null);
const bytesToHex = (bytes: Uint8Array) =>
Array.from(bytes)
.map((b) => b.toString(16).padStart(2, "0").toUpperCase())
.join("");
const clearWaiter = () => {
resolverRef.current = null;
rejecterRef.current = null;
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
timeoutRef.current = null;
}
};
const formatSpindownSeconds = (seconds: number) => {
return seconds.toFixed(2).replace(/\.?0+$/, "");
};
const subscribeFff3IfNeeded = async () => {
if (notifySubscribedRef.current) return;
try {
await Central.unsubscribeCharacteristic(
peripheral,
fullUUID(powerServiceUuid),
fullUUID(powerNotifyUuid)
).catch(() => {});
await Central.subscribeCharacteristic(
peripheral,
fullUUID(powerServiceUuid),
fullUUID(powerNotifyUuid),
(notifyEv) => {
try {
const raw = notifyEv.value;
if (!raw) return;
const bytes = new Uint8Array(raw);
if (DEBUG) {
console.log("[BLE] FFF3 =", bytes.length);
}
if (bytes.length >= 3 && bytes[0] === 0x09) {
lastSpindownPacketRef.current = bytes;
}
resolverRef.current?.(bytes);
} catch (err) {
rejecterRef.current?.(err);
clearWaiter();
}
}
);
notifySubscribedRef.current = true;
isFff3ReadyRef.current = true;
setIsFff3Ready(true);
} catch {
isFff3ReadyRef.current = false;
setIsFff3Ready(false);
}
};
const waitForSpindownTime = (timeout = 5000) => {
return new Promise<number>((resolve, reject) => {
clearWaiter();
// 先吃缓存,防止 0x09 结果包在 runSpindown 前就已经到了
const cached = lastSpindownPacketRef.current;
if (cached && cached.length >= 3 && cached[0] === 0x09) {
console.log("[BLE] use cached spindown packet =", bytesToHex(cached));
const rawSeconds = cached[1] | (cached[2] << 8);
lastSpindownPacketRef.current = null;
if (rawSeconds === 0xffff) {
reject(new Error(t("spindown.failedRetry")));
return;
}
resolve(rawSeconds / 100);
return;
}
resolverRef.current = (value: Uint8Array) => {
if (value.length < 3 || value[0] !== 0x09) {
console.log("[BLE] ignore unrelated FFF3(spindown):", bytesToHex(value));
return;
}
lastSpindownPacketRef.current = null;
const rawSeconds = value[1] | (value[2] << 8);
if (rawSeconds === 0xffff) {
clearWaiter();
reject(new Error(t("spindown.failedRetry")));
return;
}
const displaySeconds = rawSeconds / 100;
clearWaiter();
resolve(displaySeconds);
};
rejecterRef.current = reject;
timeoutRef.current = setTimeout(() => {
clearWaiter();
reject(new Error(t("spindown.timeout")));
}, timeout);
});
};
const prepareSpindownResultWait = (timeout = 45000) => {
if (spindownResultPromiseRef.current) {
return spindownResultPromiseRef.current;
}
const promise = waitForSpindownTime(timeout);
spindownResultPromiseRef.current = promise;
void promise.catch(() => {});
return promise;
};
const writeFff2 = async (bytes: number[]) => {
await Central.writeCharacteristic(
peripheral,
fullUUID(powerServiceUuid),
fullUUID(powerWriteUuid),
new Uint8Array(bytes).buffer,
{ withoutResponse: false }
);
};
const enableSpindownMode = async () => {
if (spindownModeEnabledRef.current) return;
if (!isConnectedRef.current || !isFff3ReadyRef.current) return;
// 开始一轮新的消旋前清掉旧缓存,避免上一次结果污染
lastSpindownPacketRef.current = null;
console.log("[BLE] write FFF2 1001");
await writeFff2([0x10, 0x01]);
spindownModeEnabledRef.current = true;
};
const disableSpindownMode = async () => {
if (!spindownModeEnabledRef.current) return;
try {
console.log("[BLE] write FFF2 1000");
await writeFff2([0x10, 0x00]);
} finally {
spindownModeEnabledRef.current = false;
}
};
const runSpindown = async () => {
if (spindownStartedRef.current) return;
spindownStartedRef.current = true;
try {
if (!isConnectedRef.current || !isFff3ReadyRef.current) {
throw new Error(t("spindown.deviceNotReady"));
}
setStatusText(t("spindown.statusCalibrating"));
const seconds = await prepareSpindownResultWait(5000);
setSpindownSeconds(seconds);
setPhase("completed");
phaseRef.current = "completed";
setStatusText(t("spindown.statusCompleted"));
} catch (err: any) {
console.warn("消旋失败:", err);
setPhase("error");
phaseRef.current = "error";
setStatusText("消旋失败,请重新消旋");
Alert.alert(
"消旋失败",
err?.message || "本次消旋未完成,请检查速度是否符合要求后重新尝试。",
[{ text: "确定" }]
);
} finally {
spindownResultPromiseRef.current = null;
}
};
useEffect(() => {
mountedRef.current = true;
const connectionHandler = async (ev: {
peripheral: ScannedPeripheral;
connectionStatus: ConnectionStatus;
}) => {
const addr = ev.peripheral.address || ev.peripheral.systemId;
if (addr !== deviceKey) return;
const connected =
ev.connectionStatus === "connected" || ev.connectionStatus === "ready";
isConnectedRef.current = connected;
setIsConnected(connected);
if (connected) {
hasConnectedOnceRef.current = true;
}
if (ev.connectionStatus !== "ready") {
isFff3ReadyRef.current = false;
setIsFff3Ready(false);
notifySubscribedRef.current = false;
}
if (ev.connectionStatus === "ready" && !notifySubscribedRef.current) {
await subscribeFff3IfNeeded();
}
if (
!connected &&
hasConnectedOnceRef.current &&
mountedRef.current &&
!hasNavigatedBackRef.current
) {
hasNavigatedBackRef.current = true;
skipDisconnectOnLeaveRef.current = true;
Alert.alert(t("common.notice"), t("info2.disconnectedNeedReconnect"), [
{
text: t("info.confirm"),
onPress: () => {
navigation.reset({
index: 0,
routes: [{ name: "ScanScreen3" }],
});
},
},
]);
}
};
Central.addListener("peripheralConnectionStatus", connectionHandler);
return () => {
mountedRef.current = false;
Central.removeListener("peripheralConnectionStatus", connectionHandler);
notifySubscribedRef.current = false;
setIsFff3Ready(false);
clearWaiter();
};
}, [deviceKey, navigation, peripheral, t]);
useEffect(() => {
let cancelled = false;
(async () => {
setIsLoading(true);
try {
// 进入页面时清一次缓存,避免旧结果残留
lastSpindownPacketRef.current = null;
spindownResultPromiseRef.current = null;
spindownStartedRef.current = false;
phaseRef.current = "reach36";
// Reuse existing connection from Info3 instead of reconnecting here.
isConnectedRef.current = true;
setIsConnected(true);
await subscribeFff3IfNeeded();
} catch (err) {
console.warn("连接设备失败:", err);
isConnectedRef.current = false;
setIsConnected(false);
if (!cancelled) {
Alert.alert(t("common.notice"), t("spindown.connectFailed"));
}
} finally {
if (!cancelled) {
setIsLoading(false);
}
}
})();
return () => {
cancelled = true;
if (skipDisconnectOnLeaveRef.current) return;
disableSpindownMode().catch((err) => {
console.warn("关闭消旋模式失败:", err);
});
};
}, [peripheral, t]);
useEffect(() => {
if (!isConnected || !isFff3Ready) return;
enableSpindownMode().catch((err) => {
console.warn("启用消旋模式失败:", err);
spindownModeEnabledRef.current = false;
});
}, [isConnected, isFff3Ready]);
useEffect(() => {
if (!peripheral) return;
let cancelled = false;
let cleanup: (() => void) | null = null;
observeFtmsIndoorBikeData(peripheral, (data) => {
if (cancelled) return;
const speed = data.speedKph;
const now = Date.now();
// ✅ UI 限速 10Hz
if (now - lastUiUpdateRef.current > 100) {
lastUiUpdateRef.current = now;
setSpeedKph(speed);
}
// ===== ⭐ 实时状态机(不再依赖 React state =====
const currentPhase = phaseRef.current;
// step1到 36
if (currentPhase === "reach36" && speed >= 36) {
phaseRef.current = "wait18";
setPhase("wait18");
setStep1Done(true);
setStatusText(t("spindown.statusReached36", { high: 36, low: 18 }));
prepareSpindownResultWait();
return;
}
// step2降到 18
if (currentPhase === "wait18" && speed <= 18) {
phaseRef.current = "calibrating";
setPhase("calibrating");
setStep2Done(true);
setStatusText(t("spindown.statusCalibrating"));
runSpindown();
return;
}
})
.then((unsubscribe) => {
if (cancelled) {
unsubscribe();
return;
}
cleanup = unsubscribe;
})
.catch(() => {});
return () => {
cancelled = true;
cleanup?.();
};
}, [peripheral, t]);
const renderStepState = (done: boolean, active: boolean) => {
if (done) {
return <Icon name="check-circle" size={28} color="#19a15f" />;
}
if (active) {
return <ActivityIndicator size="small" color="#eb3b3b" />;
}
return <Icon name="circle-outline" size={26} color="#c6c6c6" />;
};
const isDeviceReady = isConnected || isFff3Ready;
const connectionLabel = isDeviceReady
? t("spindown.connected")
: t("spindown.connecting");
const connectionColor = isDeviceReady ? "#19a15f" : "#eb3b3b";
return (
<View style={styles.screen}>
<MyStatusbar backgroundColor="#ffffff" dark={true} />
<MyHeader
backgroundColor="#ffffff"
title={t("spindown.title")}
textColor="#eb3b3b"
navigation={navigation}
/>
<ScrollView contentContainerStyle={styles.scrollContent}>
<View style={styles.heroCard}>
<View style={styles.connectionRow}>
<View
style={[
styles.connectionBadge,
{ backgroundColor: `${connectionColor}16` },
]}
>
<View
style={[styles.connectionDot, { backgroundColor: connectionColor }]}
/>
<Text style={[styles.connectionText, { color: connectionColor }]}>
{connectionLabel}
</Text>
</View>
</View>
<Text style={styles.heroTitle}>{t("spindown.headerTitle")}</Text>
<Text style={styles.heroHint}>{statusText}</Text>
<View style={styles.targetCard}>
<Text style={styles.targetLabel}>{t("spindown.targetLabel")}</Text>
<Text style={styles.targetValue}>36 km/h</Text>
<Text style={styles.targetHint}>{t("spindown.targetHint")}</Text>
</View>
<View style={styles.speedCard}>
<Text style={styles.speedLabel}>{t("spindown.currentSpeed")}</Text>
<Text style={styles.speedValue}>{speedKph.toFixed(1)} km/h</Text>
</View>
</View>
<View style={styles.stepCard}>
<View style={styles.stepRow}>
<View style={styles.stepIndexWrap}>
<Text style={styles.stepIndex}>1</Text>
</View>
<View style={styles.stepTextWrap}>
<Text style={styles.stepTitle}>
{t("spindown.step1Title", { speed: 36 })}
</Text>
<Text style={styles.stepDesc}>{t("spindown.step1Desc")}</Text>
</View>
{renderStepState(step1Done, phase === "reach36")}
</View>
<View style={styles.divider} />
<View style={styles.stepRow}>
<View style={styles.stepIndexWrap}>
<Text style={styles.stepIndex}>2</Text>
</View>
<View style={styles.stepTextWrap}>
<Text style={styles.stepTitle}>{t("spindown.step2Title")}</Text>
<Text style={styles.stepDesc}>
{t("spindown.step2Desc", { speed: 18 })}
</Text>
</View>
{renderStepState(step2Done, phase === "wait18" || phase === "calibrating")}
</View>
<View style={styles.divider} />
<View style={styles.stepRow}>
<View style={styles.stepIndexWrap}>
<Text style={styles.stepIndex}>3</Text>
</View>
<View style={styles.stepTextWrap}>
<Text style={styles.stepTitle}>{t("spindown.step3Title")}</Text>
<Text style={styles.stepDesc}>
{phase === "error"
? "消旋失败,请重新消旋"
: spindownSeconds === null
? phase === "calibrating"
? t("spindown.step3Loading")
: t("spindown.step3Pending")
: `${formatSpindownSeconds(spindownSeconds)}s`}
</Text>
</View>
{phase === "error" ? (
<Icon name="close-circle" size={28} color="#eb3b3b" />
) : (
renderStepState(phase === "completed", phase === "calibrating")
)}
</View>
</View>
{isLoading && (
<View style={styles.loadingWrap}>
<ActivityIndicator size={28} color="#eb3b3b" />
<Text style={styles.loadingText}>{t("spindown.loading")}</Text>
</View>
)}
{phase === "completed" && (
<View style={styles.resultCard}>
<Icon name="check-decagram" size={24} color="#19a15f" />
<Text style={styles.resultText}>
{t("spindown.result", {
seconds: formatSpindownSeconds(spindownSeconds ?? 0),
})}
</Text>
</View>
)}
{phase === "error" && (
<TouchableOpacity
activeOpacity={0.85}
style={styles.retryButton}
onPress={() => navigation.replace("Spindown", { peripheral })}
>
<Text style={styles.retryButtonText}>{t("spindown.retry")}</Text>
</TouchableOpacity>
)}
</ScrollView>
</View>
);
}
const RED = "#eb3b3b";
const BG = "#f6f7fb";
const DARK = "#242424";
const styles = StyleSheet.create({
screen: {
flex: 1,
backgroundColor: BG,
},
scrollContent: {
paddingHorizontal: 18,
paddingTop: 10,
paddingBottom: 28,
},
heroCard: {
backgroundColor: "#ffffff",
borderRadius: 24,
paddingHorizontal: 18,
paddingVertical: 18,
shadowColor: "#000",
shadowOpacity: 0.06,
shadowRadius: 12,
shadowOffset: { width: 0, height: 6 },
elevation: 3,
},
connectionRow: {
flexDirection: "row",
justifyContent: "flex-end",
},
connectionBadge: {
flexDirection: "row",
alignItems: "center",
paddingHorizontal: 10,
paddingVertical: 6,
borderRadius: 999,
},
connectionDot: {
width: 8,
height: 8,
borderRadius: 4,
marginRight: 6,
},
connectionText: {
fontSize: 13,
fontWeight: "600",
},
heroTitle: {
fontSize: 18,
fontWeight: "800",
color: DARK,
marginTop: 10,
},
heroHint: {
fontSize: 15,
color: "#666666",
lineHeight: 22,
marginTop: 8,
minHeight: 26,
},
targetCard: {
marginTop: 12,
borderRadius: 18,
backgroundColor: "#fff0f0",
borderWidth: 1,
borderColor: "#ffd4d4",
paddingVertical: 14,
paddingHorizontal: 14,
alignItems: "center",
},
targetLabel: {
fontSize: 14,
color: "#8a5555",
fontWeight: "600",
},
targetValue: {
marginTop: 4,
fontSize: 36,
color: RED,
fontWeight: "900",
lineHeight: 40,
},
targetHint: {
marginTop: 4,
fontSize: 13,
color: "#9b6c6c",
},
speedCard: {
marginTop: 18,
borderRadius: 22,
backgroundColor: "#fff5f5",
borderWidth: 1,
borderColor: "#ffdede",
paddingVertical: 20,
alignItems: "center",
},
speedLabel: {
fontSize: 15,
color: "#666666",
},
speedValue: {
fontSize: 34,
fontWeight: "800",
color: RED,
marginTop: 8,
},
stepCard: {
marginTop: 16,
backgroundColor: "#ffffff",
borderRadius: 24,
paddingHorizontal: 16,
paddingVertical: 10,
shadowColor: "#000",
shadowOpacity: 0.05,
shadowRadius: 10,
shadowOffset: { width: 0, height: 6 },
elevation: 3,
},
stepRow: {
flexDirection: "row",
alignItems: "center",
paddingVertical: 18,
},
stepIndexWrap: {
width: 34,
height: 34,
borderRadius: 17,
backgroundColor: "#fff1f1",
alignItems: "center",
justifyContent: "center",
marginRight: 12,
},
stepIndex: {
fontSize: 17,
color: RED,
fontWeight: "800",
},
stepTextWrap: {
flex: 1,
paddingRight: 12,
},
stepTitle: {
fontSize: 17,
color: DARK,
fontWeight: "700",
lineHeight: 24,
},
stepDesc: {
fontSize: 14,
color: "#777777",
lineHeight: 20,
marginTop: 4,
},
divider: {
height: 1,
backgroundColor: "#f0f0f0",
},
loadingWrap: {
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
marginTop: 18,
},
loadingText: {
marginLeft: 10,
fontSize: 14,
color: "#666666",
},
resultCard: {
marginTop: 16,
backgroundColor: "#edf9f2",
borderRadius: 18,
paddingVertical: 14,
paddingHorizontal: 16,
flexDirection: "row",
alignItems: "center",
},
resultText: {
marginLeft: 10,
color: "#156d45",
fontSize: 15,
fontWeight: "700",
},
retryButton: {
marginTop: 18,
height: 46,
borderRadius: 14,
backgroundColor: RED,
alignItems: "center",
justifyContent: "center",
},
retryButtonText: {
color: "#ffffff",
fontSize: 16,
fontWeight: "700",
},
});

View File

@ -2,8 +2,9 @@ import React, { useEffect } from "react";
import { View, Image, StyleSheet } from "react-native"; import { View, Image, StyleSheet } from "react-native";
import { NativeStackScreenProps } from "@react-navigation/native-stack"; import { NativeStackScreenProps } from "@react-navigation/native-stack";
import { RootStackParamList } from "../App"; import { RootStackParamList } from "../App";
import MyStatusbar from "./component/MyStatusbar";
type Props = NativeStackScreenProps<RootStackParamList, "Splash">; type Props = NativeStackScreenProps<RootStackParamList>;
export default function SplashScreen({ navigation }: Props) { export default function SplashScreen({ navigation }: Props) {
useEffect(() => { useEffect(() => {
@ -16,6 +17,7 @@ export default function SplashScreen({ navigation }: Props) {
return ( return (
<View style={styles.container}> <View style={styles.container}>
<MyStatusbar backgroundColor="#E7141E" dark={false}></MyStatusbar>
<Image <Image
source={require("../assets/logo.png")} // ✅ 替换为你自己的图片 source={require("../assets/logo.png")} // ✅ 替换为你自己的图片
style={styles.logo} style={styles.logo}

View File

@ -0,0 +1,131 @@
import React from 'react';
import {
Modal,
View,
Text,
TouchableOpacity,
StyleSheet,
Pressable,
Image,
} from 'react-native';
import { useTranslation } from 'react-i18next';
import { LANGUAGES, saveLanguage } from '../i18n';
import pxToDp from '../helper/pxToDp';
interface LanguageModalProps {
visible: boolean;
onClose: () => void;
}
const LanguageModal: React.FC<LanguageModalProps> = ({ visible, onClose }) => {
const { t, i18n } = useTranslation();
const currentLanguage = i18n.language;
const handleLanguageSelect = async (languageKey: string) => {
await saveLanguage(languageKey);
i18n.changeLanguage(languageKey);
onClose();
};
return (
<Modal
visible={visible}
transparent
animationType="fade"
onRequestClose={onClose}
>
<Pressable style={styles.overlay} onPress={onClose}>
<Pressable
style={styles.modalContainer}
onPress={(e) => e.stopPropagation()}
>
{/* 标题栏 */}
<View style={styles.header}>
<Text style={styles.title}>{t('languageModal.title')}</Text>
<TouchableOpacity onPress={onClose} style={styles.closeButton}>
<Text style={styles.closeIcon}></Text>
</TouchableOpacity>
</View>
{/* 语言列表 */}
<View style={styles.languageList}>
{LANGUAGES.map((language) => (
<TouchableOpacity
key={language.key}
style={styles.languageItem}
onPress={() => handleLanguageSelect(language.key)}
activeOpacity={0.7}
>
<Text style={styles.languageText}>{language.label}</Text>
{currentLanguage === language.key && (
<Text style={styles.checkmark}></Text>
)}
</TouchableOpacity>
))}
</View>
</Pressable>
</Pressable>
</Modal>
);
};
const styles = StyleSheet.create({
overlay: {
flex: 1,
backgroundColor: 'rgba(0, 0, 0, 0.5)',
justifyContent: 'center',
alignItems: 'center',
},
modalContainer: {
width: pxToDp(630),
backgroundColor: '#fff',
borderRadius: pxToDp(24),
overflow: 'hidden',
},
header: {
flexDirection: 'row',
justifyContent: 'center',
alignItems: 'center',
paddingVertical: pxToDp(32),
borderBottomWidth: pxToDp(1),
borderBottomColor: '#f0f0f0',
position: 'relative',
},
title: {
fontSize: pxToDp(32),
fontWeight: '600',
color: '#333',
},
closeButton: {
position: 'absolute',
right: pxToDp(32),
padding: pxToDp(8),
},
closeIcon: {
fontSize: pxToDp(40),
color: '#666',
fontWeight: '300',
},
languageList: {
paddingVertical: pxToDp(16),
},
languageItem: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
paddingHorizontal: pxToDp(40),
paddingVertical: pxToDp(32),
backgroundColor: '#fff',
},
languageText: {
fontSize: pxToDp(30),
color: '#333',
},
checkmark: {
fontSize: pxToDp(40),
color: '#FF6B35',
fontWeight: 'bold',
},
});
export default LanguageModal;

View File

@ -0,0 +1,67 @@
// components/Hello.tsx
import React, {ReactNode, useState} from 'react';
import {StyleProp, Text, TouchableOpacity, View, ViewStyle, Image} from 'react-native';
import pxToDp from '../helper/pxToDp';
import {RootNavigationProp} from '../types/RootNavigationProp'
export interface Props {
backgroundColor: string;
title: string;
textColor: string;
navigation: RootNavigationProp;
hideBack?: boolean;
customBackFunction?: () => void;
backIconType?: number;
rightView?: ReactNode;
}
const MyHeader: React.FC<Props> = props => {
const containerStyle: StyleProp<ViewStyle> = {
backgroundColor: props.backgroundColor,
height: pxToDp(88),
width: '100%',
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
paddingHorizontal: pxToDp(24),
};
let backImage = <Image
source={require('../img/Return.png')}
style={{width: pxToDp(16), height: pxToDp(28)}}
resizeMode="stretch" />
return (
<View style={containerStyle}>
{props.hideBack ? (
<View style={{width: pxToDp(56), height: pxToDp(56)}} />
) : (
<TouchableOpacity
style={{width: pxToDp(56), height: pxToDp(56), alignItems:'center', justifyContent:'center'}}
onPress={() => {
if (props.customBackFunction) {
props.customBackFunction();
} else {
props.navigation.goBack();
}
}}>
{backImage}
</TouchableOpacity>
)}
<Text
style={{
width: pxToDp(400),
fontSize: pxToDp(34),
color: props.textColor,
textAlign: 'center',
fontWeight: 'bold',
}}>
{props.title}
</Text>
<View style={{width: pxToDp(56), position: 'relative'}}>
{props.rightView}
</View>
</View>
);
};
export default MyHeader;

View File

@ -0,0 +1,41 @@
// components/Hello.tsx
import React, {useState} from 'react';
import {Button, StyleSheet, Text, View} from 'react-native';
import {StatusBar, Platform, StatusBarProps} from 'react-native';
import {useIsFocused} from '@react-navigation/native';
import helper from '../helper/helper';
export interface Props {
backgroundColor: string;
dark: Boolean;
}
const FocusAwareStatusBar = (props: StatusBarProps) => {
const isFocused = useIsFocused();
return isFocused ? <StatusBar {...props} /> : null;
};
const MyStatusbar: React.FC<Props> = props => {
const height = helper.statusBarHeight;
const data: StatusBarProps = {
backgroundColor: props.backgroundColor
? props.backgroundColor
: 'rgba(0,0,0,0)',
barStyle: props.dark ? 'dark-content' : 'light-content',
translucent: true,
};
console.log('height', height);
return (
<View
style={{
backgroundColor: props.backgroundColor,
height: height,
width: '100%',
}}>
<FocusAwareStatusBar {...data} />
</View>
);
};
// styles
const styles = StyleSheet.create({});
export default MyStatusbar;

View File

@ -0,0 +1,315 @@
import {
Central,
ScannedPeripheral,
ConnectionStatus,
} from "@systemic-games/react-native-bluetooth-le";
const ftmsServiceUuid = "1826";
const ftmsIndoorBikeDataUuid = "2ad2";
const fullUUID = (uuid: string) =>
uuid.length === 4
? `0000${uuid}-0000-1000-8000-00805f9b34fb`
: uuid;
export type FtmsIndoorBikeData = {
speedKph: number;
cadence: number;
power: number;
raw: Uint8Array;
};
type Listener = (data: FtmsIndoorBikeData) => void;
type SubscriptionEntry = {
listeners: Set<Listener>;
lastData: FtmsIndoorBikeData | null;
subscribePromise: Promise<void> | null;
subscribed: boolean;
cadenceState: {
lastCadenceValue: number;
lastCadenceChangedTime: number;
};
lastEmitTime: number; // ✅ throttle 用
};
const entries = new Map<string, SubscriptionEntry>();
let connectionListenerInstalled = false;
const getDeviceKey = (peripheral: ScannedPeripheral) =>
String(peripheral.address || peripheral.systemId || peripheral.name || "unknown");
const resetEntrySubscriptionState = (
key: string,
connectionStatus?: ConnectionStatus
) => {
const entry = entries.get(key);
if (!entry) return;
entry.subscribed = false;
entry.subscribePromise = null;
entry.lastEmitTime = 0;
entry.cadenceState.lastCadenceValue = 0;
entry.cadenceState.lastCadenceChangedTime = 0;
if (connectionStatus !== "connected" && connectionStatus !== "ready") {
entry.lastData = null;
}
};
const ensureConnectionListenerInstalled = () => {
if (connectionListenerInstalled) return;
Central.addListener("peripheralConnectionStatus", (ev) => {
const key = getDeviceKey(ev.peripheral);
if (
ev.connectionStatus === "disconnected" ||
ev.connectionStatus === "disconnecting" ||
ev.connectionStatus === "connecting"
) {
resetEntrySubscriptionState(key, ev.connectionStatus);
}
});
connectionListenerInstalled = true;
};
// ✅ 默认关闭调试(避免性能问题)
const DEBUG = false;
const bytesToHex = (bytes: Uint8Array) =>
Array.from(bytes)
.map((b) => b.toString(16).padStart(2, "0").toUpperCase())
.join("");
const decodeBase64 = (value: string): Uint8Array | null => {
try {
const atobFn = (globalThis as { atob?: (data: string) => string }).atob;
if (typeof atobFn === "function") {
const binary = atobFn(value);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i += 1) {
bytes[i] = binary.charCodeAt(i);
}
return bytes;
}
const chars =
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
let buffer = 0;
let bits = 0;
const output: number[] = [];
for (const char of value.replace(/=+$/, "")) {
const index = chars.indexOf(char);
if (index < 0) continue;
buffer = (buffer << 6) | index;
bits += 6;
if (bits >= 8) {
bits -= 8;
output.push((buffer >> bits) & 0xff);
}
}
return new Uint8Array(output);
} catch (err) {
if (DEBUG) console.warn("[FTMS] decodeBase64 failed", err);
return null;
}
};
const toUint8Array = (raw: any): Uint8Array | null => {
try {
if (!raw) return null;
if (raw instanceof Uint8Array) return raw;
if (raw instanceof ArrayBuffer) return new Uint8Array(raw);
if (typeof raw === "string") return decodeBase64(raw);
if (Array.isArray(raw)) return new Uint8Array(raw);
if (raw?.buffer instanceof ArrayBuffer) {
return new Uint8Array(raw.buffer, raw.byteOffset || 0, raw.byteLength);
}
return null;
} catch (err) {
if (DEBUG) console.warn("[FTMS] toUint8Array failed", err);
return null;
}
};
const parseIndoorBikeData = (
byteArray: Uint8Array,
cadenceState: SubscriptionEntry["cadenceState"]
): FtmsIndoorBikeData | null => {
if (byteArray.length < 8) return null;
const currentTime = Date.now();
const rawSpeed = byteArray[2] | (byteArray[3] << 8);
const rawCadence = byteArray[4] | (byteArray[5] << 8);
let rawPower = byteArray[6] | (byteArray[7] << 8);
const speedKph = Number((rawSpeed / 100.0).toFixed(1));
const cadenceValue = rawCadence / 2.0;
if (cadenceValue !== cadenceState.lastCadenceValue) {
cadenceState.lastCadenceChangedTime = currentTime;
cadenceState.lastCadenceValue = cadenceValue;
}
const cadence =
cadenceState.lastCadenceChangedTime > 0 &&
currentTime - cadenceState.lastCadenceChangedTime > 3000
? 0
: Math.round(cadenceValue);
if (rawPower & 0x8000) {
rawPower -= 0x10000;
}
const power = rawPower;
return {
speedKph,
cadence,
power,
raw: byteArray,
};
};
const ensureSubscribed = async (
peripheral: ScannedPeripheral,
entry: SubscriptionEntry
) => {
if (entry.subscribed) return;
if (entry.subscribePromise) {
await entry.subscribePromise;
return;
}
entry.subscribePromise = Central.subscribeCharacteristic(
peripheral,
fullUUID(ftmsServiceUuid),
fullUUID(ftmsIndoorBikeDataUuid),
(notifyEv) => {
try {
const byteArray = toUint8Array(notifyEv.value);
if (!byteArray || byteArray.length === 0) return;
if (DEBUG) {
const flags =
byteArray.length >= 2
? byteArray[0] | (byteArray[1] << 8)
: 0;
console.log("[FTMS]", bytesToHex(byteArray));
console.log(
"[FTMS] flags =",
"0x" + flags.toString(16).padStart(4, "0")
);
}
const parsed = parseIndoorBikeData(
byteArray,
entry.cadenceState
);
if (!parsed) return;
const now = Date.now();
// ✅ throttle: 限制为 10Hz100ms 一次)
if (now - entry.lastEmitTime < 100) {
return;
}
entry.lastEmitTime = now;
if (DEBUG) {
console.log(
"[FTMS] parsed =>",
parsed.speedKph,
parsed.cadence,
parsed.power
);
}
entry.lastData = parsed;
entry.listeners.forEach((listener) => listener(parsed));
} catch (err) {
if (DEBUG) console.warn("处理 2AD2 通知失败", err);
}
}
)
.then(() => {
entry.subscribed = true;
if (DEBUG) console.log("[FTMS] subscribed");
})
.catch((err) => {
if (DEBUG) console.warn("[FTMS] subscribe failed", err);
throw err;
})
.finally(() => {
entry.subscribePromise = null;
});
await entry.subscribePromise;
};
export const observeFtmsIndoorBikeData = async (
peripheral: ScannedPeripheral,
listener: Listener
) => {
ensureConnectionListenerInstalled();
const key = getDeviceKey(peripheral);
let entry = entries.get(key);
if (!entry) {
entry = {
listeners: new Set(),
lastData: null,
subscribePromise: null,
subscribed: false,
cadenceState: {
lastCadenceValue: 0,
lastCadenceChangedTime: 0,
},
lastEmitTime: 0, // ✅ 初始化
};
entries.set(key, entry);
}
if (entry.listeners.size === 0) {
resetEntrySubscriptionState(key, "disconnected");
}
entry.listeners.add(listener);
await ensureSubscribed(peripheral, entry);
if (entry.lastData) {
listener(entry.lastData);
}
return () => {
const currentEntry = entries.get(key);
if (!currentEntry) return;
currentEntry.listeners.delete(listener);
};
};
export const debugFtmsIndoorBikeDataEntries = () => {
return Array.from(entries.entries()).map(([key, entry]) => ({
key,
listeners: entry.listeners.size,
subscribed: entry.subscribed,
lastDataHex: entry.lastData ? bytesToHex(entry.lastData.raw) : null,
}));
};

106
src/helper/helper.js Normal file
View File

@ -0,0 +1,106 @@
import { Dimensions, StatusBar, Platform, Linking } from 'react-native';
import pxToDp from './pxToDp';
import { getStatusBarHeight } from 'react-native-safearea-height';
import * as _ from 'lodash';
import myToast, { Toast } from './myToast';
export default {
width: Dimensions.get('window').width,
height:
Platform.OS === 'ios'
? Dimensions.get('window').height
: Dimensions.get('window').height / Dimensions.get('window').width > 1.8
? Dimensions.get('window').height + getStatusBarHeight()
: Dimensions.get('window').height,
statusBarHeight: getStatusBarHeight(),
bottomTabHeight: (Platform.OS === 'ios' && Dimensions.get('window').height / Dimensions.get('window').width > 1.8?34:0) + pxToDp(98),
isIOS: Platform.OS == 'ios',
isNullOrEmpty(strings) {
return !strings || String(strings).replace(/(^s*)|(s*$)/g, '').length == 0;
},
stringToNumberStr(s) {
console.log(16, String(Number(s.replace(/[^0-9]/g, ''))));
return String(Number(s.replace(/[^0-9]/g, '')));
},
isIOSFullScreen() {
const dimen = Dimensions.get('window');
return (
Platform.OS === 'ios' &&
!Platform.isPad &&
!Platform.isTVOS &&
(dimen.height === 780 ||
dimen.width === 780 ||
dimen.height === 812 ||
dimen.width === 812 ||
dimen.height === 844 ||
dimen.width === 844 ||
dimen.height === 896 ||
dimen.width === 896 ||
dimen.height === 926 ||
dimen.width === 926)
);
},
//写一个方法传入一个数组判断数量是否是n的倍数如果不是补null
getNewArr(arr, n) {
let count = n - arr.length % n;
for (let i = 0; i < count; i++) {
arr.push(null);
}
return arr;
},
//获取随机颜色
getRandomColor() {
return '#' + ('00000' + (Math.random() * 0x1000000 << 0).toString(16)).slice(-6);
},
goPage(navigation, type, page, params) {
console.log(44, type, page, params);
if(page === '#') {
myToast.show('敬请期待','error');
return;
}
if (type === 2) {
console.log(57, '外链')
Linking.openURL(page);
} else {
console.log(60, '内链')
const json = this.toJSON(_.replace(params, /&quot;/g, '"'));
navigation.navigate(page, { ...json });
}
},
toJSON(str) {
try {
const a = JSON.parse(str);
return a;
} catch (error) {
return {};
}
},
getIntegerAndDecimal(num, type = 1) {
//type 1:去掉小数点前面的0 2:去掉0.,整数部分加小数点
let integer = Math.floor(num).toFixed(0)
let decimal = (num - integer).toFixed(1)
if (decimal === '0.0') {
return { integer, decimal: '' }
}
if (type === 1) {
decimal = decimal.replace('0', '')
} else if (type === 2) {
decimal = decimal.replace('0.', '')
integer += '.'
}
return { integer, decimal }
},
getHideString(str){
//写一个方法,判断如果是手机号,隐藏中间四位,如果是邮箱,隐藏@前面的,如果是汉字,隐藏后面的
if(!str) return '';
if(str.indexOf('@') > -1){
let arr = str.split('@');
return arr[0].substr(0,1) + '****' + arr[0].substr(arr[0].length-1) + '@' + arr[1];
}
if(str.length === 11){
return str.substr(0,3) + '****' + str.substr(7);
}
if(str.length > 1){
return str.substr(0,1) + '**';
}
},
};

37
src/helper/myToast.tsx Normal file
View File

@ -0,0 +1,37 @@
type ToastType = 'success' | 'error';
import React from 'react';
import _Toast from 'react-native-toast-message';
export default {
show(
text: string,
type: ToastType = 'success',
duration: number = 3500,
hide: () => void | undefined = () => {},
) {
// _Toast.hide();
_Toast.show({
text1: '提示',
text2: text,
type,
visibilityTime: duration,
onHide: hide,
});
},
useBlur(
navigation: any,
customFunc: (() => void) | null = null,
) {
React.useEffect(() => {
const unsubscribe = navigation.addListener('blur', () => {
// do something
_Toast.hide();
if (customFunc != null) {
customFunc();
}
});
return unsubscribe;
}, [navigation]);
},
};
export const Toast = _Toast;

14
src/helper/pxToDp.js Normal file
View File

@ -0,0 +1,14 @@
import {
Dimensions,
PixelRatio
} from 'react-native';
//视屏的宽度和设置像素转换
const deviceWidthDp = Dimensions.get('window').width;
const uiWidthPx = 750;
function pxToDp(uiElementPx,isConvert = true) {
if(!isConvert) return uiElementPx;
return Number((uiElementPx * deviceWidthDp / uiWidthPx));
}
export default pxToDp

71
src/i18n/index.ts Normal file
View File

@ -0,0 +1,71 @@
import i18n from 'i18next';
import { initReactI18next } from 'react-i18next';
import AsyncStorage from '@react-native-async-storage/async-storage';
import zh from './locales/zh.json';
import en from './locales/en.json';
const LANGUAGE_KEY = '@app_language';
// 语言配置类型
export interface LanguageOption {
key: string;
label: string;
value: string;
}
// 语言选项
export const LANGUAGES: LanguageOption[] = [
{
key: 'zh',
label: '中文',
value: 'zh',
},
{
key: 'en',
label: 'English',
value: 'en',
},
];
// 获取存储的语言
export const getStoredLanguage = async (): Promise<string> => {
try {
const language = await AsyncStorage.getItem(LANGUAGE_KEY);
return language || 'zh';
} catch (error) {
return 'zh';
}
};
// 保存语言选择
export const saveLanguage = async (language: string): Promise<void> => {
try {
await AsyncStorage.setItem(LANGUAGE_KEY, language);
} catch (error) {
console.error('保存语言失败:', error);
}
};
// 初始化 i18n
i18n
.use(initReactI18next)
.init({
compatibilityJSON: 'v4',
resources: {
zh: { translation: zh },
en: { translation: en },
},
lng: 'zh',
fallbackLng: 'zh',
interpolation: {
escapeValue: false,
},
});
// 从存储中加载语言设置
getStoredLanguage().then((language) => {
i18n.changeLanguage(language);
});
export default i18n;

258
src/i18n/locales/en.json Normal file
View File

@ -0,0 +1,258 @@
{
"nav": {
"scan": "Scan",
"info": "Device Info",
"dfu": "Firmware Update",
"privacy": "Privacy Policy"
},
"home": {
"title": "POWERFUN Settings",
"scan": "Scan Devices",
"privacy": "Privacy Policy",
"version": "Version v0.0.1",
"powerMeter": "Power Meter",
"paddle": "Paddle",
"T5trainer": "T5 Trainer"
},
"scan": {
"title": "Scan Devices",
"scanning": "Scanning...",
"noDevice": "No Devices Found",
"tipScanning": "(Make sure the device is powered on and awake)",
"tipBluetooth": "(Please enable Bluetooth in settings)",
"noName": "[No Name]",
"rssiUnit": "dBm"
},
"t5Scan": {
"title": "Scan T5 Trainer",
"scanning": "Scanning...",
"noDevice": "No T5 trainers found",
"tipScanning": "(Make sure the trainer is powered on and awake)",
"tipBluetooth": "(Please enable Bluetooth in settings)"
},
"paddleScan": {
"title": "Scan Paddle",
"scanning": "Scanning...",
"noDevice": "No paddle devices found",
"tipScanning": "(Make sure the paddle device is powered on and awake)",
"tipBluetooth": "(Please enable Bluetooth in settings)",
"noName": "[No Name]",
"rssiUnit": "dBm"
},
"common": {
"notice": "Notice",
"unknown": "Unknown",
"unknownDevice": "Unknown Device",
"yes": "Yes",
"no": "No"
},
"info2": {
"waitingAction": "Waiting",
"notMatched": "Not Matched",
"checkFailed": "Check Failed",
"unknownBoatType": "Unknown boat type",
"readAbnormal": "Unexpected response: {{hex}}",
"writingBoat": "Writing {{boatName}}...",
"writtenWaitRead": "Written {{boatName}}, reading back in 200ms...",
"requestingBoatRead": "Requesting boat type...",
"setSuccess": "Set success: {{boatName}}",
"notMatch": "Mismatch: expected {{expected}}, actual {{actual}}",
"readResult": "Read result: {{hex}}",
"actionFailed": "Action failed: {{message}}",
"failed": "Failed",
"waitFff3Timeout": "Timeout waiting for FFF3 response",
"currentBoat": "✅ Current boat type: {{boatName}}",
"readCurrentBoat": "Current boat type read: {{boatName}}",
"disconnectedNeedReconnect": "Device disconnected. Reconnect before upgrading.",
"defaultReadFailed": "Connected, but initial boat type read failed",
"connectOrReadFailed": "Connection or read failed",
"connectOrReadFailedAlert": "Bluetooth connection failed or device info read failed",
"cadence": "Cadence",
"cadenceUnit": "RPM (strokes/min)",
"boatSelect": "Boat Type",
"boatSwitching": "Switching boat type...",
"retry": "Please retry",
"boatKayak": "Kayak",
"boatRowing": "Rowing",
"boatRacing": "Racing",
"latestFirmware": "Latest Firmware",
"checking": "Checking..."
},
"info3": {
"bikeTypeFollow": "Follow Software",
"bikeTypeRoad": "Road Bike",
"bikeTypeMtb26": "Mountain Bike 26\"",
"bikeTypeMtb275": "Mountain Bike 27.5\"",
"bikeTypeMtb29": "Mountain Bike 29\"",
"bikeTypeSmallWheel": "Small Wheel Bike",
"ergOn": "On",
"ergOff": "Off",
"readUsedHoursTimeout": "Timeout reading usage hours",
"readUsedMileageTimeout": "Timeout reading usage mileage",
"powerTrimRangeError": "Please enter a value between 50.00 and 200.00 with up to 2 decimals",
"deviceNotReady": "Device is not ready yet. Please try again later",
"invalidWeight": "Please enter a valid weight",
"waitFff3Timeout": "Timeout waiting for FFF3 response",
"readPowerTrimTimeout": "Timeout reading current power trim",
"waitWeightAckTimeout": "Timeout waiting for weight setting confirmation",
"readWeightTimeout": "Timeout reading current weight",
"readBikeTypeTimeout": "Timeout reading bike type",
"waitBikeTypeAckTimeout": "Timeout waiting for bike type confirmation",
"readErgTimeout": "Timeout reading ERG smoothing",
"waitErgAckTimeout": "Timeout waiting for ERG smoothing confirmation",
"connectReadFailed": "Device connection or read failed",
"readFailed": "Read failed",
"connecting": "Connecting",
"pendingTag": "Pending",
"settingTag": "Applying",
"currentWeightValue": "Current: {{value}} kg",
"currentPowerTrimValue": "Current: {{value}} %",
"currentTextValue": "Current: {{value}}",
"usedMileage": "Usage Mileage",
"speedKph": "Speed/km/h",
"weightSetting": "Weight Setting",
"powerTrim": "Power Trim",
"bikeType": "Bike Type",
"ergSmooth": "ERG Smoothing",
"settingWeight": "Setting weight...",
"weightSetDone": "Weight set successfully",
"weightSetFailed": "Weight setting failed",
"settingPowerTrim": "Setting power trim...",
"powerTrimSetDone": "Power trim set successfully",
"powerTrimSetFailed": "Power trim setting failed",
"settingBikeType": "Setting bike type...",
"bikeTypeSetDone": "Bike type set successfully",
"bikeTypeSetFailed": "Bike type setting failed",
"settingErgSmooth": "Setting ERG smoothing...",
"ergSmoothSetDone": "ERG smoothing set successfully",
"ergSmoothSetFailed": "ERG smoothing setting failed",
"confirmWeightChange": "Set weight to {{value}}kg?",
"confirmPowerTrimChange": "Set power trim to {{value}}%?",
"helpText": "Hold to view help"
},
"spindown": {
"title": "Spindown",
"headerTitle": "Trainer Spindown",
"connecting": "Connecting",
"connected": "Connected",
"targetLabel": "Target Speed",
"targetHint": "Ride to reach the target speed first",
"currentSpeed": "Current Speed",
"statusReach36": "Ride to reach {{speed}} km/h",
"statusReached36": "Reached {{high}} km/h. Stop pedaling and wait until speed drops to {{low}} km/h",
"statusCalibrating": "Spindown in progress, please wait",
"statusCompleted": "Spindown completed",
"deviceNotReady": "Device not ready, please try again",
"connectFailed": "Failed to connect. Please go back and try again",
"timeout": "Timed out waiting for spindown time",
"failedRetry": "Spindown failed, please try again",
"step1Title": "Ride to {{speed}} km/h",
"step1Desc": "Automatically proceeds after reaching the target speed",
"step2Title": "Stop pedaling and coast",
"step2Desc": "Starts spindown automatically when speed drops to {{speed}} km/h",
"step3Title": "Spindown Completed",
"step3Loading": "Reading spindown time...",
"step3Pending": "Will appear here after completion",
"loading": "Connecting and preparing spindown...",
"result": "Spindown completed, time {{seconds}}s",
"retry": "Restart Spindown"
},
"dfu": {
"title": "Firmware Update",
"preparing": "Preparing...",
"reading": "Reading...",
"bluetoothName": "Bluetooth Name",
"latestVersion": "Latest Version",
"currentVersion": "Current Version",
"upgradeStatus": "Update Status",
"stateConnecting": "Connecting…",
"stateStarting": "Initializing…",
"stateEnablingDfuMode": "Enabling DFU Mode…",
"stateUploading": "Uploading Firmware…",
"stateValidating": "Validating Firmware…",
"stateDisconnecting": "Disconnecting…",
"stateCompleted": "Update Completed",
"stateAborted": "Aborted",
"stateFailed": "Update Failed",
"stateInitializing": "Starting…",
"stateErrored": "Update Error!",
"pleaseWait": "Please Wait",
"doNotReturn": "Updating in progress, do not go back or close the app!",
"cannotUpgrade": "Cannot Update",
"hardwareNotFound": "Firmware not found for hardware version {hardware}",
"noNeedUpgrade": "No Update Needed",
"alreadyLatest": "Already the latest firmware, no update needed",
"upgradeSuccess": "Update Successful",
"upgradeSuccessMessage": "Update successful, please reconnect the device",
"upgradeFailed": "Update Failed",
"dfuFailed": "DFU Failed",
"confirm": "OK"
},
"privacy": {
"title": "Privacy Policy",
"content": "This application respects and protects the personal privacy of all users. This Privacy Policy applies only to the POWERFUN Settings App products or services provided by Wuxi Zhixingpai Sports Culture Development Co., Ltd. Please read and fully understand this policy before using our products or services.\n\n1. How We Collect and Use Your Information\n\n1. The POWERFUN Settings App does not require registration or login and does not collect any personal information.\n\n2. During your use of our products or services, we may use the following permissions:\n- Android ID\n- Storage\n- Location\n- Bluetooth\n\n3. Third-party SDK usage:\n\n(1) Tencent Bugly \nProvider: Tencent Computer Systems Company Limited \nPurpose: crash reporting and performance monitoring.\n\n(2) Aliyun OSS \nProvider: Alibaba Cloud Computing Co., Ltd. \nPurpose: storage of configuration and firmware update files.\n\n2. Updates to This Policy\n\nWe may update this policy from time to time. Changes will be published on this page.\n\n3. Contact Us\n\nEmail: bike99@qq.com\n\nWuxi Zhixingpai Sports Culture Development Co., Ltd. \nEffective date: July 1, 2019"
},
"settings": {
"title": "Settings",
"language": "Language",
"privacy": "Privacy Policy",
"version": "Version"
},
"languageModal": {
"title": "Language"
},
"info": {
"title": "Device Info",
"bluetoothName": "Bluetooth Name",
"idNumber": "ID Number",
"firmwareVersion": "Firmware Version",
"battery": "Battery",
"connectionStatus": "Connection Status",
"connected": "Connected",
"disconnected": "Disconnected",
"reading": "Reading...",
"power": "Power/W",
"cadence": "Cadence/RPM",
"balance": "L/R Balance/%",
"balanceHeader": "L / R",
"powerTrimTitle": "Power Trim Settings",
"currentTrim": "Current Trim",
"trimPlaceholder": "Enter 50-200",
"updateValue": "Update Value",
"calibrateButton": "Calibrate Zero",
"calibrating": "Calibrating... Waiting for device",
"firmwareUpgrade": "Firmware Update",
"readingInfo": "Reading information...",
"readSuccess": "Read successfully!",
"writingTrim": "Writing power trim...",
"trimUpdateSuccess": "Power trim updated successfully!",
"disconnectTitle": "Notice",
"disconnectMessage": "Device disconnected, please reconnect",
"reconnectMessage": "Please reconnect the device",
"confirm": "OK",
"trimRangeAlert": "Power trim adjusts the power meter's high and low deviation. Default value is 100%. Adjustable range is 50%-200%. Please enter a number between 50 and 200 without the % symbol. Click the button below to update the power meter after entering.",
"deviceNotConnected": "Device not connected",
"writeFailed": "Write failed",
"calibrationSuccess": "Calibration Successful",
"calibrationValue": "Calibration Value",
"calibrationError": "Calibration Error",
"calibrationTimeout": "Device not responding, please try again",
"calibrationFormatError": "Invalid response format from device",
"calibrationSendError": "Failed to send calibration command",
"error": "Error"
}
}

257
src/i18n/locales/zh.json Normal file
View File

@ -0,0 +1,257 @@
{
"nav": {
"scan": "搜索",
"info": "设备信息",
"dfu": "固件升级",
"privacy": "隐私协议"
},
"home": {
"title": "POWERFUN设置",
"scan": "搜索设备",
"privacy": "隐私协议",
"version": "版本号 v0.0.1",
"powerMeter": "功率计",
"paddle": "桨频器",
"T5trainer": "T5骑行台"
},
"scan": {
"title": "搜索设备",
"scanning": "搜索中...",
"noDevice": "暂无设备",
"tipScanning": "(请确保设备有电且被唤醒)",
"tipBluetooth": "(请在设置中打开蓝牙)",
"noName": "[无名称]",
"rssiUnit": "dBm"
},
"t5Scan": {
"title": "搜索T5骑行台",
"scanning": "搜索中...",
"noDevice": "暂无T5骑行台设备",
"tipScanning": "(请确保骑行台有电且被唤醒)",
"tipBluetooth": "(请在设置中打开蓝牙)"
},
"paddleScan": {
"title": "搜索桨频器",
"scanning": "搜索中...",
"noDevice": "暂无桨频器设备",
"tipScanning": "(请确保桨频器设备有电且被唤醒)",
"tipBluetooth": "(请在设置中打开蓝牙)",
"noName": "[无名称]",
"rssiUnit": "dBm"
},
"common": {
"notice": "提示",
"unknown": "未知",
"unknownDevice": "未知设备",
"yes": "是",
"no": "否"
},
"info2": {
"waitingAction": "等待操作",
"notMatched": "未匹配",
"checkFailed": "检查失败",
"unknownBoatType": "读取到未知船型",
"readAbnormal": "读取返回异常:{{hex}}",
"writingBoat": "正在写入{{boatName}}...",
"writtenWaitRead": "已写入{{boatName}}200ms后读取确认...",
"requestingBoatRead": "正在请求读取船型...",
"setSuccess": "设置成功:{{boatName}}",
"notMatch": "不一致:期望 {{expected}},实际 {{actual}}",
"readResult": "读取返回:{{hex}}",
"actionFailed": "操作失败:{{message}}",
"failed": "失败",
"waitFff3Timeout": "等待 FFF3 返回超时",
"currentBoat": "✅ 当前船型:{{boatName}}",
"readCurrentBoat": "已读取当前船型:{{boatName}}",
"disconnectedNeedReconnect": "设备已断开,请重新连接",
"defaultReadFailed": "已连接,但默认读取船型失败",
"connectOrReadFailed": "连接或读取失败",
"connectOrReadFailedAlert": "蓝牙连接失败或设备信息读取失败",
"cadence": "桨频",
"cadenceUnit": "RPM(次/分钟)",
"boatSelect": "船型选择",
"boatSwitching": "船型切换中...",
"retry": "请重试",
"boatKayak": "皮艇",
"boatRowing": "划艇",
"boatRacing": "赛艇",
"latestFirmware": "最新固件",
"checking": "检查中..."
},
"info3": {
"bikeTypeFollow": "跟随软件",
"bikeTypeRoad": "公路车",
"bikeTypeMtb26": "山地车26寸",
"bikeTypeMtb275": "山地车27.5寸",
"bikeTypeMtb29": "山地车29寸",
"bikeTypeSmallWheel": "小轮车",
"ergOn": "开启",
"ergOff": "关闭",
"readUsedMileageTimeout": "读取使用里程超时",
"powerTrimRangeError": "请输入50.00-200.00之间的数,最多保留两位小数",
"deviceNotReady": "设备尚未准备完成,请稍后再试",
"invalidWeight": "请输入正确的体重",
"waitFff3Timeout": "等待FFF3返回超时",
"readPowerTrimTimeout": "读取当前功率微调超时",
"waitWeightAckTimeout": "等待体重设定确认超时",
"readWeightTimeout": "读取当前体重超时",
"readBikeTypeTimeout": "读取当前车型超时",
"waitBikeTypeAckTimeout": "等待车型设定确认超时",
"readErgTimeout": "读取ERG功率平滑超时",
"waitErgAckTimeout": "等待ERG功率平滑确认超时",
"connectReadFailed": "设备连接或读取失败",
"readFailed": "读取失败",
"connecting": "连接中",
"pendingTag": "待保存",
"settingTag": "设置中",
"currentWeightValue": "当前:{{value}} kg",
"currentPowerTrimValue": "当前:{{value}} %",
"currentTextValue": "当前:{{value}}",
"usedMileage": "使用里程",
"speedKph": "速度/km/h",
"weightSetting": "体重设定",
"powerTrim": "功率微调",
"bikeType": "车型选择",
"ergSmooth": "ERG功率平滑",
"settingWeight": "正在设置体重…",
"weightSetDone": "体重设定完成",
"weightSetFailed": "体重设定失败",
"settingPowerTrim": "正在设置功率微调…",
"powerTrimSetDone": "功率微调设定完成",
"powerTrimSetFailed": "功率微调设定失败",
"settingBikeType": "车型设定中",
"bikeTypeSetDone": "车型设定完成",
"bikeTypeSetFailed": "车型设定失败",
"settingErgSmooth": "ERG功率平滑设定中",
"ergSmoothSetDone": "ERG功率平滑设定完成",
"ergSmoothSetFailed": "ERG功率平滑设定失败",
"confirmWeightChange": "是否将体重改为{{value}}kg",
"confirmPowerTrimChange": "是否将功率微调改为{{value}}%",
"helpText": "按住查看说明"
},
"spindown": {
"title": "消旋",
"headerTitle": "骑行台消旋",
"connecting": "设备连接中",
"connected": "设备已连接",
"targetLabel": "目标速度",
"targetHint": "请先骑行加速到目标速度",
"currentSpeed": "当前速度",
"statusReach36": "请骑行加速到 {{speed}} km/h",
"statusReached36": "已达到 {{high}} km/h请停止踩踏并等待速度下降到 {{low}} km/h",
"statusCalibrating": "正在消旋,请等待",
"statusCompleted": "消旋完成",
"deviceNotReady": "设备未准备好,请稍后重试",
"connectFailed": "设备连接失败,请返回重试",
"timeout": "等待消旋时间返回超时",
"failedRetry": "消旋失败,请重试",
"step1Title": "请骑行到 {{speed}} km/h",
"step1Desc": "达到目标速度后自动进入下一步",
"step2Title": "停止踩踏并等待减速",
"step2Desc": "速度下降到 {{speed}} km/h 后自动开始消旋",
"step3Title": "消旋完成",
"step3Loading": "正在读取消旋时间...",
"step3Pending": "完成前会显示在这里",
"loading": "正在连接设备并准备消旋...",
"result": "消旋完成,时间 {{seconds}}s",
"retry": "重新开始消旋"
},
"dfu": {
"title": "固件升级",
"preparing": "准备中...",
"reading": "读取中...",
"bluetoothName": "蓝牙名称",
"latestVersion": "最新版本",
"currentVersion": "当前版本",
"upgradeStatus": "升级状态",
"stateConnecting": "连接中…",
"stateStarting": "初始化中…",
"stateEnablingDfuMode": "启用 DFU 模式…",
"stateUploading": "上传固件中…",
"stateValidating": "校验固件…",
"stateDisconnecting": "断开连接…",
"stateCompleted": "升级完成",
"stateAborted": "已取消",
"stateFailed": "升级失败",
"stateInitializing": "启动中…",
"stateErrored": "升级出错!",
"pleaseWait": "请稍候",
"doNotReturn": "正在升级,请勿返回或关闭应用!",
"cannotUpgrade": "无法升级",
"hardwareNotFound": "未找到硬件版本 {hardware} 的固件",
"noNeedUpgrade": "无需升级",
"alreadyLatest": "已是最新固件,无需升级",
"upgradeSuccess": "升级成功",
"upgradeSuccessMessage": "升级成功,请重连设备",
"upgradeFailed": "升级失败",
"dfuFailed": "DFU失败",
"confirm": "确认"
},
"privacy": {
"title": "隐私协议",
"content": "本应用尊重并保护所有使用服务用户的个人隐私权。本隐私政策仅适用于无锡执行派体育文化发展有限公司的 POWERFUN 设置 APP 产品或服务。请在使用我们的产品或服务前,仔细阅读并了解本隐私政策。\n\n一、我们如何收集和使用您的信息\n\n1. POWERFUN 设置 APP 不需要注册和登录,也不会收集任何关于个人的信息。\n\n2. 在您使用我司产品或服务过程中,我们可能会使用以下权限和信息:\n- Android ID用于第三方或我们分析错误信息。\n- 存储权限:用于管理本地缓存。\n- 位置权限:用于设备产品与 APP 蓝牙连接。\n- 蓝牙权限:用于设备产品与 APP 蓝牙连接。\n\n3. 关于第三方 SDK 使用说明:\n\n1第三方 SDK 名称:腾讯 Bugly \n提供方深圳市腾讯计算机系统有限公司 \n收集类型系统版本、设备 ID、手机型号、网络状态、系统设置、网络与 WiFi 信息、手机状态、系统日志等。 \n使用目的用于监控应用性能与稳定性收集崩溃报告和使用数据以帮助我们识别并修复问题。\n\n2第三方 SDK 名称Aliyun OSS \n提供方阿里云计算有限公司 \n使用目的用于存储应用运行必要的配置和固件升级文件确保数据可靠存储。\n\n二、本政策如何更新\n\n我们的隐私政策可能会根据需求进行变更。未经您明确同意我们不会削减您依据隐私政策应享有的权利。任何变更我们将在本页面公布。对于重大变更我们会提供更为显著的通知。\n\n重大变更包括但不限于\n- 服务模式发生重大变化(如用户信息处理目的、类型、方式等)。\n- 在所有权或组织架构方面发生变更(如业务调整、破产并购等)。\n- 用户信息安全影响评估报告显示存在高风险。\n\n三、如何联系我们\n\n如果您对本隐私政策有任何疑问、意见或建议可通过以下方式与我们联系\n电子邮件bike99@qq.com\n\n一般情况下我们将在三十天内回复。\n\n无锡执行派体育文化发展有限公司 \n本政策自 2019 年 7 月 1 日起生效"
},
"settings": {
"title": "设置",
"language": "软件语言",
"privacy": "隐私协议",
"version": "版本号"
},
"languageModal": {
"title": "软件语言"
},
"info": {
"title": "设备信息",
"bluetoothName": "蓝牙名称",
"idNumber": "ID号",
"firmwareVersion": "固件版本",
"battery": "电量",
"connectionStatus": "连接状态",
"connected": "已连接",
"disconnected": "未连接",
"reading": "读取中...",
"power": "功率/W",
"cadence": "踏频/RPM",
"balance": "左右平衡/%",
"balanceHeader": "L / R",
"powerTrimTitle": "功率微调设置",
"currentTrim": "当前微调",
"trimPlaceholder": "输入50-200",
"updateValue": "更新数值",
"calibrateButton": "校准归零",
"calibrating": "校准中...等待设备反应",
"firmwareUpgrade": "固件升级",
"readingInfo": "正在读取信息...",
"readSuccess": "读取成功!",
"writingTrim": "正在写入功率微调...",
"trimUpdateSuccess": "功率微调更新成功!",
"disconnectTitle": "提示",
"disconnectMessage": "设备已断开,请重新连接设备",
"reconnectMessage": "请重新连接设备",
"confirm": "确定",
"trimRangeAlert": "功率微调可调整功率计的高低偏差默认值100%。可调整的范围是50%-200%。请输入50至200的纯数字不需要包含%符号。输入后点击下方按钮更新进功率计设备。",
"deviceNotConnected": "设备未连接",
"writeFailed": "写入失败",
"calibrationSuccess": "校准成功",
"calibrationValue": "校准值",
"calibrationError": "校准错误",
"calibrationTimeout": "设备未响应,请重试",
"calibrationFormatError": "设备返回数据格式错误",
"calibrationSendError": "发送校准命令失败",
"error": "错误"
}
}

BIN
src/img/Return.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 858 B

BIN
src/img/Return2.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 729 B

BIN
src/img/Search.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 75 KiB

BIN
src/img/Settings.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

BIN
src/img/_Versionnumber.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

View File

@ -0,0 +1,4 @@
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
import { RootStackParamList } from "../../App";
export type RootNavigationProp = NativeStackNavigationProp<RootStackParamList>;

801
yarn.lock

File diff suppressed because it is too large Load Diff