New setup from scratch - all modules updated - app now in subfolder
3
SwimTracker/.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
/node_modules
|
||||
/.expo
|
||||
.directory
|
||||
153
SwimTracker/App.js
Normal file
@@ -0,0 +1,153 @@
|
||||
import React from 'react';
|
||||
import AppLoading from 'expo-app-loading';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import * as Font from 'expo-font';
|
||||
|
||||
// Redux + Storage
|
||||
import swimtrackerReducer from './state/Reducer';
|
||||
import { createStore } from 'redux';
|
||||
import { ConnState, WifiState, DeviceReduxCoupling } from './state/DeviceReduxCoupling';
|
||||
import { Provider } from 'react-redux';
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
import { persistStore, persistReducer } from 'redux-persist'
|
||||
import hardSet from 'redux-persist/lib/stateReconciler/hardSet'
|
||||
import { PersistGate } from 'redux-persist/integration/react'
|
||||
|
||||
// Navigation
|
||||
import { NavigationContainer } from '@react-navigation/native';
|
||||
import { createStackNavigator } from '@react-navigation/stack';
|
||||
|
||||
// Own views
|
||||
import MainMenuView from "./views/MainMenuView";
|
||||
import SettingsView from "./views/SettingsView";
|
||||
import TrainingView from "./views/TrainingView";
|
||||
import LastSessionsView from "./views/LastSessionsView";
|
||||
import ConnectingView from './views/ConnectingView';
|
||||
import WifiSelectionView from './views/WifiSelectionView';
|
||||
import WifiPasswordView from './views/WifiPasswordView';
|
||||
|
||||
const persistConfig = {
|
||||
key: 'root',
|
||||
storage: AsyncStorage,
|
||||
// stateReconciler: hardSet,
|
||||
};
|
||||
|
||||
const persistedReducer = persistReducer(persistConfig, swimtrackerReducer);
|
||||
const store = createStore(persistedReducer);
|
||||
const persistor = persistStore(store);
|
||||
const Stack = createStackNavigator();
|
||||
|
||||
export default class App extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
isReady: false,
|
||||
disconnected: true,
|
||||
isProvisioning: false,
|
||||
};
|
||||
this.unsubscribe = undefined;
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.setState({ isReady: true });
|
||||
this.device = new DeviceReduxCoupling(store);
|
||||
|
||||
let theApp = this;
|
||||
this.unsubscribe = store.subscribe(() => {
|
||||
const state = store.getState();
|
||||
theApp.setState({
|
||||
disconnected: state.deviceState.connState == ConnState.DISCONNECTED,
|
||||
isProvisioning: state.deviceState.wifiState == WifiState.AP_PROVISIONING || state.deviceState.wifiState == WifiState.UNKNOWN,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
if (this.unsubscribe) {
|
||||
console.log("Unsubscribe");
|
||||
this.unsubscribe();
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
if (!this.state.isReady) {
|
||||
return <AppLoading />;
|
||||
}
|
||||
const screenOptions = {
|
||||
headerShown: false,
|
||||
};
|
||||
|
||||
let disconnectedView = (
|
||||
<>
|
||||
<Stack.Screen
|
||||
name="ConnectingView"
|
||||
options={screenOptions}
|
||||
component={ConnectingView} />
|
||||
</>
|
||||
);
|
||||
|
||||
let provisioningView = (
|
||||
<>
|
||||
<Stack.Screen
|
||||
name="WifiSelectionView"
|
||||
options={screenOptions} >
|
||||
{props => <WifiSelectionView {...props} device={this.device} />}
|
||||
</Stack.Screen>
|
||||
<Stack.Screen
|
||||
name="WifiPasswordView"
|
||||
options={screenOptions}
|
||||
component={WifiPasswordView}
|
||||
>
|
||||
</Stack.Screen>
|
||||
</>
|
||||
);
|
||||
|
||||
let normalView = (
|
||||
<>
|
||||
<Stack.Screen
|
||||
name="Home"
|
||||
component={MainMenuView}
|
||||
options={screenOptions}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="Settings"
|
||||
options={screenOptions}>
|
||||
{props => <SettingsView {...props} device={this.device} />}
|
||||
</Stack.Screen>
|
||||
<Stack.Screen
|
||||
name="Training"
|
||||
component={TrainingView}
|
||||
options={screenOptions}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="LastSessions"
|
||||
component={LastSessionsView}
|
||||
options={screenOptions}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
let activeView;
|
||||
if (this.state.disconnected)
|
||||
activeView = disconnectedView;
|
||||
else if (this.state.isProvisioning)
|
||||
activeView = provisioningView;
|
||||
else
|
||||
activeView = normalView;
|
||||
|
||||
return (
|
||||
<Provider store={store}>
|
||||
<PersistGate loading={<AppLoading />} persistor={persistor}>
|
||||
<NavigationContainer>
|
||||
<Stack.Navigator >
|
||||
{activeView}
|
||||
</Stack.Navigator>
|
||||
</NavigationContainer>
|
||||
</PersistGate>
|
||||
</Provider>
|
||||
);
|
||||
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
33
SwimTracker/app.json
Normal file
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"expo": {
|
||||
"name": "SwimTracker",
|
||||
"slug": "SwimTracker",
|
||||
"version": "1.0.0",
|
||||
"orientation": "portrait",
|
||||
"icon": "./assets/icon.png",
|
||||
"userInterfaceStyle": "light",
|
||||
"splash": {
|
||||
"image": "./assets/splash.png",
|
||||
"resizeMode": "contain",
|
||||
"backgroundColor": "#ffffff"
|
||||
},
|
||||
"assetBundlePatterns": [
|
||||
"**/*"
|
||||
],
|
||||
"ios": {
|
||||
"supportsTablet": true
|
||||
},
|
||||
"android": {
|
||||
"adaptiveIcon": {
|
||||
"foregroundImage": "./assets/adaptive-icon.png",
|
||||
"backgroundColor": "#ffffff"
|
||||
}
|
||||
},
|
||||
"web": {
|
||||
"favicon": "./assets/favicon.png"
|
||||
},
|
||||
"plugins": [
|
||||
"expo-localization"
|
||||
]
|
||||
}
|
||||
}
|
||||
17
SwimTracker/assets/.expo/README.md
Normal file
@@ -0,0 +1,17 @@
|
||||
> Why do I have a folder named ".expo" in my project?
|
||||
|
||||
The ".expo" folder is created when an Expo project is started using "expo start" command.
|
||||
|
||||
> What does the "packager-info.json" file contain?
|
||||
|
||||
The "packager-info.json" file contains port numbers and process PIDs that are used to serve the application to the mobile device/simulator.
|
||||
|
||||
> What does the "settings.json" file contain?
|
||||
|
||||
The "settings.json" file contains the server configuration that is used to serve the application manifest.
|
||||
|
||||
> Should I commit the ".expo" folder?
|
||||
|
||||
No, you should not share the ".expo" folder. It does not contain any information that is relevant for other developers working on the project, it is specific to your machine.
|
||||
|
||||
Upon project creation, the ".expo" folder is already added to your ".gitignore" file.
|
||||
BIN
SwimTracker/assets/adaptive-icon.png
Normal file
|
After Width: | Height: | Size: 17 KiB |
BIN
SwimTracker/assets/blue-water-background.jpg
Normal file
|
After Width: | Height: | Size: 419 KiB |
BIN
SwimTracker/assets/favicon.png
Normal file
|
After Width: | Height: | Size: 1.4 KiB |
BIN
SwimTracker/assets/icon-ios.png
Normal file
|
After Width: | Height: | Size: 9.8 KiB |
BIN
SwimTracker/assets/icon.png
Normal file
|
After Width: | Height: | Size: 20 KiB |
BIN
SwimTracker/assets/icon.xcf
Normal file
BIN
SwimTracker/assets/infinity_pool.jpg
Normal file
|
After Width: | Height: | Size: 253 KiB |
BIN
SwimTracker/assets/infinity_pool2.jpg
Normal file
|
After Width: | Height: | Size: 340 KiB |
BIN
SwimTracker/assets/pool-water.jpg
Normal file
|
After Width: | Height: | Size: 602 KiB |
BIN
SwimTracker/assets/pool_sky_background_blurred.jpg
Normal file
|
After Width: | Height: | Size: 740 KiB |
BIN
SwimTracker/assets/splash.png
Normal file
|
After Width: | Height: | Size: 899 KiB |
BIN
SwimTracker/assets/splash.xcf
Normal file
BIN
SwimTracker/assets/swimmer.jpg
Normal file
|
After Width: | Height: | Size: 137 KiB |
6
SwimTracker/babel.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
module.exports = function(api) {
|
||||
api.cache(true);
|
||||
return {
|
||||
presets: ['babel-preset-expo'],
|
||||
};
|
||||
};
|
||||
109
SwimTracker/cli/index.js
Normal file
@@ -0,0 +1,109 @@
|
||||
import SwimTrackerWebsocketConnection from "../data_processing/SwimTrackerWebsocketConnection.js";
|
||||
import yargs from 'yargs';
|
||||
import WS from 'ws';
|
||||
import { SerialPort } from 'serialport'
|
||||
import { ReadlineParser } from '@serialport/parser-readline'
|
||||
import chalk from 'chalk';
|
||||
|
||||
|
||||
/**
|
||||
* Opens serial port and calls provided function on each line
|
||||
*
|
||||
* @param {string} serialPortPath
|
||||
* @param {(str) => any} onLineReceived
|
||||
*/
|
||||
function openSerialConnection(serialPortPath, onLineReceived) {
|
||||
const port = new SerialPort({
|
||||
path: serialPortPath,
|
||||
baudRate: 115200
|
||||
});
|
||||
const parser = port.pipe(new ReadlineParser());
|
||||
parser.on('data', onLineReceived);
|
||||
}
|
||||
|
||||
function basicTest(deviceIpOrHostname, serialPort) {
|
||||
|
||||
let lastHostLog = undefined;
|
||||
let lastDeviceLog = undefined;
|
||||
|
||||
function log(color, main, startCol) {
|
||||
if(lastHostLog === undefined)
|
||||
lastHostLog = Date.now();
|
||||
const currentTime = Date.now();
|
||||
const dt = currentTime - lastHostLog;
|
||||
lastHostLog = currentTime;
|
||||
|
||||
console.log(color((startCol).padEnd(9), "|", String(dt).padStart(5), "|", main));
|
||||
}
|
||||
|
||||
function logDeviceTime(color, main, column1, time) {
|
||||
if(lastDeviceLog === undefined)
|
||||
lastDeviceLog = time;
|
||||
const dt = time - lastDeviceLog;
|
||||
lastDeviceLog = time;
|
||||
|
||||
console.log(color((column1).padEnd(9), "|", String(dt).padStart(5), "|", main));
|
||||
}
|
||||
|
||||
const onStarted = (sessionid) => { log(chalk.yellow, "Session started " + sessionid, "WS Msg") };
|
||||
const onStopped = () => { log(chalk.yellow, "Session stopped", "WS Msg") };
|
||||
const onWifiStateInfo = (wifiInfo) => { log(chalk.cyan, JSON.stringify(wifiInfo), "WS Wifi") };
|
||||
const onWebsocketLog = (logMsg) => {logDeviceTime(chalk.blue, logMsg.msg, "WS Log", String(logMsg.time))};
|
||||
const onDisconnect = () => { log(chalk.red, "Disconnected", "WS Msg") };
|
||||
const onConnect = () => {
|
||||
log(chalk.blue,"Connected to device", "WS Msg")
|
||||
conn.sendLogStreamStartCommand();
|
||||
};
|
||||
|
||||
let data = [];
|
||||
|
||||
const onNewMeasurementData = (newDataAsUint16Arr) => {
|
||||
const arr = Array.from(newDataAsUint16Arr);
|
||||
newDataAsUint16Arr.forEach(element => {
|
||||
data.push(element);
|
||||
});
|
||||
|
||||
const existing = "(" + String(data.length).padStart(5) + ") ";
|
||||
if(arr.length > 5) {
|
||||
log(chalk.yellow, existing + "Bulk " + arr.length + " measurements", "WS Data");
|
||||
} else {
|
||||
log(chalk.gray, existing + JSON.stringify(Array.from(newDataAsUint16Arr)), "WS Data");
|
||||
}
|
||||
};
|
||||
|
||||
if(serialPort) {
|
||||
openSerialConnection(serialPort, (receivedLine) => {
|
||||
log(chalk.gray, receivedLine, "Serial");
|
||||
});
|
||||
}
|
||||
|
||||
const conn = new SwimTrackerWebsocketConnection(deviceIpOrHostname, onNewMeasurementData, onStarted, onStopped,
|
||||
onWifiStateInfo, onConnect, onDisconnect,
|
||||
{ WebSocket: WS });
|
||||
conn.onLogMessage = onWebsocketLog;
|
||||
}
|
||||
|
||||
|
||||
basicTest("192.168.178.56", "/dev/ttyUSB1");
|
||||
//basicTest("192.168.178.56", undefined);
|
||||
|
||||
// {"speeds":[0.4, -0.3],"durations":[1500, 1500]}
|
||||
|
||||
/*
|
||||
Testrun:
|
||||
Captures:
|
||||
- streamed data from websocket
|
||||
- log messages from serial port
|
||||
- output of serial port
|
||||
File format:
|
||||
- timestamp of aquisition
|
||||
- type
|
||||
- websocket message
|
||||
- serial port line
|
||||
Logging:
|
||||
- websocket errors always
|
||||
|
||||
Commands:
|
||||
- replay file
|
||||
- data as csv
|
||||
*/
|
||||
82
SwimTracker/components/CycleView.js
Normal file
@@ -0,0 +1,82 @@
|
||||
import React from 'react';
|
||||
import { Animated, TouchableWithoutFeedback, View } from 'react-native';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
|
||||
export default class CycleView extends React.Component {
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
activeView: props.initialView
|
||||
};
|
||||
|
||||
this.opacityArr = React.Children.map(props.children,
|
||||
(c, i) => { console.log("iter", c, i); return new Animated.Value(i === props.initialView ? 1 : 0); });
|
||||
|
||||
this.fadeInAnimations = this.opacityArr.map(a => Animated.timing(a, {
|
||||
toValue: 1,
|
||||
duration: props.fadeDuration,
|
||||
useNativeDriver: true
|
||||
}));
|
||||
this.fadeOutAnimations = this.opacityArr.map(a => Animated.timing(a, {
|
||||
toValue: 0,
|
||||
duration: props.fadeDuration,
|
||||
useNativeDriver: true
|
||||
}));
|
||||
|
||||
console.log("opacity arr length", this.opacityArr.length, React.Children.count(props.children));
|
||||
}
|
||||
|
||||
_onTouch = () => {
|
||||
const currentViewIdx = this.state.activeView;
|
||||
const nextViewIdx = (currentViewIdx + 1) % React.Children.count(this.props.children);
|
||||
this.fadeOutAnimations[currentViewIdx].start();
|
||||
this.fadeInAnimations[nextViewIdx].start();
|
||||
this.setState({ activeView: nextViewIdx });
|
||||
this.props.onViewChange(nextViewIdx);
|
||||
}
|
||||
|
||||
render() {
|
||||
const children = React.Children.map(this.props.children, (c, i) => c);
|
||||
|
||||
return (
|
||||
<TouchableWithoutFeedback onPress={this._onTouch} styles={{ flex: 1 }}>
|
||||
<View styles={{ flex: 1, flexDirection: 'row', width: "100%" }}>
|
||||
{
|
||||
React.Children.map(this.props.children, (c, i) => {
|
||||
return <Animated.View style={{
|
||||
position: i === 0 ? 'relative' : 'absolute',
|
||||
left: 0,
|
||||
right: 0,
|
||||
opacity: this.opacityArr[i],
|
||||
transform: [{
|
||||
scale: this.opacityArr[i].interpolate({
|
||||
inputRange: [0, 1],
|
||||
outputRange: [this.props.minScaleOnFade, 1],
|
||||
})
|
||||
}]
|
||||
}}>
|
||||
{c}
|
||||
</Animated.View>
|
||||
})
|
||||
}
|
||||
</View>
|
||||
</TouchableWithoutFeedback>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
CycleView.propTypes = {
|
||||
initialView: PropTypes.number,
|
||||
fadeDuration: PropTypes.number,
|
||||
minScaleOnFade: PropTypes.number,
|
||||
onViewChange: PropTypes.func,
|
||||
}
|
||||
|
||||
CycleView.defaultProps = {
|
||||
initialView: 0,
|
||||
fadeDuration: 600,
|
||||
minScaleOnFade: 0.4,
|
||||
onViewChange: viewIdx => null,
|
||||
}
|
||||
107
SwimTracker/components/Graph.js
Normal file
@@ -0,0 +1,107 @@
|
||||
import React from 'react';
|
||||
import { View, StyleSheet } from 'react-native';
|
||||
import Svg, { Polyline, Text, Circle } from 'react-native-svg';
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
|
||||
function computeTickMarks(largest, mostTicks) {
|
||||
const minimum = largest / mostTicks
|
||||
const magnitude = 10 ** Math.floor(Math.log10(minimum))
|
||||
const residual = minimum / magnitude
|
||||
let tickInterval = 0;
|
||||
if (residual > 5)
|
||||
tickInterval = 10 * magnitude;
|
||||
else if (residual > 2)
|
||||
tickInterval = 5 * magnitude;
|
||||
else if (residual > 1)
|
||||
tickInterval = 2 * magnitude;
|
||||
else
|
||||
tickInterval = magnitude;
|
||||
|
||||
let result = [];
|
||||
let nextTick = tickInterval;
|
||||
while (nextTick < largest) {
|
||||
result.push(nextTick);
|
||||
nextTick += tickInterval;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
const Graph = props => {
|
||||
const graphHeight = 100;
|
||||
const numPoints = 300;
|
||||
const yLabelSpace = 40;
|
||||
const graphWidth = numPoints + yLabelSpace;
|
||||
const minKgScale = 4; // scale such that upper graph value is n kg
|
||||
|
||||
const data = props.data.slice(-numPoints);
|
||||
const maxValueDeviceCoord = data.reduce((running, x) => Math.max(x, running), minKgScale / props.kgFactor);
|
||||
const maxValueKg = Math.max(maxValueDeviceCoord * props.kgFactor, minKgScale);
|
||||
|
||||
const devCoordToSvgX = x => yLabelSpace + x;
|
||||
const devCoordToSvgY = y => graphHeight - (y * 100 / maxValueDeviceCoord);
|
||||
const dataInKgToSvgCoordX = devCoordToSvgX;
|
||||
const dataInKgToSvgCoordY = y => devCoordToSvgY(y / props.kgFactor);
|
||||
|
||||
const coordStr = data.map((element, i) => `${devCoordToSvgX(i)}, ${devCoordToSvgY(element)}`).join(" ");
|
||||
const ticks = computeTickMarks(maxValueKg * 0.9, 4);
|
||||
let viewBox = `0 0 ${graphWidth} ${graphHeight}`;
|
||||
|
||||
const cutOffIndex = Math.max(0, props.data.size - numPoints);
|
||||
const peaksToDisplay = props.peaks.filter(p => p > cutOffIndex)
|
||||
const peaksXCoords = peaksToDisplay.map(p => devCoordToSvgX(p - cutOffIndex));
|
||||
const peaksYCoords = peaksToDisplay.map(p => devCoordToSvgY(props.data.get(p)));
|
||||
|
||||
return (
|
||||
<View style={{ aspectRatio: graphWidth / graphHeight }}>
|
||||
<Svg height="100%" width="100%" viewBox={viewBox} preserveAspectRatio="none" >
|
||||
<Polyline
|
||||
points={coordStr}
|
||||
stroke="black"
|
||||
strokeWidth="2"
|
||||
strokeLinejoin="round"
|
||||
fill="none"
|
||||
/>
|
||||
{ticks.map(tick => (
|
||||
<React.Fragment key={`fragment${tick}`} >
|
||||
<Polyline
|
||||
points={`${yLabelSpace}, ${dataInKgToSvgCoordY(tick)} ${graphWidth}, ${dataInKgToSvgCoordY(tick)}`}
|
||||
stroke="black"
|
||||
key={`line${tick}`}
|
||||
strokeWidth="1"
|
||||
strokeDasharray="5,5"
|
||||
strokeLinejoin="round"
|
||||
fill="none"
|
||||
/>
|
||||
<Text x="5" y={`${dataInKgToSvgCoordY(tick)}`}
|
||||
alignmentBaseline="center"
|
||||
key={`label${tick}`}
|
||||
fontSize="9pt">{tick} kg </Text>
|
||||
</React.Fragment>
|
||||
)
|
||||
)}
|
||||
{peaksXCoords.zip(peaksYCoords).map(peak => (
|
||||
<Circle
|
||||
cx={peak[0]}
|
||||
key={`peak${peak[0]}`}
|
||||
stroke="black"
|
||||
fill="black"
|
||||
cy={peak[1]}
|
||||
r="3"
|
||||
/>
|
||||
))}
|
||||
</Svg>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
const mapStateToProps = (state) => {
|
||||
return {
|
||||
data: state.deviceState.measurements,
|
||||
kgFactor: state.settings.analysis.kgFactor,
|
||||
peaks: state.deviceState.analysis.peaks,
|
||||
};
|
||||
};
|
||||
|
||||
export default connect(mapStateToProps)(Graph);
|
||||
48
SwimTracker/components/IconCard.js
Normal file
@@ -0,0 +1,48 @@
|
||||
import React from 'react';
|
||||
import { View, StyleSheet, Text } from 'react-native';
|
||||
import EntypoIcon from "react-native-vector-icons/Entypo";
|
||||
import AntDesignIcon from "react-native-vector-icons/AntDesign";
|
||||
import Fa5Icon from "react-native-vector-icons/FontAwesome5";
|
||||
|
||||
const IconCard = props => {
|
||||
let IconClass;
|
||||
if (props.iconType === "AntDesign") {
|
||||
IconClass = AntDesignIcon;
|
||||
}
|
||||
else if (props.iconType === "FontAwesome5") {
|
||||
IconClass = Fa5Icon;
|
||||
} else if (props.iconType === "Entypo") {
|
||||
IconClass = EntypoIcon;
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={styles.card}>
|
||||
|
||||
<View style={{ paddingLeft: 20 }}>
|
||||
<Text style={{ color: 'white', fontSize: props.fontSize, textAlign: "center" }}> {props.value}</Text>
|
||||
</View>
|
||||
<View style={{ alignItems: 'center', justifyContent: 'center', paddingLeft: 20 }}>
|
||||
<IconClass style={{ color: 'white', fontSize: 40 }} name={props.iconName} />
|
||||
<Text style={{ color: 'white', marginTop: 5 }}> {props.label}</Text>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
card: {
|
||||
flexDirection: 'row',
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.3)',
|
||||
margin: 5,
|
||||
padding: 5,
|
||||
borderRadius: 6,
|
||||
justifyContent: 'space-between',
|
||||
}
|
||||
});
|
||||
|
||||
IconCard.defaultProps = {
|
||||
fontSize: 65,
|
||||
flex: 1
|
||||
};
|
||||
|
||||
export default IconCard;
|
||||
56
SwimTracker/components/ImageHeader.js
Normal file
@@ -0,0 +1,56 @@
|
||||
import React from "react";
|
||||
import {
|
||||
View,
|
||||
ImageBackground,
|
||||
Text,
|
||||
TouchableOpacity,
|
||||
StyleSheet,
|
||||
} from "react-native";
|
||||
import EntypoIcon from "react-native-vector-icons/Entypo";
|
||||
|
||||
|
||||
function ImageHeader(props) {
|
||||
return (
|
||||
<View style={imageHeaderStyles.container}>
|
||||
<ImageBackground
|
||||
source={props.image}
|
||||
resizeMode="cover"
|
||||
style={{ flex: 1 }}
|
||||
>
|
||||
<View style={imageHeaderStyles.row}>
|
||||
<TouchableOpacity onPress={() => props.navigation.goBack()}>
|
||||
<EntypoIcon name="chevron-left" style={imageHeaderStyles.icon}></EntypoIcon>
|
||||
</TouchableOpacity>
|
||||
<Text style={imageHeaderStyles.text}>{props.text}</Text>
|
||||
</View>
|
||||
</ImageBackground>
|
||||
</View >
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
const imageHeaderStyles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
minHeight: 185,
|
||||
maxHeight: 185,
|
||||
height: 175,
|
||||
width: "100%",
|
||||
},
|
||||
row: {
|
||||
paddingTop: 40,
|
||||
flexDirection: "row",
|
||||
},
|
||||
icon: {
|
||||
color: "white",
|
||||
fontSize: 40,
|
||||
paddingRight: 10,
|
||||
paddingLeft: 10,
|
||||
},
|
||||
text: {
|
||||
color: "white",
|
||||
fontSize: 30,
|
||||
},
|
||||
});
|
||||
|
||||
export default ImageHeader;
|
||||
39
SwimTracker/components/PropValueCard.js
Normal file
@@ -0,0 +1,39 @@
|
||||
import React from 'react';
|
||||
import {View, StyleSheet, Text} from 'react-native';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
|
||||
|
||||
const PropValueCard = props => {
|
||||
|
||||
return (
|
||||
<LinearGradient
|
||||
colors={['#0075c4', '#1A8FDE']}
|
||||
start={[0, 0]}
|
||||
end={[1, 0]}
|
||||
style={styles.gradient}>
|
||||
|
||||
<Text style={{color:'white', fontSize: 16}}>
|
||||
{props.label}
|
||||
</Text>
|
||||
<Text style={{color:'white', fontSize: 55}}>
|
||||
{props.value}
|
||||
</Text>
|
||||
</LinearGradient>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
gradient : {
|
||||
flex: 1,
|
||||
padding: 15,
|
||||
alignItems: 'center',
|
||||
borderRadius: 16,
|
||||
margin:15,
|
||||
marginRight: 4,
|
||||
height:120,
|
||||
justifyContent:'center',
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
export default PropValueCard;
|
||||
109
SwimTracker/components/SetupView.js
Normal file
@@ -0,0 +1,109 @@
|
||||
import React from "react";
|
||||
import {
|
||||
StyleSheet,
|
||||
View,
|
||||
StatusBar,
|
||||
ImageBackground,
|
||||
Text,
|
||||
TouchableOpacity,
|
||||
} from "react-native";
|
||||
import EntypoIcon from "react-native-vector-icons/Entypo";
|
||||
|
||||
|
||||
function AdditionalOptionsBottomBar(props) {
|
||||
return (
|
||||
<View style={bottomBarStyles.container}>
|
||||
{ props.leftText ?
|
||||
<TouchableOpacity onPress={props.onLeftPress} style={bottomBarStyles.button}>
|
||||
<Text style={bottomBarStyles.text}>{props.leftText} </Text>
|
||||
</TouchableOpacity> : <View></View>
|
||||
}
|
||||
{props.rightText &&
|
||||
<TouchableOpacity onPress={props.onRightPress} style={bottomBarStyles.button}>
|
||||
<Text style={bottomBarStyles.text}>{props.rightText}</Text>
|
||||
</TouchableOpacity>
|
||||
}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const bottomBarStyles = StyleSheet.create({
|
||||
container: {
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
paddingBottom: 30,
|
||||
},
|
||||
text: {
|
||||
color: "rgba(255,255,255,0.5)",
|
||||
},
|
||||
button: {
|
||||
borderStyle: "dotted"
|
||||
}
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
|
||||
function SetupView(props) {
|
||||
|
||||
return (
|
||||
<View style={{ flex: 1 }}>
|
||||
|
||||
<StatusBar barStyle="light-content" backgroundColor="rgba(0,0,0,0.4)" translucent={true} />
|
||||
<ImageBackground
|
||||
source={require("../assets/pool_sky_background_blurred.jpg")}
|
||||
resizeMode="cover"
|
||||
style={{ flex: 1 }}
|
||||
>
|
||||
<View style={setupViewStyles.container}>
|
||||
<View style={{ flexDirection: "row", alignItems: "center" }}>
|
||||
{props.backButton &&
|
||||
<TouchableOpacity onPress={() => props.navigation.goBack()}>
|
||||
<EntypoIcon name="chevron-left" style={setupViewStyles.backButton}></EntypoIcon>
|
||||
</TouchableOpacity>
|
||||
}
|
||||
<Text style={setupViewStyles.headerText}>
|
||||
{props.headerText}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={{flex: 1, justifyContent: "center"}}>
|
||||
{props.children}
|
||||
</View>
|
||||
<AdditionalOptionsBottomBar leftText={props.lowerLeftButtonText}
|
||||
onLeftPress={props.onLowerLeftButtonPress}
|
||||
rightText={props.lowerRightButtonText}
|
||||
onRightPress={props.onLowerRightButtonPress}>
|
||||
</AdditionalOptionsBottomBar>
|
||||
</View>
|
||||
</ImageBackground>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const setupViewStyles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
justifyContent: "space-between",
|
||||
width: "80%",
|
||||
marginLeft: 40,
|
||||
marginTop: 60,
|
||||
},
|
||||
headerText: {
|
||||
color: "rgba(255,255,255,1)",
|
||||
fontSize: 25,
|
||||
},
|
||||
subtext: {
|
||||
color: "rgba(255,255,255,1)",
|
||||
textAlign: "left",
|
||||
fontSize: 18,
|
||||
lineHeight: 25,
|
||||
width: "80%",
|
||||
marginBottom: 50,
|
||||
},
|
||||
backButton: {
|
||||
color: "rgba(255,255,255,1)",
|
||||
fontSize: 40
|
||||
},
|
||||
});
|
||||
|
||||
export default SetupView;
|
||||
26
SwimTracker/components/themeColors.js
Normal file
@@ -0,0 +1,26 @@
|
||||
// https://flatuicolors.com/palette/defo
|
||||
|
||||
const themeColors = {
|
||||
'TURQUOISE': "rgb(26, 188, 156)",
|
||||
"EMERALD": "rgb(46, 204, 113)",
|
||||
"PETER RIVER" : "rgb(52, 152, 219)",
|
||||
"AMETHYST" : "rgb(155, 89, 182)",
|
||||
"WET ASPHALT" : "rgb(52, 73, 94)",
|
||||
"GREEN SEA" : "rgb(22, 160, 133)",
|
||||
"NEPHRITIS" : "rgb(39, 174, 96)",
|
||||
"BELIZE HOLE" : "rgb(41, 128, 185)",
|
||||
"WISTERIA" : "rgb(142, 68, 173)",
|
||||
"MIDNIGHT BLUE" : "rgb(44, 62, 80)",
|
||||
"SUN FLOWER" : "rgb(241, 196, 15)",
|
||||
"CARROT" : "rgb(230, 126, 34)",
|
||||
"ALIZARIN" : "rgb(231, 76, 60)",
|
||||
"CLOUDS" : "rgb(236, 240, 241)",
|
||||
"CONCRETE" : "rgb(149, 165, 166)",
|
||||
"ORANGE" : "rgb(243, 156, 18)",
|
||||
"PUMPKIN" : "rgb(211, 84, 0)",
|
||||
"POMEGRANATE" : "rgb(192, 57, 43)",
|
||||
"SILVER" : "rgb(189, 195, 199)",
|
||||
"ASBESTOS" : "rgb(127, 140, 141)",
|
||||
};
|
||||
|
||||
export default themeColors;
|
||||
80
SwimTracker/data_processing/DataAnalysis.js
Normal file
@@ -0,0 +1,80 @@
|
||||
import { PeakDetectorSimple } from './PeakDetection';
|
||||
import { MovingAverage} from './MovingAverage';
|
||||
import { List } from 'immutable';
|
||||
|
||||
|
||||
export default class DataAnalysis {
|
||||
|
||||
constructor() {
|
||||
this._resetCache(null, 0);
|
||||
}
|
||||
|
||||
analyze(analysisParameters, sessionId, allMeasurements) {
|
||||
const cacheValid = (
|
||||
this.sessionId === sessionId &&
|
||||
this.analyzedUpToIdx <= allMeasurements.size &&
|
||||
this.analysisParameters === analysisParameters);
|
||||
|
||||
let newData = null;
|
||||
|
||||
if (cacheValid) {
|
||||
newData = allMeasurements.slice(this.analyzedUpToIdx);
|
||||
}
|
||||
else {
|
||||
this._resetCache(analysisParameters, sessionId);
|
||||
newData = allMeasurements;
|
||||
console.log("cache reset");
|
||||
}
|
||||
const allMeasurementsSize = allMeasurements.size ? allMeasurements.size : allMeasurements.length;
|
||||
const newDataArr = (typeof newData.toArray ==="function") ? newData.toArray() : newData;
|
||||
|
||||
// active time
|
||||
const newAverages = this.movingAverage.addVector(newDataArr);
|
||||
this.activeMeasurements += newAverages.reduce((n, val) => {
|
||||
return n + ((val >= analysisParameters.activeTimeThreshold) ? 1 : 0);
|
||||
}, 0);
|
||||
|
||||
// peaks
|
||||
const newPeaks = this.peakDetectorSimple.addVector(newDataArr);
|
||||
this.allPeaks = this.allPeaks.concat(List(newPeaks));
|
||||
|
||||
// aggregated sum/max
|
||||
this.aggregatedMomentum = newData.reduce((sum, x) => sum + x, this.aggregatedMomentum);
|
||||
this.peakMax = newData.reduce((running, x) => Math.max(x, running), this.peakMax);
|
||||
|
||||
// windowed
|
||||
const windowNumDataPoints = analysisParameters.windowSizeInSecs * analysisParameters.numMeasurementsPerSec;
|
||||
const windowed = allMeasurements.slice(-windowNumDataPoints);
|
||||
const peakMaxWindow = windowed.reduce((running, x) => Math.max(x, running), 0);
|
||||
const momentumWindow = windowed.reduce((sum, x) => sum + x, 0);
|
||||
|
||||
|
||||
this.analyzedUpToIdx = allMeasurementsSize;
|
||||
return {
|
||||
peaks: this.allPeaks,
|
||||
totalTime: allMeasurementsSize / analysisParameters.numMeasurementsPerSec,
|
||||
activeTime: this.activeMeasurements / analysisParameters.numMeasurementsPerSec,
|
||||
|
||||
totalMomentum: this.aggregatedMomentum,
|
||||
peakMax: this.peakMax,
|
||||
|
||||
momentumWindow: momentumWindow,
|
||||
peakMaxWindow: peakMaxWindow,
|
||||
};
|
||||
}
|
||||
|
||||
_resetCache(analysisParameters, sessionId) {
|
||||
this.movingAverage = analysisParameters ? new MovingAverage(analysisParameters.movingAverageWindowSize) : null;
|
||||
this.activeMeasurements = 0;
|
||||
|
||||
this.peakDetectorSimple = analysisParameters ? new PeakDetectorSimple(analysisParameters.peakDetectorSimpleThreshold) : null;
|
||||
this.allPeaks = List();
|
||||
|
||||
this.aggregatedMomentum = 0;
|
||||
this.peakMax = 0;
|
||||
|
||||
this.sessionId = sessionId;
|
||||
this.analyzedUpToIdx = 0;
|
||||
this.analysisParameters = analysisParameters;
|
||||
}
|
||||
};
|
||||
128
SwimTracker/data_processing/DataProcessing.js
Normal file
@@ -0,0 +1,128 @@
|
||||
import DeviceHttpDataSource from './DeviceDataSource';
|
||||
import { List } from 'immutable';
|
||||
import { reportDeviceData, resetDeviceData } from '../state/ActionCreators';
|
||||
import { PeakDetectorSimple } from './PeakDetection';
|
||||
|
||||
|
||||
// todo: put in settings?
|
||||
const NUM_MEASUREMENTS_PER_SECOND = 10;
|
||||
const WINDOW_SIZE_SECS = 5;
|
||||
|
||||
|
||||
class DataProcessing {
|
||||
constructor(reduxStore) {
|
||||
this.store = reduxStore;
|
||||
this.store.subscribe(this.onStateChange);
|
||||
this.state = this.store.getState();
|
||||
this.dataSource = null;
|
||||
this.peakDetectorSimple = new PeakDetectorSimple(this.state.settings.peakDetectorSimpleThreshold);
|
||||
|
||||
this.onDataSourceChanged(this.state.settings.deviceURL);
|
||||
this.onRunningChanged(this.state.session.running, this.state.settings.deviceURL);
|
||||
}
|
||||
|
||||
onStateChange = () => {
|
||||
const newState = this.store.getState();
|
||||
if (newState.settings.deviceURL !== this.state.settings.deviceURL)
|
||||
this.onDataSourceChanged(newState.settings.deviceURL);
|
||||
|
||||
if (newState.session.running !== this.state.session.running) {
|
||||
this.onRunningChanged(newState.session.running, newState.settings.deviceURL);
|
||||
};
|
||||
if (newState.settings.peakDetectorSimpleThreshold !== this.state.settings.peakDetectorSimpleThreshold) {
|
||||
this.onAnalysisParameterChange();
|
||||
};
|
||||
this.state = newState;
|
||||
}
|
||||
|
||||
resetAnalysis = () => {
|
||||
this.peakDetectorSimple = new PeakDetectorSimple(this.state.settings.peakDetectorSimpleThreshold);
|
||||
}
|
||||
|
||||
onAnalysisParameterChange = () => {
|
||||
this.resetAnalysis();
|
||||
this.peakDetectorSimple.addVector(this.state.session.rawData.toArray());
|
||||
const analysis = this.analyzeNewMeasurements(data.values, List());
|
||||
this.store.dispatch(reportDeviceData(this.state.session.sessionId, this.state.session.rawData.size, this.state.session.rawData, analysis));
|
||||
}
|
||||
|
||||
onDataSourceChanged = (newDeviceURL) => {
|
||||
if (this.dataSource !== null) {
|
||||
this.dataSource.stop();
|
||||
this.dataSource = null;
|
||||
}
|
||||
this.dataSource = new DeviceHttpDataSource(newDeviceURL + "/api/session/data", this.onNewData);
|
||||
}
|
||||
|
||||
onRunningChanged = (running, deviceURL) => {
|
||||
let req = new XMLHttpRequest();
|
||||
if (running) {
|
||||
//console.log("Starting session", deviceURL + "/api/session/start");
|
||||
req.open("GET", deviceURL + "/api/session/start");
|
||||
this.dataSource.startIndex = 0;
|
||||
this.dataSource.start();
|
||||
} else {
|
||||
//console.log("Stopping session");
|
||||
req.open("GET", deviceURL + "/api/session/stop");
|
||||
this.dataSource.stop();
|
||||
this.dataSource.startIndex = 0;
|
||||
}
|
||||
req.addEventListener("error", evt => console.log(evt));
|
||||
req.addEventListener("abort", evt => console.log(evt));
|
||||
req.send();
|
||||
}
|
||||
|
||||
onNewData = (data) => {
|
||||
if (data.sessionStartTime == this.state.session.sessionId &&
|
||||
data.startIndex == this.state.session.rawData.size) {
|
||||
// normal case, add received data to measurement array
|
||||
const newData = this.state.session.rawData.concat(List(data.values));
|
||||
const analysis = this.analyzeNewMeasurements(data.values, this.state.session.rawData);
|
||||
this.store.dispatch(reportDeviceData(data.sessionStartTime, data.startIndex, newData, analysis));
|
||||
}
|
||||
else if (data.startIndex === 0) {
|
||||
this.resetAnalysis();
|
||||
const newData = List(data.values);
|
||||
const analysis = this.analyzeNewMeasurements(data.values, this.state.session.rawData);
|
||||
this.store.dispatch(reportDeviceData(data.sessionStartTime, data.startIndex, newData, analysis));
|
||||
} else {
|
||||
// missed some data -> re-query
|
||||
console.log("Requery :(");
|
||||
//console.log("Session times", data.sessionStartTime == this.state.session.sessionId, data.sessionStartTime, this.state.session.sessionId);
|
||||
//console.log("Index ",data.startIndex == this.state.session.rawData.size, data.startIndex, this.state.session.rawData.size);
|
||||
this.resetAnalysis();
|
||||
this.dataSource.startIndex = 0;
|
||||
this.store.dispatch(resetDeviceData());
|
||||
}
|
||||
}
|
||||
|
||||
analyzeNewMeasurements = (newData, oldData) => {
|
||||
const newPeaks = this.peakDetectorSimple.addVector(newData);
|
||||
const allPeaks = this.state.session.analysis.peaks.concat(List(newPeaks));
|
||||
|
||||
const allMeasurements = oldData.concat(List(newData));
|
||||
const totalMomentum = allMeasurements.reduce((sum, x) => sum + x, 0);
|
||||
const peakMax = allMeasurements.reduce((running, x) => Math.max(x, running), 0);
|
||||
|
||||
// windowed quantities
|
||||
const windowSizeMeasurements = WINDOW_SIZE_SECS * NUM_MEASUREMENTS_PER_SECOND;
|
||||
const windowedSeq = allMeasurements.slice(-windowSizeMeasurements);
|
||||
const peakMaxWindow = windowedSeq.reduce((running, x) => Math.max(x, running), 0);
|
||||
const momentumWindow = windowedSeq.reduce((sum, x) => sum + x, 0);
|
||||
|
||||
return {
|
||||
peaks: allPeaks,
|
||||
totalTime: allMeasurements.length / NUM_MEASUREMENTS_PER_SECOND,
|
||||
activeTime: 0,
|
||||
totalMomentum: totalMomentum,
|
||||
peakFrequency: 0,
|
||||
peakMax: peakMax,
|
||||
// windowed quantities
|
||||
momentumWindow: momentumWindow,
|
||||
frequencyWindow: 0,
|
||||
peakMaxWindow: peakMaxWindow,
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export default DataProcessing;
|
||||
76
SwimTracker/data_processing/DeviceDataSource.js
Normal file
@@ -0,0 +1,76 @@
|
||||
import * as msgpack from 'msgpack-lite';
|
||||
|
||||
class DeviceHttpDataSource {
|
||||
|
||||
constructor(dataUrl, onNewData, pollInterval=800, startIndex = 0) {
|
||||
this.dataUrl = dataUrl;
|
||||
this.onNewData = onNewData;
|
||||
this.pollInterval = pollInterval;
|
||||
this.startIndex = startIndex;
|
||||
this.timer = null;
|
||||
|
||||
// msgpack setup
|
||||
this.msgpackCodec = msgpack.createCodec();
|
||||
this.msgpackCodec.addExtUnpacker(205, function (byteArr) {
|
||||
const buffer = byteArr.buffer.slice(byteArr.byteOffset, byteArr.byteLength + byteArr.byteOffset);
|
||||
const result = new Int16Array(buffer);
|
||||
return result;
|
||||
});
|
||||
|
||||
this.fetchDataHttp = this.fetchDataHttp.bind(this);
|
||||
}
|
||||
|
||||
getUrl(url) {
|
||||
return new Promise((accept, reject) => {
|
||||
var req = new XMLHttpRequest();
|
||||
req.open("GET", url, true);
|
||||
req.responseType = "arraybuffer";
|
||||
|
||||
req.onload = function (event) {
|
||||
var resp = req.response;
|
||||
if (resp) {
|
||||
accept(resp);
|
||||
}
|
||||
};
|
||||
req.addEventListener("error", evt => reject(evt));
|
||||
req.addEventListener("abort", evt => reject(evt));
|
||||
|
||||
req.send(null);
|
||||
});
|
||||
}
|
||||
|
||||
async fetchDataHttp() {
|
||||
try {
|
||||
const url = this.dataUrl + "?startIdx=" + this.startIndex;
|
||||
const arrayBuffer = await this.getUrl(url);
|
||||
const decoded = msgpack.decode(new Uint8Array(arrayBuffer), { codec: this.msgpackCodec });
|
||||
this.startIndex += decoded["values"].length;
|
||||
this.onNewData(decoded);
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
}
|
||||
}
|
||||
|
||||
start() {
|
||||
if (this.timer === null) {
|
||||
console.log("Start monitoring");
|
||||
this.timer = setInterval(this.fetchDataHttp, this.pollInterval);
|
||||
return true;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
stop() {
|
||||
if (this.timer !== null) {
|
||||
console.log("stop monitoring");
|
||||
clearInterval(this.timer);
|
||||
this.timer = null;
|
||||
return true;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export default DeviceHttpDataSource;
|
||||
29
SwimTracker/data_processing/MovingAverage.js
Normal file
@@ -0,0 +1,29 @@
|
||||
|
||||
/**
|
||||
* A moving average computation
|
||||
*/
|
||||
export class MovingAverage {
|
||||
constructor(windowSize) {
|
||||
this._windowSize = windowSize;
|
||||
this._queue = [];
|
||||
this._queueSum = 0;
|
||||
}
|
||||
|
||||
windowSize() {
|
||||
return this._windowSize;
|
||||
}
|
||||
|
||||
addVector(vec) {
|
||||
return vec.map(this.add.bind(this));
|
||||
}
|
||||
|
||||
add(value) {
|
||||
this._queueSum += value;
|
||||
this._queue.push(value);
|
||||
if(this._queue.length > this._windowSize) {
|
||||
this._queueSum -= this._queue[0];
|
||||
this._queue.shift();
|
||||
}
|
||||
return this._queueSum / this._queue.length;
|
||||
}
|
||||
};
|
||||
185
SwimTracker/data_processing/PeakDetection.js
Normal file
@@ -0,0 +1,185 @@
|
||||
|
||||
/**
|
||||
* A simple peak detector
|
||||
*
|
||||
* Usage: Successively add values via add() and query the indices of the peaks with peaks
|
||||
*
|
||||
* A peak is detected if the current point is local maximum (no filtering!) and the current
|
||||
* value is larger than (threshold + minimum_since_last_peak)
|
||||
*/
|
||||
class PeakDetectorSimple {
|
||||
constructor(threshold) {
|
||||
this._threshold = threshold;
|
||||
this._queue = [];
|
||||
this._last_min = 0;
|
||||
this._counter = 0;
|
||||
}
|
||||
|
||||
getThreshold() {
|
||||
return this._threshold;
|
||||
}
|
||||
|
||||
addVector(vec) {
|
||||
let result = [];
|
||||
|
||||
for (let i = 0; i < vec.length; ++i) {
|
||||
const res = this.add(vec[i]);
|
||||
if(res !== null)
|
||||
result.push(res);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
add(value) {
|
||||
let result = null;
|
||||
this._queue.push(value);
|
||||
if (this._queue.length > 3) {
|
||||
this._queue.shift();
|
||||
}
|
||||
if (this._queue.length !== 3) {
|
||||
return null;
|
||||
}
|
||||
const [last, current, next] = this._queue;
|
||||
const is_maximum = current > next && current > last;
|
||||
if (is_maximum && (current - this._last_min) > this._threshold) {
|
||||
result = this._counter + 1;
|
||||
this._last_min = current;
|
||||
}
|
||||
this._last_min = Math.min(this._last_min, current);
|
||||
this._counter += 1;
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Implementation of Z-Score peak detection according to
|
||||
* https://stackoverflow.com/questions/22583391/peak-signal-detection-in-realtime-timeseries-data
|
||||
*/
|
||||
class PeakDetectorZScore {
|
||||
constructor(lag, threshold, influence, handleNewPeaks) {
|
||||
this.peaks = [];
|
||||
this._filter = ZScoreFilter(lag, threshold, influence);
|
||||
this._counter = 0;
|
||||
this._previous_signal = 0;
|
||||
this._max = null;
|
||||
this._max_index = null;
|
||||
this._handleNewPeaks = handleNewPeaks;
|
||||
}
|
||||
|
||||
addVector(vec) {
|
||||
const callbackBackup = this._handleNewPeaks;
|
||||
const numPeaksBefore = this.peaks.length;
|
||||
this._handleNewPeaks = null;
|
||||
for (let i = 0; i < vec.length; ++i) {
|
||||
this.add(vec[i]);
|
||||
}
|
||||
this._handleNewPeaks = callbackBackup;
|
||||
if (numPeaksBefore != this.peaks.length) {
|
||||
this._handleNewPeaks(this.peaks);
|
||||
}
|
||||
}
|
||||
|
||||
add(value) {
|
||||
const signal = this._filter.add(value);
|
||||
if (signal != null) {
|
||||
const rising_flank = this._previous_signal !== 1 && signal === 1;
|
||||
const falling_flank = this._previous_signal === 1 && signal !== 1;
|
||||
if (rising_flank)
|
||||
this._max = -1;
|
||||
if (signal === 1 && this._max != null && value > this._max) {
|
||||
this._max = value;
|
||||
this._max_index = this._counter;
|
||||
}
|
||||
if (falling_flank) {
|
||||
this.peaks.push(this._max_index);
|
||||
if (this._handleNewPeaks) {
|
||||
this._handleNewPeaks(this.peaks);
|
||||
}
|
||||
}
|
||||
|
||||
this._previous_signal = signal;
|
||||
}
|
||||
this._counter += 1;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
export { PeakDetectorSimple, PeakDetectorZScore };
|
||||
|
||||
|
||||
// --------------------------------------- Helper classes -------------------------------------------------------------
|
||||
|
||||
class StatisticsQueue {
|
||||
constructor(size) {
|
||||
this._size = size;
|
||||
|
||||
this._queue = [];
|
||||
this._queue_sum = 0; // running sum over all elements currently in _queue
|
||||
this._queue_sum_sq = 0; // sum of squared elements in _queue
|
||||
}
|
||||
|
||||
add(value) {
|
||||
this._queue.push(value);
|
||||
|
||||
this._queue_sum += value;
|
||||
this._queue_sum_sq += value * value;
|
||||
|
||||
if (this._queue.length > this._size) {
|
||||
const removed = this._queue[0];
|
||||
this._queue.shift();
|
||||
this._queue_sum -= removed;
|
||||
this._queue_sum_sq -= removed * removed;
|
||||
}
|
||||
}
|
||||
|
||||
get avg() {
|
||||
return this._queue_sum / self._queue.length;
|
||||
}
|
||||
|
||||
get variance() {
|
||||
const exp_sq = this._queue_sum_sq / self._queue.length;
|
||||
const my_avg = self.avg;
|
||||
return exp_sq - (my_avg * my_avg);
|
||||
}
|
||||
|
||||
get std_deviation() {
|
||||
return Math.sqrt(this.variance);
|
||||
}
|
||||
|
||||
get filled() {
|
||||
return this._queue.length === this._size;
|
||||
}
|
||||
}
|
||||
|
||||
class ZScoreFilter {
|
||||
|
||||
constructor(lag, threshold, influence) {
|
||||
this._threshold = threshold;
|
||||
this._influence = influence;
|
||||
|
||||
this._last_value = null;
|
||||
this._stat_queue = StatisticsQueue(lag);
|
||||
}
|
||||
|
||||
add(value) {
|
||||
let sq = this._stat_queue;
|
||||
if (!sq.filled) {
|
||||
sq.add(value);
|
||||
this._last_value = value;
|
||||
return null;
|
||||
} else {
|
||||
const avg = sq.avg;
|
||||
if (Math.abs(value - avg) > this._threshold * sq.std_deviation) {
|
||||
const signal = value > avg ? 1 : -1;
|
||||
const filtered = this._influence * value + (1 - this._influence) * this._last_value;
|
||||
sq.add(filtered);
|
||||
this._last_value = filtered;
|
||||
return signal;
|
||||
} else {
|
||||
sq.add(value);
|
||||
this._last_value = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
11
SwimTracker/data_processing/PeakDetection.test.js
Normal file
@@ -0,0 +1,11 @@
|
||||
import { PeakDetectorSimple } from './PeakDetection';
|
||||
|
||||
describe("test PeakDetectorSimple", () => {
|
||||
|
||||
it("detects simple peak", () => {
|
||||
let pd = new PeakDetectorSimple(40);
|
||||
const result = pd.addVector([0, 10, 30, 50, 30, 50, 2, 0, 60, 0 ]);
|
||||
expect(result).toEqual([3, 8]);
|
||||
});
|
||||
|
||||
});
|
||||
213
SwimTracker/data_processing/SwimTrackerWebsocketConnection.js
Normal file
@@ -0,0 +1,213 @@
|
||||
import ReconnectingWebSocket from 'reconnecting-websocket';
|
||||
import * as msgpack from 'msgpack-lite';
|
||||
|
||||
|
||||
const OpCodes = {
|
||||
// from swim tracker device to frontend
|
||||
ERROR: 1,
|
||||
|
||||
INITIAL_INFO: 2,
|
||||
SESSION_STARTED: 3,
|
||||
SESSION_STOPPED: 4,
|
||||
SESSION_NEW_DATA: 5,
|
||||
ANSWER_USER_LIST: 6,
|
||||
ANSWER_SESSION_LIST: 7,
|
||||
WIFI_STATE_RESPONSE: 8,
|
||||
WIFI_SCAN_RESPONSE: 9,
|
||||
APP_LAYER_PING: 10,
|
||||
LOG_UPDATE: 11,
|
||||
|
||||
// from frontend to device
|
||||
START_SESSION: 128,
|
||||
STOP_SESSION: 129,
|
||||
TARE: 130,
|
||||
QUERY_USER_LIST: 131,
|
||||
QUERY_SESSION_LIST: 132,
|
||||
WIFI_STATE_SET: 133,
|
||||
WIFI_STATE_GET: 134,
|
||||
WIFI_TRIGGER_SCAN: 135,
|
||||
LOG_STREAMING_START: 136,
|
||||
LOG_STREAMING_STOP: 137
|
||||
};
|
||||
|
||||
const HEARTBEAT_TIMEOUT = 3000;
|
||||
const PROVISIONING_IP = "192.168.42.1";
|
||||
|
||||
export default class SwimTrackerWebsocketConnection {
|
||||
/**
|
||||
* Creates a new persistent websocket connection to a swimtracker device
|
||||
*
|
||||
* @param {string} swimTrackerHost hostname or ip of the swimtracker device
|
||||
* @param {(data: Uint16Array) => any} onData called whenever new measurement data is available
|
||||
* @param {(sessionId: number) => any} onStarted called when a new measurement session was started
|
||||
* @param {() => any} onStopped called when session was stopped
|
||||
* @param {(wifistate : object) => any} onWifiStateInfo wifi state contains "state" (STATION_MODE|AP_PROVISIONING|AP_SECURE) and "hostname"
|
||||
* @param {() => any} onConnect called when websocket connection was established
|
||||
* @param {() => any} onDisconnect called when websocket disconnected
|
||||
*/
|
||||
constructor(swimTrackerHost, onData, onStarted, onStopped, onWifiStateInfo, onConnect, onDisconnect, reconnectingWsOptions={}) {
|
||||
this.swimTrackerHost = swimTrackerHost;
|
||||
|
||||
this.onData = onData;
|
||||
this.onStarted = onStarted;
|
||||
this.onStopped = onStopped;
|
||||
this.onWifiStateInfo = onWifiStateInfo;
|
||||
this.onConnect = onConnect;
|
||||
this.onDisconnect = onDisconnect;
|
||||
this.onLogMessage = () => {};
|
||||
|
||||
// try configured URL and provisioning URL
|
||||
const urls = [`ws://${swimTrackerHost}:81`, `ws://${PROVISIONING_IP}:81`];
|
||||
let urlIndex = 0;
|
||||
const urlProvider = () => urls[urlIndex++ % urls.length]; // round robin url provider
|
||||
|
||||
this.ws = new ReconnectingWebSocket(urlProvider, [], { ...reconnectingWsOptions, maxReconnectionDelay: 3000 });
|
||||
this.ws.onmessage = this._onMessage;
|
||||
this.ws.onopen = this.onConnect;
|
||||
this.ws.onclose = this.onDisconnect;
|
||||
this.ws.onerror = this._onError;
|
||||
this.ws.binaryType = 'arraybuffer';
|
||||
|
||||
this.msgpackCodec = msgpack.createCodec();
|
||||
this.msgpackCodec.addExtUnpacker(205, function (byteArr) {
|
||||
const buffer = byteArr.buffer.slice(byteArr.byteOffset, byteArr.byteLength + byteArr.byteOffset);
|
||||
const result = new Int16Array(buffer);
|
||||
return result;
|
||||
});
|
||||
|
||||
this._wifiScanPromises = [];
|
||||
this.pingTimeout = null;
|
||||
}
|
||||
|
||||
heartbeat() {
|
||||
clearTimeout(this.pingTimeout);
|
||||
|
||||
let connection = this;
|
||||
this.pingTimeout = setTimeout(() => {
|
||||
if(connection.ws !== null)
|
||||
connection.ws.reconnect();
|
||||
}, HEARTBEAT_TIMEOUT);
|
||||
}
|
||||
|
||||
close() {
|
||||
if (this.ws !== null) {
|
||||
this.ws.onmessage = null;
|
||||
this.ws.onopen = null;
|
||||
this.ws.onclose = null;
|
||||
this.ws.onerror = null;
|
||||
this.ws.close();
|
||||
this.ws = null;
|
||||
}
|
||||
}
|
||||
|
||||
sendStartCommand() {
|
||||
this._sendMsg(OpCodes.START_SESSION);
|
||||
}
|
||||
|
||||
sendStopCommand() {
|
||||
this._sendMsg(OpCodes.STOP_SESSION);
|
||||
}
|
||||
|
||||
sendTareCommand = () => {
|
||||
this._sendMsg(OpCodes.TARE);
|
||||
}
|
||||
|
||||
sendLogStreamStartCommand = () => {
|
||||
this._sendMsg(OpCodes.LOG_STREAMING_START);
|
||||
}
|
||||
|
||||
sendLogStreamStopCommand = () => {
|
||||
this._sendMsg(OpCodes.LOG_STREAMING_STOP);
|
||||
}
|
||||
|
||||
scanWifiNetworks() {
|
||||
console.log("Trigger wifi scan");
|
||||
this._sendMsg(OpCodes.WIFI_TRIGGER_SCAN);
|
||||
|
||||
let conn = this;
|
||||
return new Promise((resolve, reject) => {
|
||||
conn._wifiScanPromises.push({ resolve: resolve, reject: reject });
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
sendTareCommand = () => {
|
||||
this._sendMsg(OpCodes.WIFI_STATE_SET, {
|
||||
"reset_to_provisioning": true,
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
wifiSetModeAP(password) {
|
||||
this._sendMsg(OpCodes.WIFI_STATE_SET, {
|
||||
"ap_password": password,
|
||||
});
|
||||
}
|
||||
|
||||
wifiSetModeSTA(ssid, password) {
|
||||
console.log("Setting sta mode", ssid, password);
|
||||
this._sendMsg(OpCodes.WIFI_STATE_SET, {
|
||||
"sta_ssid": ssid,
|
||||
"sta_password": password,
|
||||
});
|
||||
}
|
||||
|
||||
_sendMsg(code, data) {
|
||||
let msg = undefined;
|
||||
if (data) {
|
||||
const serializedData = msgpack.encode(data);
|
||||
msg = new Uint8Array([code, ...serializedData]);
|
||||
} else {
|
||||
msg = new Uint8Array(1);
|
||||
msg[0] = code;
|
||||
}
|
||||
this.ws.send(msg);
|
||||
}
|
||||
|
||||
_onMessage = (e) => {
|
||||
const dv = new DataView(e.data);
|
||||
const opCode = dv.getInt8(0);
|
||||
const payload = new Uint8Array(e.data).slice(1);
|
||||
|
||||
this.heartbeat();
|
||||
|
||||
if (opCode === OpCodes.INITIAL_INFO) {
|
||||
const headerSize = 6;
|
||||
const running = Boolean(dv.getInt8(1));
|
||||
const sessionId = dv.getUint32(2);
|
||||
if (running && e.data.byteLength > headerSize) {
|
||||
const data = new Uint16Array(e.data.slice(headerSize));
|
||||
this.onStarted(sessionId);
|
||||
this.onData(data);
|
||||
} else
|
||||
this.onStopped();
|
||||
} else if (opCode === OpCodes.SESSION_STARTED) {
|
||||
const sessionId = dv.getUint32(1);
|
||||
this.onStarted(sessionId);
|
||||
} else if (opCode === OpCodes.SESSION_STOPPED) {
|
||||
this.onStopped();
|
||||
} else if (opCode === OpCodes.SESSION_NEW_DATA) {
|
||||
const data = new Uint16Array(e.data.slice(1));
|
||||
this.onData(data);
|
||||
} else if (opCode === OpCodes.WIFI_SCAN_RESPONSE) {
|
||||
const scanResult = msgpack.decode(payload, { codec: this.msgpackCodec });
|
||||
|
||||
for (let i = 0; i < this._wifiScanPromises.length; ++i) {
|
||||
this._wifiScanPromises[i].resolve(scanResult);
|
||||
}
|
||||
this._wifiScanPromises.length = 0;
|
||||
} else if (opCode === OpCodes.WIFI_STATE_RESPONSE) {
|
||||
const wifiInfo = msgpack.decode(payload, { codec: this.msgpackCodec });
|
||||
this.onWifiStateInfo(wifiInfo);
|
||||
} else if (opCode === OpCodes.LOG_UPDATE) {
|
||||
const logMsg = msgpack.decode(payload, { codec: this.msgpackCodec });
|
||||
this.onLogMessage(logMsg);
|
||||
} else if (opCode === OpCodes.APP_LAYER_PING) {
|
||||
//console.log("got heartbeat");
|
||||
}
|
||||
}
|
||||
|
||||
_onError = (ev) => {
|
||||
console.log("Websocket error", ev);
|
||||
}
|
||||
};
|
||||
44
SwimTracker/package.json
Normal file
@@ -0,0 +1,44 @@
|
||||
{
|
||||
"name": "swimtracker",
|
||||
"version": "1.0.0",
|
||||
"main": "node_modules/expo/AppEntry.js",
|
||||
"scripts": {
|
||||
"start": "expo start",
|
||||
"android": "expo start --android",
|
||||
"ios": "expo start --ios",
|
||||
"web": "expo start --web"
|
||||
},
|
||||
"dependencies": {
|
||||
"@expo/webpack-config": "^19.0.0",
|
||||
"@react-native-async-storage/async-storage": "1.18.2",
|
||||
"@react-navigation/native": "^6.1.8",
|
||||
"@react-navigation/stack": "^6.3.18",
|
||||
"expo": "~49.0.13",
|
||||
"expo-app-loading": "^2.1.1",
|
||||
"expo-localization": "~14.3.0",
|
||||
"expo-status-bar": "~1.6.0",
|
||||
"i18n-js": "^4.3.2",
|
||||
"immutable": "^5.0.0-beta.4",
|
||||
"moment": "^2.29.4",
|
||||
"msgpack-lite": "^0.1.26",
|
||||
"react": "18.2.0",
|
||||
"react-dom": "18.2.0",
|
||||
"react-native": "0.72.5",
|
||||
"react-native-gesture-handler": "~2.12.0",
|
||||
"react-native-reanimated": "~3.3.0",
|
||||
"react-native-safe-area-context": "4.6.3",
|
||||
"react-native-screens": "~3.22.0",
|
||||
"react-native-svg": "13.9.0",
|
||||
"react-native-swipe-list-view": "^3.2.9",
|
||||
"react-native-web": "~0.19.6",
|
||||
"react-redux": "^8.1.2",
|
||||
"react-xml-parser": "^1.1.8",
|
||||
"reconnecting-websocket": "^4.4.0",
|
||||
"redux": "^4.2.1",
|
||||
"redux-persist": "^6.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.20.0"
|
||||
},
|
||||
"private": true
|
||||
}
|
||||
174
SwimTracker/state/DeviceReduxCoupling.js
Normal file
@@ -0,0 +1,174 @@
|
||||
import SwimTrackerWebsocketConnection from "../data_processing/SwimTrackerWebsocketConnection";
|
||||
import DataAnalysis from "../data_processing/DataAnalysis";
|
||||
import { List } from "immutable";
|
||||
|
||||
export const ConnState = {
|
||||
DISCONNECTED: 'disconnected',
|
||||
CONNECTED_STOPPED: 'connected_stopped',
|
||||
CONNECTED_RUNNING: 'connected_running',
|
||||
CONNECTED_STARTING: 'connected_starting', // start message sent, but device hasn't ack'ed it yet
|
||||
CONNECTED_STOPPING: 'connected_stopping' // stop message sent..
|
||||
}
|
||||
|
||||
export const WifiState = {
|
||||
UNKNOWN: 'unknown',
|
||||
STA: 'sta', // connected to regular wifi
|
||||
AP_PROVISIONING: 'ap_provisioning', // acting as access point for provisioning
|
||||
AP_SECURE: 'ap_secure', // acting as access point, password has been set
|
||||
}
|
||||
|
||||
// -------------------------------------------- Actions ---------------------------------------------
|
||||
|
||||
export const DEVICE_DISCONNECT = "DEVICE_DISCONNECT";
|
||||
export const DEVICE_CONNECT = "DEVICE_CONNECT";
|
||||
export const SESSION_STARTED = "SESSION_STARTED";
|
||||
export const SESSION_STOPPED = "SESSION_STOPPED";
|
||||
export const SESSION_NEW_DATA = "SESSION_NEW_DATA";
|
||||
|
||||
export const START_SESSION = "START_SESSION";
|
||||
export const STOP_SESSION = "STOP_SESSION";
|
||||
|
||||
export const WIFI_SET_STATE = "WIFI_SET_STATE";
|
||||
|
||||
export const reportNewWifiState = (newStateStr, newHostname) => ({
|
||||
type: WIFI_SET_STATE,
|
||||
newStateStr: newStateStr,
|
||||
newHostname: newHostname,
|
||||
});
|
||||
|
||||
export const reportSessionStarted = (sessionId) => ({
|
||||
type: SESSION_STARTED,
|
||||
sessionId: sessionId
|
||||
});
|
||||
|
||||
export const reportSessionStopped = () => ({
|
||||
type: SESSION_STOPPED
|
||||
});
|
||||
|
||||
export const reportNewSessionData = (allMeasurements, analysis) => ({
|
||||
type: SESSION_NEW_DATA,
|
||||
data: allMeasurements,
|
||||
analysis: analysis
|
||||
});
|
||||
|
||||
export const reportDeviceConnect = () => ({
|
||||
type: DEVICE_CONNECT
|
||||
});
|
||||
|
||||
export const reportDeviceDisconnect = () => ({
|
||||
type: DEVICE_DISCONNECT
|
||||
});
|
||||
|
||||
export const startSession = () => ({
|
||||
type: START_SESSION
|
||||
});
|
||||
|
||||
export const stopSession = () => ({
|
||||
type: STOP_SESSION
|
||||
});
|
||||
|
||||
// -------------------------------------------- Device coupling -------------------------------------
|
||||
|
||||
export class DeviceReduxCoupling {
|
||||
constructor(reduxStore) {
|
||||
this.reduxStore = reduxStore;
|
||||
this.analysis = new DataAnalysis();
|
||||
|
||||
this.conn = null;
|
||||
|
||||
this.reduxStore.subscribe(this._onStateChange);
|
||||
this._onStateChange();
|
||||
}
|
||||
|
||||
_onStateChange = () => {
|
||||
const state = this.reduxStore.getState();
|
||||
|
||||
if (this.conn === null || (state.settings.swimTrackerHost != this.conn.swimTrackerHost)) {
|
||||
if( this.conn !== null) {
|
||||
this.conn.close();
|
||||
}
|
||||
|
||||
this.conn = new SwimTrackerWebsocketConnection(state.settings.swimTrackerHost,
|
||||
this._onNewData,
|
||||
(sessionId) => this.reduxStore.dispatch(reportSessionStarted(sessionId)),
|
||||
() => this.reduxStore.dispatch(reportSessionStopped()),
|
||||
(response) => this.reduxStore.dispatch(reportNewWifiState(response["state"], response["hostname"])),
|
||||
() => this.reduxStore.dispatch(reportDeviceConnect()),
|
||||
() => this.reduxStore.dispatch(reportDeviceDisconnect())
|
||||
);
|
||||
}
|
||||
if (state.deviceState.connState === ConnState.CONNECTED_STARTING) {
|
||||
console.log("sending start command to connection");
|
||||
this.conn.sendStartCommand();
|
||||
}
|
||||
else if (state.deviceState.connState === ConnState.CONNECTED_STOPPING)
|
||||
this.conn.sendStopCommand();
|
||||
}
|
||||
|
||||
_onNewData = (newData) => {
|
||||
const state = this.reduxStore.getState();
|
||||
const allMeasurements = state.deviceState.measurements.concat(List(newData));
|
||||
const analysisResult = this.analysis.analyze(state.settings.analysis, state.deviceState.sessionId, allMeasurements);
|
||||
this.reduxStore.dispatch(reportNewSessionData(allMeasurements, analysisResult));
|
||||
}
|
||||
};
|
||||
|
||||
// -------------------------------------------- Reducer -----------------------------------------------
|
||||
|
||||
const INITIAL_ANALYSIS = {
|
||||
'peaks': List(),
|
||||
'totalTime': null,
|
||||
'totalMomentum': null,
|
||||
'peakMax': null,
|
||||
'momentumWindow': null,
|
||||
'peakMaxWindow': null,
|
||||
};
|
||||
|
||||
const INITIAL_DEVICE_STATE = {
|
||||
connState: ConnState.DISCONNECTED,
|
||||
wifiState: WifiState.UNKNOWN,
|
||||
sessionId: 0,
|
||||
measurements: List(),
|
||||
analysis: INITIAL_ANALYSIS,
|
||||
deviceReportedHostname: "",
|
||||
};
|
||||
|
||||
export const deviceStateReducer = (state = INITIAL_DEVICE_STATE, action) => {
|
||||
switch (action.type) {
|
||||
case SESSION_NEW_DATA:
|
||||
const res = {
|
||||
...state,
|
||||
measurements: action.data,
|
||||
analysis: { ...state.analysis, ...action.analysis }
|
||||
};
|
||||
return res;
|
||||
case DEVICE_CONNECT:
|
||||
return { ...INITIAL_DEVICE_STATE, wifiState: state.wifiState, connState: ConnState.CONNECTED_STOPPED };
|
||||
case DEVICE_DISCONNECT:
|
||||
return { ...INITIAL_DEVICE_STATE, wifiState: state.wifiState, connState: ConnState.DISCONNECTED };
|
||||
case SESSION_STARTED:
|
||||
return { ...INITIAL_DEVICE_STATE, wifiState: state.wifiState, connState: ConnState.CONNECTED_RUNNING, sessionId: action.sessionId };
|
||||
case SESSION_STOPPED:
|
||||
return { ...INITIAL_DEVICE_STATE, wifiState: state.wifiState, connState: ConnState.CONNECTED_STOPPED };
|
||||
case START_SESSION:
|
||||
if (state.connState === ConnState.SESSION_STARTED)
|
||||
return state;
|
||||
return { ...INITIAL_DEVICE_STATE, wifiState: state.wifiState, connState: ConnState.CONNECTED_STARTING };
|
||||
case STOP_SESSION:
|
||||
if (state.connState === ConnState.SESSION_STOPPED)
|
||||
return state;
|
||||
return { ...INITIAL_DEVICE_STATE, wifiState: state.wifiState, connState: ConnState.CONNECTED_STOPPING };
|
||||
case WIFI_SET_STATE:
|
||||
let wifiState = WifiState.UNKNOWN;
|
||||
if (action.newStateStr === "STATION_MODE") { wifiState = WifiState.STA; }
|
||||
else if (action.newStateStr === "AP_PROVISIONING") { wifiState = WifiState.AP_PROVISIONING; }
|
||||
else if (action.newStateStr === "AP_SECURE") { wifiState = WifiState.AP_SECURE; }
|
||||
return { ...state, wifiState: wifiState, deviceReportedHostname: action.newHostname };
|
||||
default:
|
||||
//console.log("Unhandled state in deviceStateReducer", action, action.type, "state", state);
|
||||
return state;
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
|
||||
64
SwimTracker/state/Reducer.js
Normal file
@@ -0,0 +1,64 @@
|
||||
import { combineReducers } from 'redux';
|
||||
import { deviceStateReducer } from "./DeviceReduxCoupling";
|
||||
|
||||
export const CHANGE_USER_NAME = "SET_USERNAME";
|
||||
export const RESET_DEVICE_DATA = "RESET_DEVICE_DATA";
|
||||
export const CHANGE_SWIMTRACKER_HOSTNAME = "CHANGE_SWIMTRACKER_HOSTNAME";
|
||||
|
||||
export const changeUsername = newUsername => ({
|
||||
type: CHANGE_USER_NAME,
|
||||
newUserName: newUsername,
|
||||
});
|
||||
|
||||
export const changeSwimTrackerHostname = newSwimTrackerHost => ({
|
||||
type: CHANGE_SWIMTRACKER_HOSTNAME,
|
||||
newSwimTrackerHost: newSwimTrackerHost,
|
||||
});
|
||||
|
||||
export const startSession = () => ({
|
||||
type: START_SESSION
|
||||
});
|
||||
|
||||
export const stopSession = () => ({
|
||||
type: STOP_SESSION
|
||||
});
|
||||
|
||||
const INITIAL_SETTINGS = {
|
||||
username: "",
|
||||
swimTrackerHost: "swimtracker",
|
||||
|
||||
analysis: {
|
||||
peaksPerLap: 30,
|
||||
windowSizeInSecs: 5,
|
||||
numMeasurementsPerSec: 10,
|
||||
|
||||
kgFactor: 1.0 / (701.0 * 2.4),
|
||||
|
||||
peakDetector: 'SIMPLE', // either 'SIMPLE' or 'ZSCORE'
|
||||
peakDetectorSimpleThreshold: 2000,
|
||||
|
||||
peakDetectorZScoreLag: 8, // peak detector z-score values
|
||||
peakDetectorZScoreThreshold: 2,
|
||||
peakDetectorZScoreInfluence: 0.1,
|
||||
|
||||
activeTimeThreshold: 700,
|
||||
movingAverageWindowSize: 10 * 3,
|
||||
}
|
||||
};
|
||||
|
||||
const settingsReducer = (state = INITIAL_SETTINGS, action) => {
|
||||
|
||||
switch (action.type) {
|
||||
case CHANGE_USER_NAME:
|
||||
return { ...state, username: action.newUsername };
|
||||
case CHANGE_SWIMTRACKER_HOSTNAME:
|
||||
return { ...state, swimTrackerHost: action.newSwimTrackerHost };
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
};
|
||||
|
||||
export default combineReducers({
|
||||
settings: settingsReducer,
|
||||
deviceState: deviceStateReducer,
|
||||
});
|
||||
27
SwimTracker/utility/PromiseRequest.js
Normal file
@@ -0,0 +1,27 @@
|
||||
|
||||
let request = obj => {
|
||||
return new Promise((resolve, reject) => {
|
||||
let xhr = new XMLHttpRequest();
|
||||
xhr.open(obj.method || "GET", obj.url);
|
||||
if (obj.headers) {
|
||||
Object.keys(obj.headers).forEach(key => {
|
||||
xhr.setRequestHeader(key, obj.headers[key]);
|
||||
});
|
||||
}
|
||||
if(obj.responseType) {
|
||||
xhr.responseType = obj.responseType;
|
||||
}
|
||||
|
||||
xhr.onload = () => {
|
||||
if (xhr.status >= 200 && xhr.status < 300) {
|
||||
resolve(xhr.response);
|
||||
} else {
|
||||
reject(xhr.statusText);
|
||||
}
|
||||
};
|
||||
xhr.onerror = () => reject(xhr.statusText);
|
||||
xhr.send(obj.body);
|
||||
});
|
||||
};
|
||||
|
||||
export default request;
|
||||
45
SwimTracker/utility/TimeUtils.js
Normal file
@@ -0,0 +1,45 @@
|
||||
|
||||
import moment from 'moment/min/moment-with-locales';
|
||||
|
||||
|
||||
function timeSince(timeStamp, lang = 'de') {
|
||||
moment.locale(lang);
|
||||
|
||||
const now = Math.floor((new Date()).getTime() / 1000);
|
||||
const secondsPast = now - timeStamp;
|
||||
if (secondsPast <= 6 * 3600) {
|
||||
return moment().seconds(-secondsPast).fromNow();
|
||||
}
|
||||
else{
|
||||
const timeStampDate = new Date(timeStamp * 1000);
|
||||
const dateNow = new Date();
|
||||
|
||||
const timeStampMoment = moment.unix(timeStamp);
|
||||
|
||||
let dateStr = "";
|
||||
|
||||
if (timeStampDate.getDate() == dateNow.getDate())
|
||||
dateStr = "Heute, " + timeStampMoment.format("HH:mm");
|
||||
else if (timeStampDate.getDate() + 1 == dateNow.getDate())
|
||||
dateStr = "Gestern, " + timeStampMoment.format("HH:mm");
|
||||
else {
|
||||
dateStr = timeStampMoment.format("ddd, DD.MM.YY um HH:mm");
|
||||
}
|
||||
return dateStr;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
const toTimeStr = seconds => {
|
||||
let minuteStr = String(Math.floor(seconds / 60));
|
||||
if (minuteStr.length < 2)
|
||||
minuteStr = "0" + minuteStr;
|
||||
let secondStr = String(Math.floor(seconds % 60));
|
||||
if (secondStr.length < 2)
|
||||
secondStr = "0" + secondStr;
|
||||
return minuteStr + ":" + secondStr;
|
||||
}
|
||||
|
||||
|
||||
export { toTimeStr, timeSince };
|
||||
34
SwimTracker/utility/i18n.js
Normal file
@@ -0,0 +1,34 @@
|
||||
import { I18n } from "i18n-js";
|
||||
import * as Localization from 'expo-localization';
|
||||
|
||||
const translation_store = {
|
||||
en: {
|
||||
connecting: "Connecting",
|
||||
connectSubtext: "Please connect your phone to the WiFi of your SwimTracker",
|
||||
simpleMode: "Simple Mode",
|
||||
advancedMode: "Advanced Mode",
|
||||
help: "Need help?",
|
||||
settings: "Settings",
|
||||
lastSessions: "Last Sessions",
|
||||
mainMenu_social: "Social",
|
||||
mainMenu_swimNow: "Swim now"
|
||||
},
|
||||
de : {
|
||||
connecting: "Verbindung aufbauen",
|
||||
connectSubtext: "Gehe entweder in das WLAN deines SwimTrackers oder in dein eigenes WLAN, falls du den SwimTracker schon eingerichtet hast.",
|
||||
simpleMode: "Weniger Einstellungen",
|
||||
advancedMode: "Mehr Einstellungen",
|
||||
settings: "Einstellungen",
|
||||
help: "Hilfe",
|
||||
lastSessions: "Letzte Sessions",
|
||||
mainMenu_social: "Freunde",
|
||||
mainMenu_swimNow: "Jetzt schwimmen",
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
export const i18n = new I18n();
|
||||
i18n.store(translation_store);
|
||||
i18n.defaultLocale = "en";
|
||||
i18n.enableFallback = true;
|
||||
i18n.locale = Localization.locale;
|
||||
106
SwimTracker/views/ConnectingView.js
Normal file
@@ -0,0 +1,106 @@
|
||||
import React, { useState } from "react";
|
||||
import {
|
||||
StyleSheet,
|
||||
Text,
|
||||
View,
|
||||
TextInput,
|
||||
ActivityIndicator,
|
||||
} from "react-native";
|
||||
|
||||
import SetupView from '../components/SetupView';
|
||||
import { connect } from 'react-redux';
|
||||
import { changeSwimTrackerHostname } from '../state/Reducer';
|
||||
import { i18n } from '../utility/i18n';
|
||||
|
||||
const validHostnameRegex = /((^\s*((([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])\.){3}([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5]))\s*$)|(^\s*((([0-9A-Fa-f]{1,4}:){7}([0-9A-Fa-f]{1,4}|:))|(([0-9A-Fa-f]{1,4}:){6}(:[0-9A-Fa-f]{1,4}|((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3})|:))|(([0-9A-Fa-f]{1,4}:){5}(((:[0-9A-Fa-f]{1,4}){1,2})|:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3})|:))|(([0-9A-Fa-f]{1,4}:){4}(((:[0-9A-Fa-f]{1,4}){1,3})|((:[0-9A-Fa-f]{1,4})?:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){3}(((:[0-9A-Fa-f]{1,4}){1,4})|((:[0-9A-Fa-f]{1,4}){0,2}:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){2}(((:[0-9A-Fa-f]{1,4}){1,5})|((:[0-9A-Fa-f]{1,4}){0,3}:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){1}(((:[0-9A-Fa-f]{1,4}){1,6})|((:[0-9A-Fa-f]{1,4}){0,4}:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:))|(:(((:[0-9A-Fa-f]{1,4}){1,7})|((:[0-9A-Fa-f]{1,4}){0,5}:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:)))(%.+)?\s*$))|(^\s*((?=.{1,255}$)(?=.*[A-Za-z].*)[0-9A-Za-z](?:(?:[0-9A-Za-z]|\b-){0,61}[0-9A-Za-z])?(?:\.[0-9A-Za-z](?:(?:[0-9A-Za-z]|\b-){0,61}[0-9A-Za-z])?)*)\s*$)/;
|
||||
|
||||
function isValidHostname(hostname) {
|
||||
return validHostnameRegex.test(hostname);
|
||||
}
|
||||
|
||||
function ConnectingView(props) {
|
||||
|
||||
const [isHostnameValid, setIsHostnameValid] = useState(isValidHostname(props.swimTrackerHost));
|
||||
const [hostnameTextInput, setHostnameTextInput] = useState(props.swimTrackerHost);
|
||||
const [advancedMode, setAdvancedMode] = useState(false);
|
||||
|
||||
|
||||
let onHostnameChange = newHostName => {
|
||||
setHostnameTextInput(newHostName);
|
||||
const newHostnameValid = isValidHostname(newHostName);
|
||||
setIsHostnameValid(newHostnameValid);
|
||||
if (newHostnameValid) {
|
||||
props.dispatch(changeSwimTrackerHostname(newHostName));
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
const hiddenStyle = advancedMode ? {} : {"display": "none"};
|
||||
|
||||
return (
|
||||
<SetupView
|
||||
headerText={i18n.t("connecting") + "..."}
|
||||
lowerLeftButtonText={ i18n.t(advancedMode ? 'simpleMode' : 'advancedMode') }
|
||||
onLowerLeftButtonPress={() => { setAdvancedMode(!advancedMode); }}
|
||||
lowerRightButtonText={i18n.t('help')}
|
||||
>
|
||||
<View style={{flexDirection: "row", alignItems: "center"}}>
|
||||
<ActivityIndicator size="large" color="#ffffff" />
|
||||
<Text style={styles.subtext}>
|
||||
{i18n.t('connectSubtext')}
|
||||
</Text>
|
||||
</View>
|
||||
{true &&
|
||||
<View style={StyleSheet.flatten([styles.row, hiddenStyle])}>
|
||||
<Text style={styles.label}>Host:</Text>
|
||||
<TextInput
|
||||
onChangeText={onHostnameChange}
|
||||
value={hostnameTextInput}
|
||||
style={{ ...styles.hostnameInput, color: isHostnameValid ? "rgb(255, 255, 255)" : "rgb(255, 150, 150)" }}
|
||||
placeholderTextColor="rgba(255,255,255,0.5)"
|
||||
/>
|
||||
</View>
|
||||
}
|
||||
</SetupView>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
subtext: {
|
||||
color: "rgba(255,255,255,1)",
|
||||
textAlign: "left",
|
||||
fontSize: 16,
|
||||
lineHeight: 25,
|
||||
width: "100%",
|
||||
//paddingTop: 50,
|
||||
paddingLeft: 30,
|
||||
},
|
||||
row: {
|
||||
backgroundColor: "rgba(255,255,255,0.4)",
|
||||
borderRadius: 5,
|
||||
width: "100%",
|
||||
height: 50,
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
marginTop: 60,
|
||||
marginBottom: 5,
|
||||
},
|
||||
hostnameInput: {
|
||||
height: 30,
|
||||
color: "rgba(255,255,255,1)",
|
||||
width: "80%",
|
||||
fontSize: 18,
|
||||
},
|
||||
label : {
|
||||
color: "rgb(80, 80, 80)",
|
||||
marginLeft: 15,
|
||||
marginRight: 10,
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
const mapStateToProps = (state) => {
|
||||
return { swimTrackerHost: state.settings.swimTrackerHost };
|
||||
};
|
||||
|
||||
export default connect(mapStateToProps)(ConnectingView);
|
||||
278
SwimTracker/views/LastSessionsView.js
Normal file
@@ -0,0 +1,278 @@
|
||||
import React from "react";
|
||||
import {
|
||||
ActivityIndicator,
|
||||
StyleSheet,
|
||||
View,
|
||||
StatusBar,
|
||||
Text,
|
||||
TouchableOpacity,
|
||||
RefreshControl,
|
||||
} from "react-native";
|
||||
import themeColors from '../components/themeColors';
|
||||
import EntypoIcon from "react-native-vector-icons/Entypo";
|
||||
import AntDesignIcon from "react-native-vector-icons/AntDesign";
|
||||
import FaIcon from "react-native-vector-icons/FontAwesome5";
|
||||
import ImageHeader from "../components/ImageHeader";
|
||||
import { SwipeListView } from 'react-native-swipe-list-view';
|
||||
import { connect } from 'react-redux';
|
||||
import request from '../utility/PromiseRequest';
|
||||
import DataAnalysis from '../data_processing/DataAnalysis';
|
||||
import * as msgpack from 'msgpack-lite';
|
||||
import { timeSince } from '../utility/TimeUtils';
|
||||
import XMLParser from 'react-xml-parser';
|
||||
import {i18n} from '../utility/i18n';
|
||||
|
||||
|
||||
function SessionCard(props) {
|
||||
return (
|
||||
<View style={sessionCardStyles.card}>
|
||||
<View>
|
||||
<Text style={sessionCardStyles.firstLineText}>{props.textFirstLine}</Text>
|
||||
</View>
|
||||
|
||||
<View style={sessionCardStyles.secondLine}>
|
||||
<View style={sessionCardStyles.iconTextPair}>
|
||||
<FaIcon name="stopwatch" style={sessionCardStyles.icon} />
|
||||
<Text style={sessionCardStyles.secondLineText}>{props.activeTime}</Text>
|
||||
</View>
|
||||
<View style={sessionCardStyles.iconTextPair}>
|
||||
<EntypoIcon name="ruler" style={sessionCardStyles.icon} />
|
||||
<Text style={sessionCardStyles.secondLineText}>{props.momentum}</Text>
|
||||
</View>
|
||||
<View style={sessionCardStyles.iconTextPair}>
|
||||
<AntDesignIcon name="retweet" style={sessionCardStyles.icon} />
|
||||
<Text style={sessionCardStyles.secondLineText}>{props.laps}</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
function SessionCardBehindSwipe(props) {
|
||||
return (
|
||||
<View style={sessionCardStyles.rowBack}>
|
||||
<TouchableOpacity
|
||||
style={sessionCardStyles.deleteButton}
|
||||
onPress={props.onDelete}
|
||||
>
|
||||
<Text style={{ fontSize: 18, color: "white" }}>Löschen</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const sessionCardStyles = StyleSheet.create({
|
||||
card: {
|
||||
backgroundColor: "#559ac8",
|
||||
borderRadius: 12,
|
||||
height: 100,
|
||||
maxHeight: 100,
|
||||
flex: 1,
|
||||
flexDirection: "column",
|
||||
justifyContent: "space-around",
|
||||
padding: 10,
|
||||
margin: 10,
|
||||
paddingLeft: 20,
|
||||
},
|
||||
firstLineText: {
|
||||
color: "white",
|
||||
fontSize: 22
|
||||
},
|
||||
iconTextPair: {
|
||||
maxWidth: 100,
|
||||
flex: 1,
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
},
|
||||
secondLine: {
|
||||
flex: 1,
|
||||
justifyContent: "space-between",
|
||||
alignContent: "center",
|
||||
flexDirection: "row",
|
||||
maxHeight: 30,
|
||||
marginTop: 14,
|
||||
},
|
||||
icon: {
|
||||
fontSize: 30,
|
||||
color: "white",
|
||||
paddingRight: 10,
|
||||
},
|
||||
secondLineText: {
|
||||
color: "white",
|
||||
fontSize: 18,
|
||||
},
|
||||
spacerHidden: {
|
||||
flex: 1,
|
||||
color: "black",
|
||||
},
|
||||
rowBack: {
|
||||
alignItems: 'center',
|
||||
backgroundColor: themeColors['ALIZARIN'],
|
||||
flex: 1,
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
height: 100,
|
||||
padding: 10,
|
||||
margin: 10,
|
||||
paddingLeft: 20,
|
||||
borderRadius: 12,
|
||||
},
|
||||
deleteButton: {
|
||||
alignItems: 'center',
|
||||
bottom: 0,
|
||||
justifyContent: 'center',
|
||||
position: 'absolute',
|
||||
backgroundColor: themeColors['ALIZARIN'],
|
||||
top: 0,
|
||||
width: 150,
|
||||
right: 0,
|
||||
borderTopRightRadius: 12,
|
||||
borderBottomRightRadius: 12,
|
||||
},
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
function parsePropfind(text) {
|
||||
const parser = new XMLParser();
|
||||
const xmlDoc = parser.parseFromString(text);
|
||||
|
||||
//const parser = new DOMParser();
|
||||
//const xmlDoc = parser.parseFromString(text, "text/xml");
|
||||
|
||||
const responses = xmlDoc.getElementsByTagName("D:response");
|
||||
let result = [];
|
||||
for (let i = 0; i < responses.length; ++i) {
|
||||
const e = responses[i];
|
||||
const name = e.getElementsByTagName("D:href")[0].value;
|
||||
const size = e.getElementsByTagName("D:getcontentlength")[0].value;
|
||||
result.push({
|
||||
name: name,
|
||||
size: parseInt(size),
|
||||
startTime: parseInt(name.split(".")[0])
|
||||
});
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
const msgpackCodec = msgpack.createCodec();
|
||||
msgpackCodec.addExtUnpacker(205, function (byteArr) {
|
||||
const buffer = byteArr.buffer.slice(byteArr.byteOffset, byteArr.byteLength + byteArr.byteOffset);
|
||||
const result = new Int16Array(buffer);
|
||||
return result;
|
||||
});
|
||||
|
||||
|
||||
async function getSessionDetails(swimTrackerHost, sessionFileName) {
|
||||
const url = "http://" + swimTrackerHost + "/webdav/" + sessionFileName;
|
||||
const arrayBuffer = await request({ url: url, responseType: 'arraybuffer' });
|
||||
return msgpack.decode(new Uint8Array(arrayBuffer), { codec: msgpackCodec });
|
||||
}
|
||||
|
||||
async function getSessionsFromDevice(swimTrackerHost) {
|
||||
const data = await request({ url: "http://" + swimTrackerHost + "/webdav/", method: "PROPFIND" });
|
||||
return parsePropfind(data);
|
||||
}
|
||||
|
||||
async function getFullData(swimTrackerHost, analysisSettings) {
|
||||
const parsed = await getSessionsFromDevice(swimTrackerHost);
|
||||
for (let index = 0; index < parsed.length; index++) {
|
||||
const e = parsed[index];
|
||||
const sessionDetails = await getSessionDetails(swimTrackerHost, e.name);
|
||||
e.values = sessionDetails.values;
|
||||
const da = new DataAnalysis();
|
||||
e.analysis = da.analyze(analysisSettings, e.startTime, e.values);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
class LastSessionsView extends React.Component {
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.state = { sessions: null, refreshing: false };
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
getFullData(this.props.swimTrackerHost, this.props.analysisSettings).then(
|
||||
e => this.setState({ sessions: e })
|
||||
);
|
||||
}
|
||||
|
||||
render() {
|
||||
const deleteSession = async sessionFileName => {
|
||||
this.setState({ sessions: null });
|
||||
await request({ url: "http://" + this.props.swimTrackerHost + "/webdav/" + sessionFileName, method: "DELETE" });
|
||||
this.setState({ sessions: await getFullData(this.props.swimTrackerHost, this.props.analysisSettings) });
|
||||
};
|
||||
|
||||
const onRefresh = async () => {
|
||||
this.setState({ refreshing: true });
|
||||
const newSessions = await getFullData(this.props.swimTrackerHost, this.props.analysisSettings);
|
||||
this.setState({ sessions: newSessions, refreshing: false });
|
||||
};
|
||||
|
||||
let innerView;
|
||||
if (this.state.sessions) {
|
||||
innerView = <SwipeListView
|
||||
refreshControl={<RefreshControl refreshing={this.state.refreshing} onRefresh={onRefresh} /> }
|
||||
style={{ width: "100%" }}
|
||||
keyExtractor={item => item.startTime.toString()}
|
||||
disableRightSwipe={true}
|
||||
data={this.state.sessions.reverse()}
|
||||
renderItem={(data, rowMap) => (
|
||||
<SessionCard
|
||||
textFirstLine={timeSince(data.item.startTime)}
|
||||
laps={(data.item.analysis.peaks.size / this.props.peaksPerLap).toFixed(1)}
|
||||
momentum={Math.trunc(data.item.analysis.totalMomentum * this.props.kgFactor / 10 / 60)}
|
||||
activeTime={data.item.analysis.activeTime} />
|
||||
)}
|
||||
renderHiddenItem={(data, rowMap) => <SessionCardBehindSwipe onDelete={() => { deleteSession(data.item.name) }} />}
|
||||
leftOpenValue={0}
|
||||
rightOpenValue={-120}
|
||||
stopRightSwipe={-145}
|
||||
/>
|
||||
}
|
||||
else {
|
||||
innerView = (
|
||||
<View style={{
|
||||
flex: 1,
|
||||
justifyContent: "center",
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-around",
|
||||
}}>
|
||||
<ActivityIndicator size="large" color="#aaa"></ActivityIndicator>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={{ flex: 1 }}>
|
||||
<StatusBar barStyle="light-content" backgroundColor="rgba(0,0,0,0.4)" translucent={true} />
|
||||
<View style={{ flex: 1 }}>
|
||||
<ImageHeader
|
||||
text={ i18n.t('lastSessions').toUpperCase() }
|
||||
navigation={this.props.navigation}
|
||||
image={require("../assets/swimmer.jpg")}
|
||||
/>
|
||||
<View style={{ flex: 1, backgroundColor: themeColors["BELIZE HOLE"] }}>
|
||||
{innerView}
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const mapStateToProps = (state) => {
|
||||
return {
|
||||
swimTrackerHost: state.settings.swimTrackerHost,
|
||||
analysisSettings: state.settings.analysis,
|
||||
kgFactor: state.settings.analysis.kgFactor,
|
||||
peaksPerLap: state.settings.analysis.peaksPerLap,
|
||||
};
|
||||
};
|
||||
export default connect(mapStateToProps)(LastSessionsView);
|
||||
|
||||
240
SwimTracker/views/MainMenuView.js
Normal file
@@ -0,0 +1,240 @@
|
||||
import React from "react";
|
||||
import {
|
||||
StyleSheet,
|
||||
View,
|
||||
StatusBar,
|
||||
ImageBackground,
|
||||
Text,
|
||||
TouchableOpacity,
|
||||
} from "react-native";
|
||||
import themeColors from '../components/themeColors';
|
||||
import MaterialIcon from "react-native-vector-icons/MaterialIcons";
|
||||
import MaterialCommIcon from "react-native-vector-icons/MaterialCommunityIcons";
|
||||
|
||||
import EntypoIcon from "react-native-vector-icons/Entypo";
|
||||
|
||||
import { i18n } from '../utility/i18n';
|
||||
|
||||
import { ConnState, startSession } from '../state/DeviceReduxCoupling';
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
function LargeHeaderView(props) {
|
||||
return (
|
||||
<View style={largeHeaderStyles.container}>
|
||||
<Text style={largeHeaderStyles.titleText}>swimtracker</Text>
|
||||
<View style={largeHeaderStyles.separator}></View>
|
||||
<Text style={largeHeaderStyles.subText}>bauer.tech</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const largeHeaderStyles = StyleSheet.create({
|
||||
container: {
|
||||
height: 160,
|
||||
maxHeight: 160,
|
||||
flex: 1,
|
||||
alignItems: "center",
|
||||
justifyContent: "flex-end",
|
||||
paddingBottom: 10,
|
||||
},
|
||||
titleText: {
|
||||
color: "rgba(255,255,255,1)",
|
||||
fontSize: 48,
|
||||
},
|
||||
subText: {
|
||||
color: "rgba(255,255,255, 0.8)",
|
||||
marginTop: 15,
|
||||
textAlign: "center",
|
||||
},
|
||||
separator: {
|
||||
height: 7,
|
||||
backgroundColor: "rgba(255,255,255,1)",
|
||||
opacity: 0.5,
|
||||
width: 230
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
function ButtonGrid(props) {
|
||||
return (
|
||||
<View style={buttonGridStyles.rowContainer}>
|
||||
<View style={buttonGridStyles.columnContainer}>
|
||||
<TouchableOpacity
|
||||
onPress={props.onLastSessionsPress}
|
||||
style={[{ backgroundColor: themeColors["GREEN SEA"] }, buttonGridStyles.button]}
|
||||
activeOpacity={0.6}
|
||||
>
|
||||
<MaterialCommIcon name="swim" style={buttonGridStyles.icon}></MaterialCommIcon>
|
||||
<Text style={buttonGridStyles.buttonText}>{ i18n.t('lastSessions').toUpperCase().split(" ").join("\n") }</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
<View style={buttonGridStyles.columnContainer}>
|
||||
<TouchableOpacity
|
||||
onPress={props.onSocialPress}
|
||||
style={[{ backgroundColor: "#444" }, buttonGridStyles.button]}
|
||||
activeOpacity={0.6}
|
||||
disabled={true}
|
||||
>
|
||||
<MaterialCommIcon name="account-group" style={buttonGridStyles.icon}></MaterialCommIcon>
|
||||
<Text style={buttonGridStyles.buttonText}>{ i18n.t('mainMenu_social').toUpperCase()}</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
onPress={props.onSettingsPress}
|
||||
style={[{ backgroundColor: themeColors["MIDNIGHT BLUE"] }, buttonGridStyles.button]}
|
||||
activeOpacity={0.6}
|
||||
>
|
||||
<MaterialIcon name="settings" style={buttonGridStyles.icon}></MaterialIcon>
|
||||
<Text style={buttonGridStyles.buttonText}>{ i18n.t('settings').toUpperCase()}</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
const buttonGridStyles = StyleSheet.create({
|
||||
rowContainer: {
|
||||
flex: 1,
|
||||
flexDirection: "column",
|
||||
justifyContent: "space-around",
|
||||
alignItems: "center",
|
||||
paddingTop: 20,
|
||||
paddingBottom: 50,
|
||||
},
|
||||
columnContainer: {
|
||||
flex: 1,
|
||||
width: "100%",
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-around",
|
||||
alignItems: "center",
|
||||
},
|
||||
button: {
|
||||
flex: 1,
|
||||
margin: 20,
|
||||
padding: 20,
|
||||
width: 120,
|
||||
height: 130,
|
||||
borderRadius: 10,
|
||||
opacity: 0.95,
|
||||
justifyContent: "space-around",
|
||||
alignItems: "center",
|
||||
},
|
||||
buttonText: {
|
||||
color: "rgba(255,255,255,1)",
|
||||
textAlign: "center",
|
||||
fontSize: 16,
|
||||
},
|
||||
icon: {
|
||||
color: "rgba(255,255,255,1)",
|
||||
fontSize: 60,
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
function FullWidthButton(props) {
|
||||
let textStyle = [fullWidthButtonStyles.buttonText];
|
||||
let iconStyle = [fullWidthButtonStyles.icon];
|
||||
if (props.disabled) {
|
||||
textStyle.push(fullWidthButtonStyles.buttonTextDisabled);
|
||||
iconStyle.push(fullWidthButtonStyles.iconDisabled);
|
||||
}
|
||||
return (
|
||||
<TouchableOpacity
|
||||
onPress={props.onPress}
|
||||
style={fullWidthButtonStyles.container}
|
||||
disabled={props.disabled}
|
||||
activeOpacity={0.6}>
|
||||
<View style={{ flex: 1, flexDirection: "row", justifyContent: "center" }}>
|
||||
<EntypoIcon name="air" style={iconStyle}></EntypoIcon>
|
||||
<Text style={textStyle}>{props.text}</Text>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
)
|
||||
}
|
||||
|
||||
const fullWidthButtonStyles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
maxHeight: 70,
|
||||
backgroundColor: themeColors["WET ASPHALT"],
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
//paddingBottom: 10,
|
||||
justifyContent: "center",
|
||||
},
|
||||
buttonText: {
|
||||
padding: 10,
|
||||
color: "rgba(255,255,255,1)",
|
||||
fontSize: 25,
|
||||
fontWeight: "600",
|
||||
textAlign: "center",
|
||||
},
|
||||
buttonTextDisabled: {
|
||||
opacity: 0.3,
|
||||
},
|
||||
icon: {
|
||||
fontSize: 40,
|
||||
padding: 10,
|
||||
color: themeColors["GREEN SEA"],
|
||||
},
|
||||
iconDisabled: {
|
||||
color: themeColors["POMEGRANATE"],
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
function MainMenuView(props) {
|
||||
const s = props.connState;
|
||||
let startButtonText = i18n.t('mainMenu_swimNow').toUpperCase();
|
||||
let startButtonDisabled = false;
|
||||
if (s === ConnState.DISCONNECTED) {
|
||||
startButtonText = "NICHT VERBUNDEN";
|
||||
startButtonDisabled = true;
|
||||
} else if (s === ConnState.CONNECTED_RUNNING || s === ConnState.CONNECTED_STARTING) {
|
||||
startButtonText = "TRAINING LÄUFT";
|
||||
} else if (s === ConnState.CONNECTED_STOPPING) {
|
||||
startButtonDisabled = true;
|
||||
}
|
||||
|
||||
const onStartButtonPress = () => {
|
||||
if (!props.connState !== ConnState.CONNECTED_RUNNING) {
|
||||
props.dispatch(startSession());
|
||||
}
|
||||
props.navigation.navigate('Training')
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={{ flex: 1 }}>
|
||||
<StatusBar barStyle="light-content" backgroundColor="rgba(0,0,0,0.4)" translucent={true} />
|
||||
<ImageBackground
|
||||
source={require("../assets/pool_sky_background_blurred.jpg")}
|
||||
resizeMode="cover"
|
||||
style={{ flex: 1 }}
|
||||
>
|
||||
<View style={{ flex: 1 }}>
|
||||
<LargeHeaderView />
|
||||
<ButtonGrid
|
||||
onSettingsPress={() => props.navigation.navigate('Settings')}
|
||||
onLastSessionsPress = {() => props.navigation.navigate("LastSessions")}
|
||||
/>
|
||||
<FullWidthButton
|
||||
onPress={onStartButtonPress}
|
||||
text={startButtonText}
|
||||
disabled={startButtonDisabled} />
|
||||
</View>
|
||||
</ImageBackground>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const mapStateToProps = (state) => {
|
||||
return { connState: state.deviceState.connState };
|
||||
};
|
||||
|
||||
export default connect(mapStateToProps)(MainMenuView);
|
||||
252
SwimTracker/views/SettingsView.js
Normal file
@@ -0,0 +1,252 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import {
|
||||
StyleSheet,
|
||||
View,
|
||||
StatusBar,
|
||||
TextInput,
|
||||
Text,
|
||||
Switch,
|
||||
} from "react-native";
|
||||
import themeColors from '../components/themeColors';
|
||||
import ImageHeader from "../components/ImageHeader";
|
||||
import { connect } from 'react-redux';
|
||||
import { TouchableOpacity } from "react-native-gesture-handler";
|
||||
import request from '../utility/PromiseRequest';
|
||||
import { i18n } from '../utility/i18n';
|
||||
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
function SettingsTextInput(props) {
|
||||
return (
|
||||
<React.Fragment>
|
||||
<Text style={settingsGroupStyles.label}>{props.label}</Text>
|
||||
<TextInput
|
||||
style={settingsGroupStyles.textInput}
|
||||
placeholder={props.placeholder}
|
||||
placeholderTextColor="rgba(167,167,167,1)"
|
||||
selectionColor='rgb(120,120,120)'
|
||||
value={props.value}
|
||||
></TextInput>
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
function SettingsSwitch(props) {
|
||||
const [isEnabled, setIsEnabled] = useState(false);
|
||||
const toggleSwitch = () => setIsEnabled(previousState => !previousState);
|
||||
|
||||
return (
|
||||
<React.Fragment>
|
||||
<Text style={settingsGroupStyles.label}>{props.label}</Text>
|
||||
<Switch
|
||||
value={isEnabled}
|
||||
//thumbColor={themeColors["WET ASPHALT"]}
|
||||
onValueChange={toggleSwitch}
|
||||
trackColor={{ false: "#767577", true: themeColors["NEPHRITIS"] }}
|
||||
//thumbColor={isEnabled ? "#f5dd4b" : "#f4f3f4"}
|
||||
ios_backgroundColor="grey"
|
||||
style={settingsGroupStyles.switch}
|
||||
/>
|
||||
</React.Fragment>
|
||||
)
|
||||
}
|
||||
|
||||
function SettingsButton(props) {
|
||||
|
||||
return (
|
||||
<React.Fragment>
|
||||
<Text style={settingsGroupStyles.label}>{props.label}</Text>
|
||||
<TouchableOpacity style={settingsGroupStyles.buttonTouchable} onPress={props.onPress}>
|
||||
<Text style={settingsGroupStyles.buttonText}>{props.buttonText}</Text>
|
||||
</TouchableOpacity>
|
||||
</React.Fragment>
|
||||
)
|
||||
|
||||
}
|
||||
|
||||
function SettingsText(props) {
|
||||
return (
|
||||
<React.Fragment>
|
||||
<Text style={settingsGroupStyles.label}>{props.label}</Text>
|
||||
<Text style={settingsGroupStyles.text}>{props.text}</Text>
|
||||
</React.Fragment>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
function SettingsSlider(props) {
|
||||
/*
|
||||
<Slider
|
||||
value={props.value}
|
||||
disabled={props.disabled}
|
||||
thumbTintColor={themeColors["WET ASPHALT"]}
|
||||
minimumTrackTintColor={themeColors["CLOUDS"]}
|
||||
maximumTrackTintColor={themeColors["CLOUDS"]}
|
||||
style={settingsGroupStyles.slider}
|
||||
/>
|
||||
*/
|
||||
|
||||
return (
|
||||
<React.Fragment>
|
||||
<Text style={settingsGroupStyles.label}>{props.label}</Text>
|
||||
</React.Fragment>
|
||||
)
|
||||
}
|
||||
|
||||
function SettingsCombo(props) {
|
||||
|
||||
}
|
||||
|
||||
|
||||
function SettingsGroup(props) {
|
||||
|
||||
return (
|
||||
<View style={settingsGroupStyles.container}>
|
||||
<Text style={settingsGroupStyles.title}>{props.title}</Text>
|
||||
<View style={settingsGroupStyles.subsettings}>
|
||||
{React.Children.map(props.children, (child, idx) =>
|
||||
<View style={idx == 0 ? [settingsGroupStyles.row, settingsGroupStyles.firstRow] : settingsGroupStyles.row}>
|
||||
{child}
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const settingsGroupStyles = StyleSheet.create({
|
||||
container: {
|
||||
padding: 20,
|
||||
paddingRight: 30,
|
||||
},
|
||||
title: {
|
||||
color: "white",
|
||||
fontSize: 20,
|
||||
fontWeight: "600",
|
||||
},
|
||||
subsettings: {
|
||||
padding: 10,
|
||||
paddingLeft: 30,
|
||||
},
|
||||
row: {
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
borderTopColor: "rgba(255, 255,255, 0.6)",
|
||||
paddingTop: 5,
|
||||
paddingBottom: 5,
|
||||
borderTopWidth: 0.5,
|
||||
minHeight: 40,
|
||||
},
|
||||
firstRow: {
|
||||
paddingTop: 0,
|
||||
borderTopWidth: 0,
|
||||
},
|
||||
label: {
|
||||
color: "white",
|
||||
fontSize: 17,
|
||||
},
|
||||
textInput: {
|
||||
color: "rgba(255,255,255,1)",
|
||||
marginTop: 8,
|
||||
textAlign: "right",
|
||||
},
|
||||
slider: {
|
||||
minWidth: 100,
|
||||
width: 150,
|
||||
//minHeight: 50,
|
||||
},
|
||||
switch: {
|
||||
//minHeight: 50,
|
||||
//minWidth: 80,
|
||||
},
|
||||
buttonText: {
|
||||
color: "rgba(255,255,255,1)",
|
||||
width: "100%",
|
||||
textAlign: "center",
|
||||
},
|
||||
text : {
|
||||
color: "rgba(255,255,255,1)",
|
||||
width: "100%",
|
||||
textAlign: "right",
|
||||
},
|
||||
buttonTouchable: {
|
||||
backgroundColor: themeColors["CARROT"],
|
||||
width: 128,
|
||||
padding: 10,
|
||||
justifyContent: "center",
|
||||
borderRadius: 4,
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
|
||||
async function queryDeviceFirmwareVersion(swimTrackerHost) {
|
||||
const result = await request({ url: "http://" + swimTrackerHost + "/api/status", responseType: "json" });
|
||||
return result["firmware"]["version"];
|
||||
}
|
||||
|
||||
async function queryNewestFirmwareVersion() {
|
||||
const QUERY_URL = "https://swimtracker-update.bauer.tech/VERSION";
|
||||
const result = await request({ url: QUERY_URL, responseType: "text" });
|
||||
console.log("newest firmware version, got", result);
|
||||
return result;
|
||||
}
|
||||
|
||||
|
||||
function SettingsView(props) {
|
||||
|
||||
const [deviceFirmwareVersion, setDeviceFirmwareVersion] = useState("");
|
||||
const [newestFirmwareVersion, setNewestFirmwareVersion] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
Promise.all([queryDeviceFirmwareVersion(props.settings.swimTrackerHost), queryNewestFirmwareVersion()]).then(
|
||||
(values) => {
|
||||
setDeviceFirmwareVersion(values[0]);
|
||||
setNewestFirmwareVersion(values[1]);
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
const doFirmwareUpdate = () => {
|
||||
request({ url: "http://" + props.settings.swimTrackerHost + "/api/firmwareupdate", responseType: "text"});
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={{ flex: 1 }}>
|
||||
<StatusBar barStyle="light-content" backgroundColor="rgba(0,0,0,0.4)" translucent={true} />
|
||||
<View style={{ flex: 1 }}>
|
||||
<ImageHeader
|
||||
text={i18n.t('settings').toUpperCase()}
|
||||
navigation={props.navigation}
|
||||
image={require("../assets/infinity_pool2.jpg")}
|
||||
/>
|
||||
|
||||
<View style={{ flex: 1, backgroundColor: themeColors["BELIZE HOLE"] }}>
|
||||
<SettingsGroup title="swimtracker Device">
|
||||
<SettingsTextInput
|
||||
label="URL/IP"
|
||||
placeholder="swimtracker-????"
|
||||
value={props.settings.swimTrackerHost}
|
||||
/>
|
||||
<SettingsSwitch label="Start automatically" />
|
||||
<SettingsSwitch label="Stop automatically" />
|
||||
<SettingsButton label="Tare" buttonText="GO" onPress={props.device.conn.sendTareCommand} />
|
||||
<SettingsButton label="WiFi config" buttonText="Reset" onPress={props.device.conn.wifiResetToProvisioning} />
|
||||
<SettingsText label="Firmware version" text={deviceFirmwareVersion}></SettingsText>
|
||||
<SettingsText label="Newest Firmware" text={newestFirmwareVersion}></SettingsText>
|
||||
<SettingsButton label="Update Firmware" buttonText="GO" onPress={doFirmwareUpdate}></SettingsButton>
|
||||
</SettingsGroup>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
const mapStateToProps = (state) => {
|
||||
return { settings: state.settings };
|
||||
};
|
||||
|
||||
export default connect(mapStateToProps)(SettingsView);
|
||||
122
SwimTracker/views/TrainingView.js
Normal file
@@ -0,0 +1,122 @@
|
||||
import React from "react";
|
||||
import {
|
||||
StyleSheet,
|
||||
View,
|
||||
StatusBar,
|
||||
Text,
|
||||
TouchableOpacity
|
||||
} from "react-native";
|
||||
import themeColors from '../components/themeColors';
|
||||
import EntypoIcon from "react-native-vector-icons/Entypo";
|
||||
|
||||
import { connect } from 'react-redux';
|
||||
import { useKeepAwake } from 'expo-keep-awake';
|
||||
import { stopSession } from '../state/DeviceReduxCoupling';
|
||||
import CycleView from '../components/CycleView';
|
||||
import IconCard from '../components/IconCard';
|
||||
import Graph from '../components/Graph';
|
||||
import {toTimeStr} from '../utility/TimeUtils';
|
||||
|
||||
|
||||
function SmallHeaderView(props) {
|
||||
return (
|
||||
<View style={smallHeaderStyles.container}>
|
||||
<View style={smallHeaderStyles.row}>
|
||||
<TouchableOpacity onPress={() => props.navigation.goBack()}>
|
||||
<EntypoIcon name="chevron-left" style={smallHeaderStyles.backIcon}></EntypoIcon>
|
||||
</TouchableOpacity>
|
||||
<Text style={smallHeaderStyles.text}>{props.text}</Text>
|
||||
<TouchableOpacity onPress={props.onStopPressed}>
|
||||
<EntypoIcon name="controller-stop" style={smallHeaderStyles.stopIcon}></EntypoIcon>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View >
|
||||
)
|
||||
}
|
||||
|
||||
const smallHeaderStyles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
minHeight: 80,
|
||||
maxHeight: 80,
|
||||
height: 80,
|
||||
width: "100%",
|
||||
backgroundColor: themeColors["WET ASPHALT"],
|
||||
},
|
||||
row: {
|
||||
paddingTop: 30,
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
},
|
||||
backIcon: {
|
||||
color: "white",
|
||||
fontSize: 40,
|
||||
},
|
||||
stopIcon: {
|
||||
color: themeColors["ALIZARIN"],
|
||||
fontSize: 40,
|
||||
paddingRight: 10,
|
||||
paddingLeft: 10,
|
||||
},
|
||||
text: {
|
||||
color: "white",
|
||||
fontSize: 30,
|
||||
},
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
function TrainingView(props) {
|
||||
useKeepAwake();
|
||||
|
||||
const analysis = props.session.analysis;
|
||||
const laps = (analysis.peaks.size / props.peaksPerLap).toFixed(1);
|
||||
const totalMomentum = Math.trunc(analysis.totalMomentum * props.kgFactor / 10 / 60);
|
||||
|
||||
const onStopPressed = () => {
|
||||
props.dispatch(stopSession());
|
||||
props.navigation.navigate('Home');
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={{ flex: 1 }}>
|
||||
<StatusBar hidden={true} />
|
||||
<View style={{ flex: 1 }}>
|
||||
<SmallHeaderView text="TRAINING" navigation={props.navigation} onStopPressed={onStopPressed} />
|
||||
<View style={trainingViewStyles.container}>
|
||||
<CycleView>
|
||||
<IconCard label="BAHNEN" value={laps} iconName="retweet" iconType="AntDesign" />
|
||||
<IconCard label="ZÜGE" value={analysis.peaks.size} iconName="dashboard" iconType="AntDesign" />
|
||||
</CycleView>
|
||||
|
||||
<CycleView>
|
||||
<IconCard label="DAUER" value={toTimeStr(analysis.totalTime)} iconName="clock" iconType="FontAwesome5" />
|
||||
<IconCard label="AKTIVE DAUER" value={toTimeStr(analysis.activeTime)} iconName="stopwatch" iconType="FontAwesome5" />
|
||||
</CycleView>
|
||||
<IconCard label="KRAFT" value={totalMomentum} iconName="ruler" iconType="Entypo" />
|
||||
|
||||
<Graph></Graph>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
const trainingViewStyles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: themeColors["BELIZE HOLE"],
|
||||
padding: 20,
|
||||
justifyContent: "space-around",
|
||||
}
|
||||
});
|
||||
|
||||
const mapStateToProps = (state) => {
|
||||
return {
|
||||
session: state.deviceState,
|
||||
peaksPerLap: state.settings.analysis.peaksPerLap,
|
||||
theme: state.settings.theme,
|
||||
kgFactor: state.settings.analysis.kgFactor,
|
||||
};
|
||||
};
|
||||
export default connect(mapStateToProps)(TrainingView);
|
||||
182
SwimTracker/views/WifiPasswordView.js
Normal file
@@ -0,0 +1,182 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import {
|
||||
StyleSheet,
|
||||
Text,
|
||||
View,
|
||||
TouchableOpacity,
|
||||
TextInput,
|
||||
Keyboard
|
||||
} from "react-native";
|
||||
import SetupView from '../components/SetupView';
|
||||
import EvilIcon from "react-native-vector-icons/EvilIcons";
|
||||
import MaterialIcon from "react-native-vector-icons/MaterialCommunityIcons";
|
||||
import themeColors from '../components/themeColors';
|
||||
|
||||
function WifiPasswordView(props) {
|
||||
props = { ...props, ...props.route.params };
|
||||
|
||||
useEffect(() => {
|
||||
Keyboard.addListener("keyboardDidShow", _keyboardDidShow);
|
||||
Keyboard.addListener("keyboardDidHide", _keyboardDidHide);
|
||||
|
||||
// cleanup function
|
||||
return () => {
|
||||
Keyboard.removeListener("keyboardDidShow", _keyboardDidShow);
|
||||
Keyboard.removeListener("keyboardDidHide", _keyboardDidHide);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const [keyboardStatus, setKeyboardStatus] = useState(undefined);
|
||||
const [password1, setPassword1] = useState("");
|
||||
const [password2, setPassword2] = useState("");
|
||||
const [errorMsg, setErrorMsg] = useState("");
|
||||
|
||||
const _keyboardDidShow = () => setKeyboardStatus(true);
|
||||
const _keyboardDidHide = () => setKeyboardStatus(false);
|
||||
|
||||
let iconName = "wifi-strength-" + props.strength;
|
||||
if (props.lock) {
|
||||
iconName += "-lock";
|
||||
}
|
||||
|
||||
const onSubmit = () => {
|
||||
|
||||
if (props.confirmPwInput && password1 != password2)
|
||||
setErrorMsg("Passwords don't match");
|
||||
else if (password1.length < 8)
|
||||
setErrorMsg("Password has to be at least 8 characters long")
|
||||
else if (password1.length > 128)
|
||||
setErrorMsg("Password too long");
|
||||
else {
|
||||
props.onSubmit(props.ssid, password1);
|
||||
setErrorMsg("");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<SetupView
|
||||
headerText="WiFi Password"
|
||||
lowerRightButtonText="Need help?"
|
||||
backButton={true}
|
||||
navigation={props.navigation}
|
||||
>
|
||||
|
||||
{!keyboardStatus &&
|
||||
<Text style={styles.subtext}>
|
||||
{props.subText}
|
||||
</Text>
|
||||
}
|
||||
|
||||
<View style={styles.formContainer}>
|
||||
<View style={[styles.row, { backgroundColor: "rgba(155,155,155,0.8)" }]}>
|
||||
<MaterialIcon style={styles.ssidIcon} name={iconName}></MaterialIcon>
|
||||
<Text style={styles.ssidLabel} >{props.ssid}</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.row}>
|
||||
<EvilIcon name="lock" style={styles.ssidIcon}></EvilIcon>
|
||||
<TextInput style={styles.passwordInput}
|
||||
onChangeText={setPassword1}
|
||||
autoCompleteType="password"
|
||||
placeholder="Password"
|
||||
placeholderTextColor="rgba(255,255,255,0.5)"
|
||||
secureTextEntry={true}
|
||||
></TextInput>
|
||||
</View>
|
||||
|
||||
{props.confirmPwInput &&
|
||||
< View style={styles.row}>
|
||||
<EvilIcon name="lock" style={styles.ssidIcon}></EvilIcon>
|
||||
<TextInput style={styles.passwordInput}
|
||||
onChangeText={setPassword2}
|
||||
autoCompleteType="password"
|
||||
placeholder="Repeat Password"
|
||||
placeholderTextColor="rgba(255,255,255,0.5)"
|
||||
secureTextEntry={true}
|
||||
></TextInput>
|
||||
</View>
|
||||
}
|
||||
|
||||
{errorMsg.length > 0 &&
|
||||
<View>
|
||||
<Text style={{ color: "red", paddingTop: 10, paddingLeft: 55 }}>{errorMsg}</Text>
|
||||
</View>
|
||||
}
|
||||
|
||||
<TouchableOpacity style={[styles.row, styles.button]} onPress={onSubmit}>
|
||||
<Text style={[styles.ssidLabel, { alignSelf: "center", textAlign: "center" }]}>{props.buttonText}</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
</SetupView >
|
||||
);
|
||||
}
|
||||
|
||||
WifiPasswordView.defaultProps = {
|
||||
lock: true,
|
||||
strength: 2,
|
||||
ssid: "TheWLANName",
|
||||
confirmPwInput: false,
|
||||
buttonText: "Set Password",
|
||||
subText: "Please enter password for your home WiFi"
|
||||
}
|
||||
|
||||
|
||||
WifiPasswordView.defaultProps = {
|
||||
lock: true,
|
||||
strength: 3,
|
||||
ssid: "swimtracker-E2842S",
|
||||
subText: "Use this option only if you're home WiFi doesn't reach the SwimTracker. The SwimTracker creates its own WiFi with the password you set here.",
|
||||
confirmPwInput: true,
|
||||
buttonText: "Set Password",
|
||||
}
|
||||
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
subtext: {
|
||||
color: "rgba(255,255,255,1)",
|
||||
textAlign: "left",
|
||||
fontSize: 16,
|
||||
lineHeight: 25,
|
||||
width: "80%",
|
||||
paddingBottom: 30,
|
||||
},
|
||||
formContainer: {
|
||||
},
|
||||
row: {
|
||||
backgroundColor: "rgba(255,255,255,0.4)",
|
||||
borderRadius: 5,
|
||||
width: "100%",
|
||||
height: 50,
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
marginTop: 5,
|
||||
marginBottom: 5,
|
||||
},
|
||||
ssidLabel: {
|
||||
color: "rgba(255,255,255,1)",
|
||||
fontSize: 18,
|
||||
width: "100%"
|
||||
},
|
||||
button: {
|
||||
marginTop: 20,
|
||||
backgroundColor: themeColors["GREEN SEA"],
|
||||
justifyContent: "center"
|
||||
},
|
||||
ssidIcon: {
|
||||
fontSize: 25,
|
||||
color: "rgba(255,255,255,1)",
|
||||
marginLeft: 15,
|
||||
marginRight: 15,
|
||||
},
|
||||
passwordInput: {
|
||||
height: 30,
|
||||
color: "rgba(255,255,255,1)",
|
||||
width: "100%",
|
||||
fontSize: 18,
|
||||
}
|
||||
|
||||
});
|
||||
|
||||
|
||||
export default WifiPasswordView;
|
||||
194
SwimTracker/views/WifiSelectionView.js
Normal file
@@ -0,0 +1,194 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
StyleSheet,
|
||||
Text,
|
||||
View,
|
||||
TouchableOpacity,
|
||||
ScrollView,
|
||||
ActivityIndicator,
|
||||
} from "react-native";
|
||||
import SetupView from '../components/SetupView';
|
||||
import MaterialIcon from "react-native-vector-icons/MaterialCommunityIcons";
|
||||
import { connect } from 'react-redux';
|
||||
import { changeSwimTrackerHostname } from '../state/Reducer';
|
||||
|
||||
|
||||
function WifiListElement(props) {
|
||||
let iconName = "wifi-strength-" + props.strength;
|
||||
if (props.lock) {
|
||||
iconName += "-lock";
|
||||
}
|
||||
|
||||
return (
|
||||
<TouchableOpacity onPress={props.onPress}>
|
||||
<View style={wifiListElementStyles.container}>
|
||||
<MaterialIcon style={wifiListElementStyles.icon} name={iconName}></MaterialIcon>
|
||||
<Text style={wifiListElementStyles.text} >{props.text}</Text>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
)
|
||||
}
|
||||
const wifiListElementStyles = {
|
||||
container: {
|
||||
backgroundColor: "rgba(255,255,255,0.4)",
|
||||
borderRadius: 5,
|
||||
width: "100%",
|
||||
height: 50,
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
marginTop: 8,
|
||||
marginBottom: 8,
|
||||
},
|
||||
icon: {
|
||||
fontSize: 25,
|
||||
color: "rgba(255,255,255,1)",
|
||||
marginLeft: 15,
|
||||
marginRight: 15,
|
||||
},
|
||||
text: {
|
||||
color: "rgba(255,255,255,1)",
|
||||
fontSize: 18,
|
||||
width: "100%"
|
||||
}
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
class WifiSelectionView extends React.Component {
|
||||
constructor() {
|
||||
super();
|
||||
this.state = { wifiInfo: [] };
|
||||
this.mounted = false;
|
||||
}
|
||||
|
||||
processDeviceResponse(response) {
|
||||
// sort from strong to weak
|
||||
response.sort((e1, e2) => {
|
||||
if (e1.rssi > e2.rssi)
|
||||
return -1;
|
||||
if (e1.rssi < e2.rssi)
|
||||
return 1;
|
||||
else
|
||||
return 0;
|
||||
});
|
||||
|
||||
let ssidsAlreadyAdded = {};
|
||||
let result = [];
|
||||
for (let i = 0; i < response.length; i++) {
|
||||
if (response[i].ssid in ssidsAlreadyAdded)
|
||||
continue;
|
||||
|
||||
const locked = (response[i].sec != "open");
|
||||
let strength = 1;
|
||||
if (response[i].rssi > -30)
|
||||
strength = 4;
|
||||
else if (response[i].rssi > -67)
|
||||
strength = 3;
|
||||
else if (response[i].rssi > -70)
|
||||
strength = 2;
|
||||
|
||||
result.push({ ssid: response[i].ssid, locked: locked, strength: strength });
|
||||
ssidsAlreadyAdded[response[i].ssid] = true;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
let component = this;
|
||||
component.mounted = true;
|
||||
this.props.device.conn.scanWifiNetworks().then(
|
||||
(result) => {
|
||||
if(component.mounted) {
|
||||
this.setState({ wifiInfo: this.processDeviceResponse(result) })
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
this.mounted = false;
|
||||
}
|
||||
|
||||
render() {
|
||||
let inner;
|
||||
|
||||
if (this.state.wifiInfo.length > 0) {
|
||||
inner = (
|
||||
<View style={styles.listContainer}>
|
||||
<ScrollView style={{centerContent: true, paddingTop: 20}}>
|
||||
{this.state.wifiInfo.map(e => (
|
||||
<WifiListElement
|
||||
text={e.ssid}
|
||||
strength={e.strength}
|
||||
lock={e.locked}
|
||||
key={e.ssid}
|
||||
onPress={() => {
|
||||
this.props.navigation.navigate("WifiPasswordView", {
|
||||
ssid: e.ssid,
|
||||
lock: e.locked,
|
||||
strength: e.strength,
|
||||
confirmPwInput: false,
|
||||
buttonText: "OK",
|
||||
subText: "Please enter the password for your home WiFi",
|
||||
onSubmit: (ssid, pw) => {
|
||||
console.log("1");
|
||||
this.props.device.conn.wifiSetModeSTA(ssid, pw);
|
||||
console.log("2", this.props.deviceReportedHostname, changeSwimTrackerHostname, this.props);
|
||||
this.props.dispatch(changeSwimTrackerHostname(this.props.deviceReportedHostname));
|
||||
console.log("3");
|
||||
},
|
||||
});
|
||||
}}>
|
||||
</WifiListElement>)
|
||||
)}
|
||||
</ScrollView>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
else {
|
||||
inner = (
|
||||
<View style={{ alignItems: "center", justifyContent:"center", height: "100%" }}>
|
||||
<View style={{ paddingBottom: 20 }}><Text style={{ fontSize: 16, color: "#fff"}}>Scanning WiFi networks</Text></View>
|
||||
<ActivityIndicator size="large" color="#ffffff" />
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<SetupView
|
||||
headerText="WiFi Connection"
|
||||
lowerLeftButtonText="My WiFi wasn't found"
|
||||
onLowerLeftButtonPress={() => {
|
||||
this.props.navigation.navigate("WifiPasswordView", {
|
||||
ssid: "swimtracker-E2842S", // todo real id here
|
||||
lock: true,
|
||||
strength: 4,
|
||||
confirmPwInput: true,
|
||||
buttonText: "Set Password",
|
||||
subText: "Use this option only if you're home WiFi doesn't reach the SwimTracker. The SwimTracker creates its own WiFi with the password you set here.",
|
||||
onSubmit: (ssid, pw) => {
|
||||
this.props.device.conn.wifiSetModeAP(pw);
|
||||
},
|
||||
});
|
||||
}}
|
||||
lowerRightButtonText="Need help?"
|
||||
>
|
||||
{inner}
|
||||
</SetupView>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
listContainer: {
|
||||
height: "75%",
|
||||
flex: 1,
|
||||
}
|
||||
});
|
||||
|
||||
const mapStateToProps = (state) => {
|
||||
return { deviceReportedHostname: state.deviceState.deviceReportedHostname };
|
||||
};
|
||||
|
||||
export default connect(mapStateToProps)(WifiSelectionView);
|
||||