feat: add MeasurementDetailScreen, 365-day charts, and edit support
- New MeasurementDetailScreen with wijzigen/verwijderen - WeightChart now supports 30 and 365 days - NewMeasurementScreen now handles both create and edit - Updated README to match current code - Add build-toolchain documentation - Ignore *.apk build artifacts
This commit is contained in:
parent
c499f7df13
commit
ed714af8d1
15 changed files with 730 additions and 130 deletions
6
.gitignore
vendored
6
.gitignore
vendored
|
|
@ -111,6 +111,12 @@ test/fixtures/
|
||||||
test/sample-pdfs/
|
test/sample-pdfs/
|
||||||
test/output/
|
test/output/
|
||||||
|
|
||||||
|
# Expo
|
||||||
|
.expo/
|
||||||
|
|
||||||
|
# APK builds
|
||||||
|
*.apk
|
||||||
|
|
||||||
# Temporary files
|
# Temporary files
|
||||||
temp/
|
temp/
|
||||||
tmp/
|
tmp/
|
||||||
|
|
|
||||||
17
App.tsx
17
App.tsx
|
|
@ -5,8 +5,18 @@ import DashboardScreen from './src/screens/DashboardScreen'
|
||||||
import NewMeasurementScreen from './src/screens/NewMeasurementScreen'
|
import NewMeasurementScreen from './src/screens/NewMeasurementScreen'
|
||||||
import HistoryScreen from './src/screens/HistoryScreen'
|
import HistoryScreen from './src/screens/HistoryScreen'
|
||||||
import SettingsScreen from './src/screens/SettingsScreen'
|
import SettingsScreen from './src/screens/SettingsScreen'
|
||||||
|
import MeasurementDetailScreen from './src/screens/MeasurementDetailScreen'
|
||||||
|
import { Measurement } from './src/types'
|
||||||
|
|
||||||
const Stack = createNativeStackNavigator()
|
type RootStackParamList = {
|
||||||
|
Dashboard: undefined
|
||||||
|
NewMeasurement: { measurement?: Measurement }
|
||||||
|
History: undefined
|
||||||
|
MeasurementDetail: { measurementId: string }
|
||||||
|
Settings: undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
const Stack = createNativeStackNavigator<RootStackParamList>()
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
return (
|
return (
|
||||||
|
|
@ -27,6 +37,11 @@ export default function App() {
|
||||||
component={HistoryScreen}
|
component={HistoryScreen}
|
||||||
options={{ title: 'Historie' }}
|
options={{ title: 'Historie' }}
|
||||||
/>
|
/>
|
||||||
|
<Stack.Screen
|
||||||
|
name="MeasurementDetail"
|
||||||
|
component={MeasurementDetailScreen}
|
||||||
|
options={{ title: 'Meting' }}
|
||||||
|
/>
|
||||||
<Stack.Screen
|
<Stack.Screen
|
||||||
name="Settings"
|
name="Settings"
|
||||||
component={SettingsScreen}
|
component={SettingsScreen}
|
||||||
|
|
|
||||||
|
|
@ -15,10 +15,11 @@ React Native (Expo) app voor het handmatig invoeren van meetgegevens van een sli
|
||||||
|
|
||||||
## Schermen
|
## Schermen
|
||||||
|
|
||||||
- **Dashboard** — laatste meting (groene kaart) + drie losse grafieken onder elkaar (gewicht, vetmassa, spiermassa), elk met eigen y-as-schaal en 30-dagen filter gerekend vanaf de nieuwste meting
|
- **Dashboard** — laatste meting in een kaart + drie grafieken (gewicht, vetmassa, spiermassa) met eigen y-as-schaal, te schakelen tussen 30 en 365 dagen
|
||||||
- **Nieuwe meting** — datumkiezer bovenaan (standaard vandaag), formulier vooraf ingevuld met waarden van de laatste meting. Opslaan-knop altijd in beeld (buiten ScrollView). Bij opslaan wordt automatisch gesynchroniseerd met Nextcloud
|
- **Nieuwe meting / Meting wijzigen** — datumkiezer bovenaan (standaard vandaag), formulier vooraf ingevuld met waarden van de laatste meting. Ook te gebruiken om een bestaande meting te bewerken. Opslaan-knop altijd in beeld (buiten ScrollView). Bij opslaan wordt automatisch gesynchroniseerd met Nextcloud
|
||||||
|
- **Meting detail** — alle meetwaarden in een kaartweergave, met knoppen om de meting te wijzigen of te verwijderen (verwijderen roept ook sync aan)
|
||||||
- **Historie** — FlatList gesorteerd op ingevoerde datum (nieuwste bovenaan), pull-to-refresh
|
- **Historie** — FlatList gesorteerd op ingevoerde datum (nieuwste bovenaan), pull-to-refresh
|
||||||
- **Instellingen** — WebDAV configuratie (URL, gebruikersnaam, wachtwoord met oogje voor tonen/verbergen), handmatige sync, wissen met bevestigingsdialoog
|
- **Instellingen** — WebDAV configuratie (URL, gebruikersnaam, wachtwoord met oogje voor tonen/verbergen), handmatige sync, WebDAV-credentials wissen met bevestigingsdialoog
|
||||||
|
|
||||||
## Ontwikkeling
|
## Ontwikkeling
|
||||||
|
|
||||||
|
|
|
||||||
13
app.json
13
app.json
|
|
@ -26,6 +26,17 @@
|
||||||
"expo-sqlite",
|
"expo-sqlite",
|
||||||
"expo-secure-store",
|
"expo-secure-store",
|
||||||
"@react-native-community/datetimepicker"
|
"@react-native-community/datetimepicker"
|
||||||
]
|
],
|
||||||
|
"extra": {
|
||||||
|
"eas": {
|
||||||
|
"projectId": "9707c719-d5aa-45a4-a6cd-b95ac3d32996"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"runtimeVersion": {
|
||||||
|
"policy": "appVersion"
|
||||||
|
},
|
||||||
|
"updates": {
|
||||||
|
"url": "https://u.expo.dev/9707c719-d5aa-45a4-a6cd-b95ac3d32996"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
106
docs/build-toolchain.md
Normal file
106
docs/build-toolchain.md
Normal file
|
|
@ -0,0 +1,106 @@
|
||||||
|
# Lokale APK build toolchain
|
||||||
|
|
||||||
|
## Overzicht
|
||||||
|
|
||||||
|
De Weegschaal app kan lokaal gebouwd worden tot een zelfstandige APK
|
||||||
|
zonder EAS cloud. Dit is handig voor snelle iteraties of als je geen
|
||||||
|
internetverbinding hebt voor de cloud build.
|
||||||
|
|
||||||
|
## Benodigdheden
|
||||||
|
|
||||||
|
| Component | Versie | Installatie |
|
||||||
|
|---|---|---|
|
||||||
|
| Java | OpenJDK 21+ | `brew install --cask temurin` |
|
||||||
|
| Android Command Line Tools | laatste | `brew install --cask android-commandlinetools` |
|
||||||
|
| Android Platform Tools | laatste | `brew install --cask android-platform-tools` |
|
||||||
|
| Node.js | 22+ | `brew install node` |
|
||||||
|
| EAS CLI | laatste | via npm (`npx eas`) |
|
||||||
|
|
||||||
|
## Android SDK instellen
|
||||||
|
|
||||||
|
`ANDROID_HOME` moet gezet zijn in je shell config (`~/.zshrc`):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export ANDROID_HOME="/home/linuxbrew/.linuxbrew/share/android-commandlinetools"
|
||||||
|
export PATH="$ANDROID_HOME/cmdline-tools/latest/bin:$ANDROID_HOME/platform-tools:$PATH"
|
||||||
|
```
|
||||||
|
|
||||||
|
Licenties accepteren:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
yes | $ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager --sdk_root=$ANDROID_HOME --licenses
|
||||||
|
```
|
||||||
|
|
||||||
|
Best practices:
|
||||||
|
- Gebruik `--sdk_root=$ANDROID_HOME` bij sdkmanager commando's, anders
|
||||||
|
installeert hij naar de verkeerde plek
|
||||||
|
- De eerste build downloadt automatisch de juiste platform-, build-tools-,
|
||||||
|
NDK- en CMake-versies. Je hoeft ze niet handmatig voor te installeren.
|
||||||
|
- Wil je ze toch vastzetten: `sdkmanager "platforms;android-36" "build-tools;36.0.0"`
|
||||||
|
|
||||||
|
## APK bouwen
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /home/eddy/bin/weegschaal-app
|
||||||
|
npx eas build --local --profile apk --platform android
|
||||||
|
```
|
||||||
|
|
||||||
|
Het build profiel staat in `eas.json`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
"apk": {
|
||||||
|
"android": {
|
||||||
|
"buildType": "apk"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
De build:
|
||||||
|
1. Draait `npx expo prebuild` (maakt `android/` directory aan)
|
||||||
|
2. Bundelt de JS bundle via Metro
|
||||||
|
3. Voert `./gradlew :app:assembleRelease` uit
|
||||||
|
4. Produceert `android/app/build/outputs/apk/release/app-release.apk`
|
||||||
|
5. Kopieert het resultaat naar `build-<timestamp>.apk` in de projectroot
|
||||||
|
|
||||||
|
## Duur
|
||||||
|
|
||||||
|
- **Eerste build**: ~13 minuten (Gradle download, NDK/CMake compilatie)
|
||||||
|
- **Latere builds**: ~2-3 minuten (Gradle daemon werkt door)
|
||||||
|
|
||||||
|
## Veelvoorkomende problemen
|
||||||
|
|
||||||
|
**`ANDROID_HOME` niet gezet**
|
||||||
|
```
|
||||||
|
Error: ANDROID_HOME is not set
|
||||||
|
```
|
||||||
|
→ Controleer of de export in `~/.zshrc` staat en herlaad met `source ~/.zshrc`
|
||||||
|
|
||||||
|
**Gradle fault / OutOfMemoryError**
|
||||||
|
```
|
||||||
|
The daemon will expire after running out of JVM Metaspace
|
||||||
|
```
|
||||||
|
→ Zet in `android/gradle.properties`:
|
||||||
|
```
|
||||||
|
org.gradle.jvmargs=-Xmx4g -XX:MaxMetaspaceSize=1g
|
||||||
|
```
|
||||||
|
|
||||||
|
**Licenties niet geaccepteerd**
|
||||||
|
→ Voer `yes | sdkmanager --licenses` uit
|
||||||
|
|
||||||
|
## Testen
|
||||||
|
|
||||||
|
Voor het bouwen kun je de tests draaien:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /home/eddy/bin/weegschaal-app
|
||||||
|
npm test
|
||||||
|
```
|
||||||
|
|
||||||
|
## Installatie op Android
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Via ADB (als je toestel aan de USB hangt met debugging aan)
|
||||||
|
adb install -r weegschaal.apk
|
||||||
|
|
||||||
|
# Of handmatig: kopieer APK naar toestel, open bestand, kies 'installeren'
|
||||||
|
```
|
||||||
312
package-lock.json
generated
312
package-lock.json
generated
|
|
@ -15,12 +15,15 @@
|
||||||
"expo-secure-store": "~15.0.8",
|
"expo-secure-store": "~15.0.8",
|
||||||
"expo-sqlite": "~16.0.10",
|
"expo-sqlite": "~16.0.10",
|
||||||
"expo-status-bar": "~3.0.9",
|
"expo-status-bar": "~3.0.9",
|
||||||
|
"expo-updates": "~29.0.18",
|
||||||
"react": "19.1.0",
|
"react": "19.1.0",
|
||||||
|
"react-dom": "19.1.0",
|
||||||
"react-native": "0.81.5",
|
"react-native": "0.81.5",
|
||||||
"react-native-chart-kit": "^7.0.1",
|
"react-native-chart-kit": "^7.0.1",
|
||||||
"react-native-safe-area-context": "~5.6.0",
|
"react-native-safe-area-context": "~5.6.0",
|
||||||
"react-native-screens": "~4.16.0",
|
"react-native-screens": "~4.16.0",
|
||||||
"react-native-svg": "15.12.1"
|
"react-native-svg": "15.12.1",
|
||||||
|
"react-native-web": "^0.21.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/jest": "29.5.14",
|
"@types/jest": "29.5.14",
|
||||||
|
|
@ -1734,25 +1737,6 @@
|
||||||
"semver": "^7.6.0"
|
"semver": "^7.6.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@expo/image-utils/node_modules/@expo/require-utils": {
|
|
||||||
"version": "55.0.5",
|
|
||||||
"resolved": "https://registry.npmjs.org/@expo/require-utils/-/require-utils-55.0.5.tgz",
|
|
||||||
"integrity": "sha512-U4K/CQ2VpXuwfNGsN+daKmYOt15hCP8v/pXaYH6eut7kdYZo6SfJ1yr67BIcJ+1Gzzs+QzTxswAZChKpXmceyw==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@babel/code-frame": "^7.20.0",
|
|
||||||
"@babel/core": "^7.25.2",
|
|
||||||
"@babel/plugin-transform-modules-commonjs": "^7.24.8"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"typescript": "^5.0.0 || ^5.0.0-0"
|
|
||||||
},
|
|
||||||
"peerDependenciesMeta": {
|
|
||||||
"typescript": {
|
|
||||||
"optional": true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@expo/json-file": {
|
"node_modules/@expo/json-file": {
|
||||||
"version": "10.2.0",
|
"version": "10.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/@expo/json-file/-/json-file-10.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/@expo/json-file/-/json-file-10.2.0.tgz",
|
||||||
|
|
@ -2161,6 +2145,25 @@
|
||||||
"xmlbuilder": "^15.1.1"
|
"xmlbuilder": "^15.1.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@expo/require-utils": {
|
||||||
|
"version": "55.0.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@expo/require-utils/-/require-utils-55.0.5.tgz",
|
||||||
|
"integrity": "sha512-U4K/CQ2VpXuwfNGsN+daKmYOt15hCP8v/pXaYH6eut7kdYZo6SfJ1yr67BIcJ+1Gzzs+QzTxswAZChKpXmceyw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@babel/code-frame": "^7.20.0",
|
||||||
|
"@babel/core": "^7.25.2",
|
||||||
|
"@babel/plugin-transform-modules-commonjs": "^7.24.8"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"typescript": "^5.0.0 || ^5.0.0-0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"typescript": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@expo/schema-utils": {
|
"node_modules/@expo/schema-utils": {
|
||||||
"version": "0.1.8",
|
"version": "0.1.8",
|
||||||
"resolved": "https://registry.npmjs.org/@expo/schema-utils/-/schema-utils-0.1.8.tgz",
|
"resolved": "https://registry.npmjs.org/@expo/schema-utils/-/schema-utils-0.1.8.tgz",
|
||||||
|
|
@ -4326,6 +4329,57 @@
|
||||||
"node": "^14.15.0 || ^16.10.0 || >=18.0.0"
|
"node": "^14.15.0 || ^16.10.0 || >=18.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/cross-fetch": {
|
||||||
|
"version": "3.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-3.2.0.tgz",
|
||||||
|
"integrity": "sha512-Q+xVJLoGOeIMXZmbUK4HYk+69cQH6LudR0Vu/pRm2YlU/hDV9CiS0gKUMaWY5f2NeUH9C1nV3bsTlCo0FsTV1Q==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"node-fetch": "^2.7.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/cross-fetch/node_modules/node-fetch": {
|
||||||
|
"version": "2.7.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz",
|
||||||
|
"integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"whatwg-url": "^5.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "4.x || >=6.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"encoding": "^0.1.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"encoding": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/cross-fetch/node_modules/tr46": {
|
||||||
|
"version": "0.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
|
||||||
|
"integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/cross-fetch/node_modules/webidl-conversions": {
|
||||||
|
"version": "3.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
|
||||||
|
"integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==",
|
||||||
|
"license": "BSD-2-Clause"
|
||||||
|
},
|
||||||
|
"node_modules/cross-fetch/node_modules/whatwg-url": {
|
||||||
|
"version": "5.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz",
|
||||||
|
"integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"tr46": "~0.0.3",
|
||||||
|
"webidl-conversions": "^3.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/cross-spawn": {
|
"node_modules/cross-spawn": {
|
||||||
"version": "7.0.6",
|
"version": "7.0.6",
|
||||||
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
||||||
|
|
@ -4340,6 +4394,15 @@
|
||||||
"node": ">= 8"
|
"node": ">= 8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/css-in-js-utils": {
|
||||||
|
"version": "3.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/css-in-js-utils/-/css-in-js-utils-3.1.0.tgz",
|
||||||
|
"integrity": "sha512-fJAcud6B3rRu+KHYk+Bwf+WFL2MDCJJ1XG9x137tJQ0xYxor7XziQtuGFbWNdqrvF4Tk26O3H73nfVqXt/fW1A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"hyphenate-style-name": "^1.0.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/css-select": {
|
"node_modules/css-select": {
|
||||||
"version": "5.2.2",
|
"version": "5.2.2",
|
||||||
"resolved": "https://registry.npmjs.org/css-select/-/css-select-5.2.2.tgz",
|
"resolved": "https://registry.npmjs.org/css-select/-/css-select-5.2.2.tgz",
|
||||||
|
|
@ -5077,6 +5140,31 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/expo-eas-client": {
|
||||||
|
"version": "1.0.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/expo-eas-client/-/expo-eas-client-1.0.8.tgz",
|
||||||
|
"integrity": "sha512-5or11NJhSeDoHHI6zyvQDW2cz/yFyE+1Cz8NTs5NK8JzC7J0JrkUgptWtxyfB6Xs/21YRNifd3qgbBN3hfKVgA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/expo-json-utils": {
|
||||||
|
"version": "0.15.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/expo-json-utils/-/expo-json-utils-0.15.0.tgz",
|
||||||
|
"integrity": "sha512-duRT6oGl80IDzH2LD2yEFWNwGIC2WkozsB6HF3cDYNoNNdUvFk6uN3YiwsTsqVM/D0z6LEAQ01/SlYvN+Fw0JQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/expo-manifests": {
|
||||||
|
"version": "1.0.11",
|
||||||
|
"resolved": "https://registry.npmjs.org/expo-manifests/-/expo-manifests-1.0.11.tgz",
|
||||||
|
"integrity": "sha512-6zItytTewN37Cjhp3glUg0ozrgW2GwB8x9wtfzUNoJIMmxO38nnGdTLMaotYhRqdf5PP2Dzdmej1HDHXVNUpRw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@expo/config": "~12.0.13",
|
||||||
|
"expo-json-utils": "~0.15.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"expo": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/expo-modules-autolinking": {
|
"node_modules/expo-modules-autolinking": {
|
||||||
"version": "3.0.26",
|
"version": "3.0.26",
|
||||||
"resolved": "https://registry.npmjs.org/expo-modules-autolinking/-/expo-modules-autolinking-3.0.26.tgz",
|
"resolved": "https://registry.npmjs.org/expo-modules-autolinking/-/expo-modules-autolinking-3.0.26.tgz",
|
||||||
|
|
@ -5151,6 +5239,57 @@
|
||||||
"react-native": "*"
|
"react-native": "*"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/expo-structured-headers": {
|
||||||
|
"version": "5.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/expo-structured-headers/-/expo-structured-headers-5.0.0.tgz",
|
||||||
|
"integrity": "sha512-RmrBtnSphk5REmZGV+lcdgdpxyzio5rJw8CXviHE6qH5pKQQ83fhMEcigvrkBdsn2Efw2EODp4Yxl1/fqMvOZw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/expo-updates": {
|
||||||
|
"version": "29.0.18",
|
||||||
|
"resolved": "https://registry.npmjs.org/expo-updates/-/expo-updates-29.0.18.tgz",
|
||||||
|
"integrity": "sha512-qn0YDM7wVwghQAaxb9NYzzwrmj+v8Djz5H+Zft4/myG9SPg4ICAfDy52IVF0K+/GH/oNgsFfzmmLl6hIkDu42g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@expo/code-signing-certificates": "^0.0.6",
|
||||||
|
"@expo/plist": "^0.4.9",
|
||||||
|
"@expo/spawn-async": "^1.7.2",
|
||||||
|
"arg": "4.1.0",
|
||||||
|
"chalk": "^4.1.2",
|
||||||
|
"debug": "^4.3.4",
|
||||||
|
"expo-eas-client": "~1.0.8",
|
||||||
|
"expo-manifests": "~1.0.11",
|
||||||
|
"expo-structured-headers": "~5.0.0",
|
||||||
|
"expo-updates-interface": "~2.0.0",
|
||||||
|
"getenv": "^2.0.0",
|
||||||
|
"glob": "^13.0.0",
|
||||||
|
"ignore": "^5.3.1",
|
||||||
|
"resolve-from": "^5.0.0"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"expo-updates": "bin/cli.js"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"expo": "*",
|
||||||
|
"react": "*",
|
||||||
|
"react-native": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/expo-updates-interface": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/expo-updates-interface/-/expo-updates-interface-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-pTzAIufEZdVPKql6iMi5ylVSPqV1qbEopz9G6TSECQmnNde2nwq42PxdFBaUEd8IZJ/fdJLQnOT3m6+XJ5s7jg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"expo": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/expo-updates/node_modules/arg": {
|
||||||
|
"version": "4.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/arg/-/arg-4.1.0.tgz",
|
||||||
|
"integrity": "sha512-ZWc51jO3qegGkVh8Hwpv636EkbesNV5ZNQPCtRa+0qytRYPEs9IYT9qITY9buezqUH5uqyzlWLcufrzU2rffdg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/expo/node_modules/@expo/cli": {
|
"node_modules/expo/node_modules/@expo/cli": {
|
||||||
"version": "54.0.25",
|
"version": "54.0.25",
|
||||||
"resolved": "https://registry.npmjs.org/@expo/cli/-/cli-54.0.25.tgz",
|
"resolved": "https://registry.npmjs.org/@expo/cli/-/cli-54.0.25.tgz",
|
||||||
|
|
@ -5580,6 +5719,36 @@
|
||||||
"bser": "2.1.1"
|
"bser": "2.1.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/fbjs": {
|
||||||
|
"version": "3.0.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/fbjs/-/fbjs-3.0.5.tgz",
|
||||||
|
"integrity": "sha512-ztsSx77JBtkuMrEypfhgc3cI0+0h+svqeie7xHbh1k/IKdcydnvadp/mUaGgjAOXQmQSxsqgaRhS3q9fy+1kxg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"cross-fetch": "^3.1.5",
|
||||||
|
"fbjs-css-vars": "^1.0.0",
|
||||||
|
"loose-envify": "^1.0.0",
|
||||||
|
"object-assign": "^4.1.0",
|
||||||
|
"promise": "^7.1.1",
|
||||||
|
"setimmediate": "^1.0.5",
|
||||||
|
"ua-parser-js": "^1.0.35"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/fbjs-css-vars": {
|
||||||
|
"version": "1.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/fbjs-css-vars/-/fbjs-css-vars-1.0.2.tgz",
|
||||||
|
"integrity": "sha512-b2XGFAFdWZWg0phtAWLHCk836A1Xann+I+Dgd3Gk64MHKZO44FfoD1KxyvbSh0qZsIoXQGGlVztIY+oitJPpRQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/fbjs/node_modules/promise": {
|
||||||
|
"version": "7.3.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/promise/-/promise-7.3.1.tgz",
|
||||||
|
"integrity": "sha512-nolQXZ/4L+bP/UGlkfaIujX9BKxGwmQ9OT4mOt5yvy8iK1h3wqTEJCijzGANTCCl9nWjY41juyAn2K3Q1hLLTg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"asap": "~2.0.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/fill-range": {
|
"node_modules/fill-range": {
|
||||||
"version": "7.1.1",
|
"version": "7.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
|
||||||
|
|
@ -6056,6 +6225,12 @@
|
||||||
"node": ">=10.17.0"
|
"node": ">=10.17.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/hyphenate-style-name": {
|
||||||
|
"version": "1.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/hyphenate-style-name/-/hyphenate-style-name-1.1.0.tgz",
|
||||||
|
"integrity": "sha512-WDC/ui2VVRrz3jOVi+XtjqkDjiVjTtFaAGiW37k6b+ohyQ5wYDOGkvCZa8+H0nx3gyvv0+BST9xuOgIyGQ00gw==",
|
||||||
|
"license": "BSD-3-Clause"
|
||||||
|
},
|
||||||
"node_modules/iconv-lite": {
|
"node_modules/iconv-lite": {
|
||||||
"version": "0.6.3",
|
"version": "0.6.3",
|
||||||
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
|
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
|
||||||
|
|
@ -6166,6 +6341,15 @@
|
||||||
"integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==",
|
"integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==",
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
|
"node_modules/inline-style-prefixer": {
|
||||||
|
"version": "7.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/inline-style-prefixer/-/inline-style-prefixer-7.0.1.tgz",
|
||||||
|
"integrity": "sha512-lhYo5qNTQp3EvSSp3sRvXMbVQTLrvGV6DycRMJ5dm2BLMiJ30wpXKdDdgX+GmJZ5uQMucwRKHamXSst3Sj/Giw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"css-in-js-utils": "^3.1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/invariant": {
|
"node_modules/invariant": {
|
||||||
"version": "2.2.4",
|
"version": "2.2.4",
|
||||||
"resolved": "https://registry.npmjs.org/invariant/-/invariant-2.2.4.tgz",
|
"resolved": "https://registry.npmjs.org/invariant/-/invariant-2.2.4.tgz",
|
||||||
|
|
@ -9051,6 +9235,12 @@
|
||||||
"node": "^10 || ^12 || >=14"
|
"node": "^10 || ^12 || >=14"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/postcss-value-parser": {
|
||||||
|
"version": "4.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz",
|
||||||
|
"integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/pretty-bytes": {
|
"node_modules/pretty-bytes": {
|
||||||
"version": "5.6.0",
|
"version": "5.6.0",
|
||||||
"resolved": "https://registry.npmjs.org/pretty-bytes/-/pretty-bytes-5.6.0.tgz",
|
"resolved": "https://registry.npmjs.org/pretty-bytes/-/pretty-bytes-5.6.0.tgz",
|
||||||
|
|
@ -9263,6 +9453,18 @@
|
||||||
"ws": "^7"
|
"ws": "^7"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/react-dom": {
|
||||||
|
"version": "19.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.0.tgz",
|
||||||
|
"integrity": "sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"scheduler": "^0.26.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^19.1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/react-freeze": {
|
"node_modules/react-freeze": {
|
||||||
"version": "1.0.4",
|
"version": "1.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/react-freeze/-/react-freeze-1.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/react-freeze/-/react-freeze-1.0.4.tgz",
|
||||||
|
|
@ -9409,6 +9611,38 @@
|
||||||
"react-native": "*"
|
"react-native": "*"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/react-native-web": {
|
||||||
|
"version": "0.21.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-native-web/-/react-native-web-0.21.2.tgz",
|
||||||
|
"integrity": "sha512-SO2t9/17zM4iEnFvlu2DA9jqNbzNhoUP+AItkoCOyFmDMOhUnBBznBDCYN92fGdfAkfQlWzPoez6+zLxFNsZEg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@babel/runtime": "^7.18.6",
|
||||||
|
"@react-native/normalize-colors": "^0.74.1",
|
||||||
|
"fbjs": "^3.0.4",
|
||||||
|
"inline-style-prefixer": "^7.0.1",
|
||||||
|
"memoize-one": "^6.0.0",
|
||||||
|
"nullthrows": "^1.1.1",
|
||||||
|
"postcss-value-parser": "^4.2.0",
|
||||||
|
"styleq": "^0.1.3"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^18.0.0 || ^19.0.0",
|
||||||
|
"react-dom": "^18.0.0 || ^19.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/react-native-web/node_modules/@react-native/normalize-colors": {
|
||||||
|
"version": "0.74.89",
|
||||||
|
"resolved": "https://registry.npmjs.org/@react-native/normalize-colors/-/normalize-colors-0.74.89.tgz",
|
||||||
|
"integrity": "sha512-qoMMXddVKVhZ8PA1AbUCk83trpd6N+1nF2A6k1i6LsQObyS92fELuk8kU/lQs6M7BsMHwqyLCpQJ1uFgNvIQXg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/react-native-web/node_modules/memoize-one": {
|
||||||
|
"version": "6.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-6.0.0.tgz",
|
||||||
|
"integrity": "sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/react-native/node_modules/@react-native/js-polyfills": {
|
"node_modules/react-native/node_modules/@react-native/js-polyfills": {
|
||||||
"version": "0.81.5",
|
"version": "0.81.5",
|
||||||
"resolved": "https://registry.npmjs.org/@react-native/js-polyfills/-/js-polyfills-0.81.5.tgz",
|
"resolved": "https://registry.npmjs.org/@react-native/js-polyfills/-/js-polyfills-0.81.5.tgz",
|
||||||
|
|
@ -9957,6 +10191,12 @@
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/setimmediate": {
|
||||||
|
"version": "1.0.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz",
|
||||||
|
"integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/setprototypeof": {
|
"node_modules/setprototypeof": {
|
||||||
"version": "1.2.0",
|
"version": "1.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
|
||||||
|
|
@ -10304,6 +10544,12 @@
|
||||||
"integrity": "sha512-0MP/Cxx5SzeeZ10p/bZI0S6MpgD+yxAhi1BOQ34jgnMXsCq3j1t6tQnZu+KdlL7dvJTLT3g9xN8tl10TqgFMcg==",
|
"integrity": "sha512-0MP/Cxx5SzeeZ10p/bZI0S6MpgD+yxAhi1BOQ34jgnMXsCq3j1t6tQnZu+KdlL7dvJTLT3g9xN8tl10TqgFMcg==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/styleq": {
|
||||||
|
"version": "0.1.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/styleq/-/styleq-0.1.3.tgz",
|
||||||
|
"integrity": "sha512-3ZUifmCDCQanjeej1f6kyl/BeP/Vae5EYkQ9iJfUm/QwZvlgnZzyflqAsAWYURdtea8Vkvswu2GrC57h3qffcA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/sucrase": {
|
"node_modules/sucrase": {
|
||||||
"version": "3.35.1",
|
"version": "3.35.1",
|
||||||
"resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz",
|
"resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz",
|
||||||
|
|
@ -10673,6 +10919,32 @@
|
||||||
"node": ">=14.17"
|
"node": ">=14.17"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/ua-parser-js": {
|
||||||
|
"version": "1.0.41",
|
||||||
|
"resolved": "https://registry.npmjs.org/ua-parser-js/-/ua-parser-js-1.0.41.tgz",
|
||||||
|
"integrity": "sha512-LbBDqdIC5s8iROCUjMbW1f5dJQTEFB1+KO9ogbvlb3nm9n4YHa5p4KTvFPWvh2Hs8gZMBuiB1/8+pdfe/tDPug==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/ua-parser-js"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "paypal",
|
||||||
|
"url": "https://paypal.me/faisalman"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/faisalman"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"bin": {
|
||||||
|
"ua-parser-js": "script/cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/undici": {
|
"node_modules/undici": {
|
||||||
"version": "6.27.0",
|
"version": "6.27.0",
|
||||||
"resolved": "https://registry.npmjs.org/undici/-/undici-6.27.0.tgz",
|
"resolved": "https://registry.npmjs.org/undici/-/undici-6.27.0.tgz",
|
||||||
|
|
|
||||||
|
|
@ -10,12 +10,15 @@
|
||||||
"expo-secure-store": "~15.0.8",
|
"expo-secure-store": "~15.0.8",
|
||||||
"expo-sqlite": "~16.0.10",
|
"expo-sqlite": "~16.0.10",
|
||||||
"expo-status-bar": "~3.0.9",
|
"expo-status-bar": "~3.0.9",
|
||||||
|
"expo-updates": "~29.0.18",
|
||||||
"react": "19.1.0",
|
"react": "19.1.0",
|
||||||
|
"react-dom": "19.1.0",
|
||||||
"react-native": "0.81.5",
|
"react-native": "0.81.5",
|
||||||
"react-native-chart-kit": "^7.0.1",
|
"react-native-chart-kit": "^7.0.1",
|
||||||
"react-native-safe-area-context": "~5.6.0",
|
"react-native-safe-area-context": "~5.6.0",
|
||||||
"react-native-screens": "~4.16.0",
|
"react-native-screens": "~4.16.0",
|
||||||
"react-native-svg": "15.12.1"
|
"react-native-svg": "15.12.1",
|
||||||
|
"react-native-web": "^0.21.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/jest": "29.5.14",
|
"@types/jest": "29.5.14",
|
||||||
|
|
|
||||||
|
|
@ -47,8 +47,8 @@ const styles = StyleSheet.create({
|
||||||
card: { backgroundColor: '#fff', borderRadius: 12, padding: 16, margin: 16, shadowColor: '#000', shadowOpacity: 0.1, shadowRadius: 8, elevation: 3 },
|
card: { backgroundColor: '#fff', borderRadius: 12, padding: 16, margin: 16, shadowColor: '#000', shadowOpacity: 0.1, shadowRadius: 8, elevation: 3 },
|
||||||
title: { fontSize: 18, fontWeight: '700', marginBottom: 4 },
|
title: { fontSize: 18, fontWeight: '700', marginBottom: 4 },
|
||||||
date: { fontSize: 14, color: '#666', marginBottom: 12 },
|
date: { fontSize: 14, color: '#666', marginBottom: 12 },
|
||||||
row: { flexDirection: 'row', justifyContent: 'space-between', paddingVertical: 4 },
|
row: { flexDirection: 'row', paddingVertical: 4 },
|
||||||
rowLabel: { fontSize: 14, color: '#555' },
|
rowLabel: { fontSize: 14, color: '#555', flex: 1 },
|
||||||
rowValue: { fontSize: 14, fontWeight: '600' },
|
rowValue: { fontSize: 14, fontWeight: '600' },
|
||||||
emptyText: { fontSize: 16, color: '#999', textAlign: 'center', padding: 20 },
|
emptyText: { fontSize: 16, color: '#999', textAlign: 'center', padding: 20 },
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ import { generateId } from '../utils/uuid'
|
||||||
type Props = {
|
type Props = {
|
||||||
onSubmit: (m: Measurement) => void
|
onSubmit: (m: Measurement) => void
|
||||||
initialValues?: Partial<Measurement>
|
initialValues?: Partial<Measurement>
|
||||||
|
isEdit?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
type FieldDef = {
|
type FieldDef = {
|
||||||
|
|
@ -29,13 +30,10 @@ const fields: FieldDef[] = [
|
||||||
{ key: 'bmr', label: 'Stofwisseling (kcal)', placeholder: '1650', keyboardType: 'number-pad' },
|
{ key: 'bmr', label: 'Stofwisseling (kcal)', placeholder: '1650', keyboardType: 'number-pad' },
|
||||||
]
|
]
|
||||||
|
|
||||||
export default function MeasurementForm({ onSubmit, initialValues }: Props) {
|
export default function MeasurementForm({ onSubmit, initialValues, isEdit }: Props) {
|
||||||
const insets = useSafeAreaInsets()
|
const insets = useSafeAreaInsets()
|
||||||
const [pickerOpen, setPickerOpen] = useState(false)
|
const [pickerOpen, setPickerOpen] = useState(false)
|
||||||
const [measurementDate, setMeasurementDate] = useState<Date>(() => {
|
const [measurementDate, setMeasurementDate] = useState<Date>(new Date())
|
||||||
if (initialValues?.measuredAt) return new Date(initialValues.measuredAt)
|
|
||||||
return new Date()
|
|
||||||
})
|
|
||||||
const [values, setValues] = useState<Record<string, string>>(() => {
|
const [values, setValues] = useState<Record<string, string>>(() => {
|
||||||
const init: Record<string, string> = {}
|
const init: Record<string, string> = {}
|
||||||
for (const f of fields) {
|
for (const f of fields) {
|
||||||
|
|
@ -47,9 +45,18 @@ export default function MeasurementForm({ onSubmit, initialValues }: Props) {
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
const weight = parseFloat(values.weight) || 0
|
function toFloat(s: string): number {
|
||||||
const bodyFatPct = parseFloat(values.bodyFatPercentage) || 0
|
return parseFloat(s.replace(',', '.')) || 0
|
||||||
const musclePct = parseFloat(values.musclePercentage) || 0
|
}
|
||||||
|
|
||||||
|
function toInt(s: string): number {
|
||||||
|
return parseInt(s.replace(',', '.'), 10) || 0
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
const weight = toFloat(values.weight)
|
||||||
|
const bodyFatPct = toFloat(values.bodyFatPercentage)
|
||||||
|
const musclePct = toFloat(values.musclePercentage)
|
||||||
|
|
||||||
const computed = useMemo(() => ({
|
const computed = useMemo(() => ({
|
||||||
fatMass: weight > 0 && bodyFatPct > 0 ? ((weight * bodyFatPct) / 100).toFixed(1) : '',
|
fatMass: weight > 0 && bodyFatPct > 0 ? ((weight * bodyFatPct) / 100).toFixed(1) : '',
|
||||||
|
|
@ -59,17 +66,17 @@ export default function MeasurementForm({ onSubmit, initialValues }: Props) {
|
||||||
const handleSubmit = () => {
|
const handleSubmit = () => {
|
||||||
const now = new Date().toISOString()
|
const now = new Date().toISOString()
|
||||||
const m: Measurement = {
|
const m: Measurement = {
|
||||||
id: generateId(),
|
id: isEdit && initialValues?.id ? initialValues.id : generateId(),
|
||||||
weight,
|
weight,
|
||||||
bmi: parseFloat(values.bmi) || 0,
|
bmi: toFloat(values.bmi),
|
||||||
bodyFatPercentage: bodyFatPct,
|
bodyFatPercentage: bodyFatPct,
|
||||||
fatMass: parseFloat(computed.fatMass) || 0,
|
fatMass: toFloat(computed.fatMass),
|
||||||
musclePercentage: musclePct,
|
musclePercentage: musclePct,
|
||||||
muscleMass: parseFloat(computed.muscleMass) || 0,
|
muscleMass: toFloat(computed.muscleMass),
|
||||||
visceralFat: parseInt(values.visceralFat, 10) || 0,
|
visceralFat: toInt(values.visceralFat),
|
||||||
bmr: parseFloat(values.bmr) || 0,
|
bmr: toFloat(values.bmr),
|
||||||
measuredAt: measurementDate.toISOString(),
|
measuredAt: measurementDate.toISOString(),
|
||||||
createdAt: now,
|
createdAt: isEdit && initialValues?.createdAt ? initialValues.createdAt : now,
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
syncStatus: 'local',
|
syncStatus: 'local',
|
||||||
}
|
}
|
||||||
|
|
@ -140,7 +147,7 @@ export default function MeasurementForm({ onSubmit, initialValues }: Props) {
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
<TouchableOpacity style={[styles.button, { marginBottom: insets.bottom + 16 }]} onPress={handleSubmit}>
|
<TouchableOpacity style={[styles.button, { marginBottom: insets.bottom + 48 }]} onPress={handleSubmit}>
|
||||||
<Text style={styles.buttonText}>Opslaan</Text>
|
<Text style={styles.buttonText}>Opslaan</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</KeyboardAvoidingView>
|
</KeyboardAvoidingView>
|
||||||
|
|
@ -149,7 +156,7 @@ export default function MeasurementForm({ onSubmit, initialValues }: Props) {
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
container: { flex: 1, backgroundColor: '#f5f5f5' },
|
container: { flex: 1, backgroundColor: '#f5f5f5' },
|
||||||
scroll: { padding: 16, paddingBottom: 0 },
|
scroll: { padding: 16 },
|
||||||
field: { marginBottom: 12 },
|
field: { marginBottom: 12 },
|
||||||
label: { fontSize: 14, fontWeight: '600', color: '#333', marginBottom: 4 },
|
label: { fontSize: 14, fontWeight: '600', color: '#333', marginBottom: 4 },
|
||||||
input: { backgroundColor: '#fff', borderRadius: 8, padding: 12, fontSize: 16, borderWidth: 1, borderColor: '#ddd' },
|
input: { backgroundColor: '#fff', borderRadius: 8, padding: 12, fontSize: 16, borderWidth: 1, borderColor: '#ddd' },
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import React from 'react'
|
import React, { useRef, useState } from 'react'
|
||||||
import { View, Text, StyleSheet, Dimensions } from 'react-native'
|
import { View, Text, StyleSheet, Dimensions, ScrollView, NativeSyntheticEvent, NativeScrollEvent } from 'react-native'
|
||||||
import { LineChart } from 'react-native-chart-kit'
|
import { LineChart } from 'react-native-chart-kit'
|
||||||
import { Measurement } from '../types'
|
import { Measurement } from '../types'
|
||||||
|
|
||||||
|
|
@ -11,6 +11,9 @@ const COLORS = {
|
||||||
muscleMass: '#2196F3',
|
muscleMass: '#2196F3',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const screenWidth = Dimensions.get('window').width
|
||||||
|
const chartWidth = screenWidth - 32
|
||||||
|
|
||||||
function SingleChart({
|
function SingleChart({
|
||||||
title,
|
title,
|
||||||
data,
|
data,
|
||||||
|
|
@ -29,7 +32,7 @@ function SingleChart({
|
||||||
<Text style={styles.chartTitle}>{title}</Text>
|
<Text style={styles.chartTitle}>{title}</Text>
|
||||||
<LineChart
|
<LineChart
|
||||||
data={{ labels, datasets: [{ data, color: () => color, strokeWidth: 2 }] }}
|
data={{ labels, datasets: [{ data, color: () => color, strokeWidth: 2 }] }}
|
||||||
width={Dimensions.get('window').width - 32}
|
width={chartWidth}
|
||||||
height={180}
|
height={180}
|
||||||
yAxisSuffix={unit}
|
yAxisSuffix={unit}
|
||||||
chartConfig={{
|
chartConfig={{
|
||||||
|
|
@ -57,7 +60,56 @@ function hexToRgb(hex: string): string {
|
||||||
return `${r}, ${g}, ${b}`
|
return `${r}, ${g}, ${b}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function filterData(sorted: Measurement[], days: number, lastDate: Date) {
|
||||||
|
const cutoff = new Date(lastDate)
|
||||||
|
cutoff.setDate(cutoff.getDate() - days)
|
||||||
|
const filtered = sorted.filter(m => new Date(m.measuredAt) >= cutoff)
|
||||||
|
return filtered.length >= 2 ? filtered : sorted
|
||||||
|
}
|
||||||
|
|
||||||
|
function prepareLabels(dataPoints: Measurement[]) {
|
||||||
|
const labels = dataPoints.map(m => {
|
||||||
|
const d = new Date(m.measuredAt)
|
||||||
|
return `${d.getDate()}/${d.getMonth() + 1}`
|
||||||
|
})
|
||||||
|
const labelStep = labels.length > 8 ? Math.ceil(labels.length / 8) : 1
|
||||||
|
return labels.map((l, i) => (i % labelStep === 0 ? l : ''))
|
||||||
|
}
|
||||||
|
|
||||||
|
function ChartSet({ dataPoints, title }: { dataPoints: Measurement[]; title: string }) {
|
||||||
|
const labels = prepareLabels(dataPoints)
|
||||||
|
return (
|
||||||
|
<View style={styles.page}>
|
||||||
|
<Text style={styles.title}>{title}</Text>
|
||||||
|
<SingleChart
|
||||||
|
title="Gewicht"
|
||||||
|
data={dataPoints.map(m => m.weight)}
|
||||||
|
labels={labels}
|
||||||
|
color={COLORS.weight}
|
||||||
|
unit=" kg"
|
||||||
|
/>
|
||||||
|
<SingleChart
|
||||||
|
title="Vetmassa"
|
||||||
|
data={dataPoints.map(m => m.fatMass)}
|
||||||
|
labels={labels}
|
||||||
|
color={COLORS.fatMass}
|
||||||
|
unit=" kg"
|
||||||
|
/>
|
||||||
|
<SingleChart
|
||||||
|
title="Spiermassa"
|
||||||
|
data={dataPoints.map(m => m.muscleMass)}
|
||||||
|
labels={labels}
|
||||||
|
color={COLORS.muscleMass}
|
||||||
|
unit=" kg"
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export default function WeightChart({ measurements }: Props) {
|
export default function WeightChart({ measurements }: Props) {
|
||||||
|
const scrollRef = useRef<ScrollView>(null)
|
||||||
|
const [page, setPage] = useState(0)
|
||||||
|
|
||||||
if (measurements.length < 2) {
|
if (measurements.length < 2) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
@ -66,52 +118,38 @@ export default function WeightChart({ measurements }: Props) {
|
||||||
(a, b) => new Date(a.measuredAt).getTime() - new Date(b.measuredAt).getTime()
|
(a, b) => new Date(a.measuredAt).getTime() - new Date(b.measuredAt).getTime()
|
||||||
)
|
)
|
||||||
|
|
||||||
const cutoff = new Date(sorted[sorted.length - 1].measuredAt)
|
const lastDate = new Date(sorted[sorted.length - 1].measuredAt)
|
||||||
cutoff.setDate(cutoff.getDate() - 30)
|
|
||||||
const filtered = sorted.filter(m => new Date(m.measuredAt) >= cutoff)
|
|
||||||
const dataPoints = filtered.length >= 2 ? filtered : sorted
|
|
||||||
|
|
||||||
const labels = dataPoints.map(m => {
|
const data30 = filterData(sorted, 30, lastDate)
|
||||||
const d = new Date(m.measuredAt)
|
const data365 = filterData(sorted, 365, lastDate)
|
||||||
return `${d.getDate()}/${d.getMonth() + 1}`
|
|
||||||
})
|
|
||||||
|
|
||||||
const labelStep = labels.length > 8 ? Math.ceil(labels.length / 8) : 1
|
const onScroll = (e: NativeSyntheticEvent<NativeScrollEvent>) => {
|
||||||
const sparseLabels = labels.map((l, i) => (i % labelStep === 0 ? l : ''))
|
const offset = e.nativeEvent.contentOffset.x
|
||||||
|
const newPage = Math.round(offset / screenWidth)
|
||||||
|
if (newPage !== page) {
|
||||||
|
setPage(newPage)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={styles.container}>
|
<View style={styles.container}>
|
||||||
<Text style={styles.title}>Voortgang (30 dagen)</Text>
|
<ScrollView
|
||||||
|
ref={scrollRef}
|
||||||
<SingleChart
|
horizontal
|
||||||
title="Gewicht"
|
pagingEnabled
|
||||||
data={dataPoints.map(m => m.weight)}
|
showsHorizontalScrollIndicator={false}
|
||||||
labels={sparseLabels}
|
onMomentumScrollEnd={onScroll}
|
||||||
color={COLORS.weight}
|
>
|
||||||
unit=" kg"
|
<ChartSet dataPoints={data30} title="Voortgang (30 dagen)" />
|
||||||
/>
|
<ChartSet dataPoints={data365} title="Voortgang (365 dagen)" />
|
||||||
|
</ScrollView>
|
||||||
<SingleChart
|
|
||||||
title="Vetmassa"
|
|
||||||
data={dataPoints.map(m => m.fatMass)}
|
|
||||||
labels={sparseLabels}
|
|
||||||
color={COLORS.fatMass}
|
|
||||||
unit=" kg"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<SingleChart
|
|
||||||
title="Spiermassa"
|
|
||||||
data={dataPoints.map(m => m.muscleMass)}
|
|
||||||
labels={sparseLabels}
|
|
||||||
color={COLORS.muscleMass}
|
|
||||||
unit=" kg"
|
|
||||||
/>
|
|
||||||
</View>
|
</View>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
container: { marginHorizontal: 16, marginBottom: 16 },
|
container: { marginBottom: 16 },
|
||||||
|
page: { width: screenWidth, paddingHorizontal: 16 },
|
||||||
title: { fontSize: 18, fontWeight: '700', marginBottom: 8 },
|
title: { fontSize: 18, fontWeight: '700', marginBottom: 8 },
|
||||||
chartBlock: { marginBottom: 12 },
|
chartBlock: { marginBottom: 12 },
|
||||||
chartTitle: { fontSize: 13, fontWeight: '600', color: '#555', marginBottom: 4, marginLeft: 4 },
|
chartTitle: { fontSize: 13, fontWeight: '600', color: '#555', marginBottom: 4, marginLeft: 4 },
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import React, { useCallback, useState } from 'react'
|
import React, { useCallback, useState } from 'react'
|
||||||
import { View, FlatList, StyleSheet, Text, RefreshControl, Alert } from 'react-native'
|
import { View, FlatList, StyleSheet, Text, RefreshControl, TouchableOpacity } from 'react-native'
|
||||||
import { useFocusEffect } from '@react-navigation/native'
|
import { useFocusEffect } from '@react-navigation/native'
|
||||||
import { initDatabase, getAllMeasurements, deleteMeasurement } from '../database/db'
|
import { initDatabase, getAllMeasurements, deleteMeasurement } from '../database/db'
|
||||||
import { Measurement } from '../types'
|
import { Measurement } from '../types'
|
||||||
|
|
@ -80,7 +80,9 @@ export default function HistoryScreen({ navigation }: Props) {
|
||||||
data={measurements}
|
data={measurements}
|
||||||
keyExtractor={item => item.id}
|
keyExtractor={item => item.id}
|
||||||
renderItem={({ item }) => (
|
renderItem={({ item }) => (
|
||||||
<MeasurementRow measurement={item} onPress={() => handlePress(item)} />
|
<TouchableOpacity onPress={() => navigation.navigate('MeasurementDetail', { measurementId: item.id })}>
|
||||||
|
<MeasurementRow measurement={item} />
|
||||||
|
</TouchableOpacity>
|
||||||
)}
|
)}
|
||||||
contentContainerStyle={measurements.length === 0 ? styles.empty : undefined}
|
contentContainerStyle={measurements.length === 0 ? styles.empty : undefined}
|
||||||
ListEmptyComponent={<Text style={styles.emptyText}>Nog geen metingen</Text>}
|
ListEmptyComponent={<Text style={styles.emptyText}>Nog geen metingen</Text>}
|
||||||
|
|
|
||||||
135
src/screens/MeasurementDetailScreen.tsx
Normal file
135
src/screens/MeasurementDetailScreen.tsx
Normal file
|
|
@ -0,0 +1,135 @@
|
||||||
|
import React, { useCallback, useState } from 'react'
|
||||||
|
import { View, Text, StyleSheet, TouchableOpacity, Alert, ScrollView } from 'react-native'
|
||||||
|
import { useFocusEffect } from '@react-navigation/native'
|
||||||
|
import { useSafeAreaInsets } from 'react-native-safe-area-context'
|
||||||
|
import { initDatabase, getAllMeasurements, deleteMeasurement } from '../database/db'
|
||||||
|
import { Measurement } from '../types'
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
navigation: any
|
||||||
|
route: {
|
||||||
|
params: {
|
||||||
|
measurementId: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows: { label: string; key: keyof Measurement; suffix: string }[] = [
|
||||||
|
{ label: 'Gewicht', key: 'weight', suffix: 'kg' },
|
||||||
|
{ label: 'BMI', key: 'bmi', suffix: '' },
|
||||||
|
{ label: 'Vetpercentage', key: 'bodyFatPercentage', suffix: '%' },
|
||||||
|
{ label: 'Vetmassa', key: 'fatMass', suffix: 'kg' },
|
||||||
|
{ label: 'Spierpercentage', key: 'musclePercentage', suffix: '%' },
|
||||||
|
{ label: 'Spiermassa', key: 'muscleMass', suffix: 'kg' },
|
||||||
|
{ label: 'Buikvet', key: 'visceralFat', suffix: '' },
|
||||||
|
{ label: 'Stofwisseling', key: 'bmr', suffix: 'kcal' },
|
||||||
|
]
|
||||||
|
|
||||||
|
export default function MeasurementDetailScreen({ navigation, route }: Props) {
|
||||||
|
const insets = useSafeAreaInsets()
|
||||||
|
const { measurementId } = route.params
|
||||||
|
const [measurement, setMeasurement] = useState<Measurement | null>(null)
|
||||||
|
|
||||||
|
useFocusEffect(
|
||||||
|
useCallback(() => {
|
||||||
|
const load = async () => {
|
||||||
|
await initDatabase()
|
||||||
|
const all = await getAllMeasurements()
|
||||||
|
const m = all.find(x => x.id === measurementId)
|
||||||
|
setMeasurement(m ?? null)
|
||||||
|
}
|
||||||
|
load()
|
||||||
|
}, [measurementId])
|
||||||
|
)
|
||||||
|
|
||||||
|
const handleEdit = () => {
|
||||||
|
navigation.navigate('NewMeasurement', { measurement })
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDelete = () => {
|
||||||
|
Alert.alert(
|
||||||
|
'Meting verwijderen?',
|
||||||
|
'Dit kan niet ongedaan worden gemaakt.',
|
||||||
|
[
|
||||||
|
{ text: 'Annuleren', style: 'cancel' },
|
||||||
|
{
|
||||||
|
text: 'Verwijderen',
|
||||||
|
style: 'destructive',
|
||||||
|
onPress: async () => {
|
||||||
|
await initDatabase()
|
||||||
|
await deleteMeasurement(measurementId)
|
||||||
|
try {
|
||||||
|
const { syncAll } = await import('../sync/syncService')
|
||||||
|
await syncAll(new Set([measurementId]))
|
||||||
|
} catch {}
|
||||||
|
navigation.goBack()
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!measurement) {
|
||||||
|
return (
|
||||||
|
<View style={styles.centered}>
|
||||||
|
<Text style={{ color: '#999' }}>Laden...</Text>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.container}>
|
||||||
|
<ScrollView contentContainerStyle={styles.content}>
|
||||||
|
<Text style={styles.date}>
|
||||||
|
{new Date(measurement.measuredAt).toLocaleDateString('nl-NL', {
|
||||||
|
weekday: 'long',
|
||||||
|
day: 'numeric',
|
||||||
|
month: 'long',
|
||||||
|
year: 'numeric',
|
||||||
|
})}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{rows.map(r => (
|
||||||
|
<View key={r.key} style={styles.row}>
|
||||||
|
<Text style={styles.label}>{r.label}</Text>
|
||||||
|
<Text style={styles.value}>
|
||||||
|
{typeof measurement[r.key] === 'number'
|
||||||
|
? (measurement[r.key] as number).toFixed(1)
|
||||||
|
: measurement[r.key]}{' '}
|
||||||
|
{r.suffix}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</ScrollView>
|
||||||
|
|
||||||
|
<View style={[styles.buttons, { paddingBottom: insets.bottom + 16 }]}>
|
||||||
|
<TouchableOpacity style={styles.editButton} onPress={handleEdit}>
|
||||||
|
<Text style={styles.buttonText}>Wijzigen</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
<TouchableOpacity style={styles.deleteButton} onPress={handleDelete}>
|
||||||
|
<Text style={styles.buttonText}>Verwijderen</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: { flex: 1, backgroundColor: '#f5f5f5' },
|
||||||
|
content: { padding: 16 },
|
||||||
|
centered: { flex: 1, justifyContent: 'center', alignItems: 'center', backgroundColor: '#f5f5f5' },
|
||||||
|
date: { fontSize: 18, fontWeight: '700', marginBottom: 16, textAlign: 'center' },
|
||||||
|
row: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
backgroundColor: '#fff',
|
||||||
|
padding: 14,
|
||||||
|
marginBottom: 8,
|
||||||
|
borderRadius: 8,
|
||||||
|
},
|
||||||
|
label: { fontSize: 15, color: '#555', flex: 1 },
|
||||||
|
value: { fontSize: 15, fontWeight: '600' },
|
||||||
|
buttons: { flexDirection: 'row', gap: 12, padding: 16 },
|
||||||
|
editButton: { flex: 1, backgroundColor: '#2196F3', padding: 14, borderRadius: 8, alignItems: 'center' },
|
||||||
|
deleteButton: { flex: 1, backgroundColor: '#e53935', padding: 14, borderRadius: 8, alignItems: 'center' },
|
||||||
|
buttonText: { color: '#fff', fontSize: 16, fontWeight: '700' },
|
||||||
|
})
|
||||||
|
|
@ -3,88 +3,74 @@ import { View, Text, Alert } from 'react-native'
|
||||||
import { useFocusEffect } from '@react-navigation/native'
|
import { useFocusEffect } from '@react-navigation/native'
|
||||||
import MeasurementForm from '../components/MeasurementForm'
|
import MeasurementForm from '../components/MeasurementForm'
|
||||||
import { Measurement } from '../types'
|
import { Measurement } from '../types'
|
||||||
import {
|
import { initDatabase, insertMeasurement, upsertMeasurement, getLatestMeasurement, getMeasurementByDate } from '../database/db'
|
||||||
initDatabase,
|
|
||||||
insertMeasurement,
|
|
||||||
getLatestMeasurement,
|
|
||||||
getMeasurementByDate,
|
|
||||||
upsertMeasurement,
|
|
||||||
} from '../database/db'
|
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
navigation: any
|
navigation: any
|
||||||
route: any
|
route: {
|
||||||
|
params: {
|
||||||
|
measurement?: Measurement
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function NewMeasurementScreen({ navigation, route }: Props) {
|
export default function NewMeasurementScreen({ navigation, route }: Props) {
|
||||||
const editing = route.params?.measurement as Measurement | undefined
|
const editMeasurement = route.params?.measurement
|
||||||
|
const isEdit = !!editMeasurement
|
||||||
const [latest, setLatest] = useState<Partial<Measurement> | undefined>(undefined)
|
const [latest, setLatest] = useState<Partial<Measurement> | undefined>(undefined)
|
||||||
const [loaded, setLoaded] = useState(false)
|
const [loaded, setLoaded] = useState(false)
|
||||||
|
|
||||||
|
React.useLayoutEffect(() => {
|
||||||
|
navigation.setOptions({ title: isEdit ? 'Meting wijzigen' : 'Nieuwe meting' })
|
||||||
|
}, [navigation, isEdit])
|
||||||
|
|
||||||
useFocusEffect(
|
useFocusEffect(
|
||||||
useCallback(() => {
|
useCallback(() => {
|
||||||
|
if (isEdit) {
|
||||||
|
setLatest(editMeasurement)
|
||||||
|
setLoaded(true)
|
||||||
|
return
|
||||||
|
}
|
||||||
let cancelled = false
|
let cancelled = false
|
||||||
const load = async () => {
|
const load = async () => {
|
||||||
await initDatabase()
|
await initDatabase()
|
||||||
if (editing) {
|
const m = await getLatestMeasurement()
|
||||||
if (!cancelled) {
|
if (!cancelled) {
|
||||||
setLatest(editing)
|
setLatest(m || {})
|
||||||
setLoaded(true)
|
setLoaded(true)
|
||||||
}
|
|
||||||
} else {
|
|
||||||
const m = await getLatestMeasurement()
|
|
||||||
if (!cancelled) {
|
|
||||||
setLatest(m || {})
|
|
||||||
setLoaded(true)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
load()
|
load()
|
||||||
return () => { cancelled = true }
|
return () => { cancelled = true }
|
||||||
}, [editing])
|
}, [isEdit, editMeasurement])
|
||||||
)
|
)
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (editing) {
|
|
||||||
navigation.setOptions({ title: 'Meting wijzigen' })
|
|
||||||
}
|
|
||||||
}, [editing, navigation])
|
|
||||||
|
|
||||||
const handleSubmit = async (m: Measurement) => {
|
const handleSubmit = async (m: Measurement) => {
|
||||||
await initDatabase()
|
await initDatabase()
|
||||||
if (editing) {
|
if (isEdit) {
|
||||||
m.id = editing.id
|
|
||||||
m.createdAt = editing.createdAt
|
|
||||||
await upsertMeasurement(m)
|
await upsertMeasurement(m)
|
||||||
doSync()
|
} else {
|
||||||
return
|
const existing = await getMeasurementByDate(m.measuredAt)
|
||||||
}
|
if (existing) {
|
||||||
const existing = await getMeasurementByDate(m.measuredAt)
|
Alert.alert(
|
||||||
if (existing) {
|
'Dubbele datum',
|
||||||
Alert.alert(
|
`Er bestaat al een meting van ${new Date(m.measuredAt).toLocaleDateString('nl-NL')}. Overschrijven?`,
|
||||||
'Dubbele datum',
|
[
|
||||||
`Er bestaat al een meting van ${new Date(m.measuredAt).toLocaleDateString('nl-NL')}. Overschrijven?`,
|
{ text: 'Annuleren', style: 'cancel' },
|
||||||
[
|
{ text: 'Overschrijven', style: 'destructive', onPress: () => {
|
||||||
{ text: 'Annuleren', style: 'cancel' },
|
m.id = existing.id
|
||||||
{ text: 'Overschrijven', style: 'destructive', onPress: () => doSave(m, existing.id) },
|
doSave(m)
|
||||||
]
|
}},
|
||||||
)
|
]
|
||||||
return
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
await insertMeasurement(m)
|
||||||
}
|
}
|
||||||
doSave(m)
|
doSave(m)
|
||||||
}
|
}
|
||||||
|
|
||||||
const doSave = async (m: Measurement, existingId?: string) => {
|
const doSave = async (m: Measurement) => {
|
||||||
if (existingId) {
|
|
||||||
m.id = existingId
|
|
||||||
await upsertMeasurement(m)
|
|
||||||
} else {
|
|
||||||
await insertMeasurement(m)
|
|
||||||
}
|
|
||||||
doSync()
|
|
||||||
}
|
|
||||||
|
|
||||||
const doSync = async () => {
|
|
||||||
try {
|
try {
|
||||||
const { syncAll } = await import('../sync/syncService')
|
const { syncAll } = await import('../sync/syncService')
|
||||||
await syncAll()
|
await syncAll()
|
||||||
|
|
@ -110,6 +96,7 @@ export default function NewMeasurementScreen({ navigation, route }: Props) {
|
||||||
<MeasurementForm
|
<MeasurementForm
|
||||||
onSubmit={handleSubmit}
|
onSubmit={handleSubmit}
|
||||||
initialValues={latest as Partial<Measurement> | undefined}
|
initialValues={latest as Partial<Measurement> | undefined}
|
||||||
|
isEdit={isEdit}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,14 +6,16 @@ const WEBDAV_PATH = 'weegschaal/measurements.json'
|
||||||
|
|
||||||
type SyncResult = { local: number; remote: number; merged: number }
|
type SyncResult = { local: number; remote: number; merged: number }
|
||||||
|
|
||||||
export function mergeMeasurements(local: Measurement[], remote: Measurement[]): Measurement[] {
|
export function mergeMeasurements(local: Measurement[], remote: Measurement[], excludeIds?: Set<string>): Measurement[] {
|
||||||
const map = new Map<string, Measurement>()
|
const map = new Map<string, Measurement>()
|
||||||
|
|
||||||
for (const m of local) {
|
for (const m of local) {
|
||||||
|
if (excludeIds?.has(m.id)) continue
|
||||||
map.set(m.id, m)
|
map.set(m.id, m)
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const m of remote) {
|
for (const m of remote) {
|
||||||
|
if (excludeIds?.has(m.id)) continue
|
||||||
const existing = map.get(m.id)
|
const existing = map.get(m.id)
|
||||||
if (!existing || new Date(m.updatedAt) > new Date(existing.updatedAt)) {
|
if (!existing || new Date(m.updatedAt) > new Date(existing.updatedAt)) {
|
||||||
map.set(m.id, { ...m, syncStatus: 'synced' })
|
map.set(m.id, { ...m, syncStatus: 'synced' })
|
||||||
|
|
@ -82,7 +84,7 @@ async function uploadRemote(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function syncAll(): Promise<SyncResult> {
|
export async function syncAll(excludeIds?: Set<string>): Promise<SyncResult> {
|
||||||
const config = await getWebDAVConfig()
|
const config = await getWebDAVConfig()
|
||||||
if (!config) {
|
if (!config) {
|
||||||
throw new Error('Geen WebDAV configuratie. Stel deze eerst in in Instellingen.')
|
throw new Error('Geen WebDAV configuratie. Stel deze eerst in in Instellingen.')
|
||||||
|
|
@ -93,7 +95,7 @@ export async function syncAll(): Promise<SyncResult> {
|
||||||
const local = await getAllMeasurements()
|
const local = await getAllMeasurements()
|
||||||
const remote = await fetchRemote(config)
|
const remote = await fetchRemote(config)
|
||||||
|
|
||||||
const merged = mergeMeasurements(local, remote)
|
const merged = mergeMeasurements(local, remote, excludeIds)
|
||||||
|
|
||||||
for (const m of merged) {
|
for (const m of merged) {
|
||||||
await upsertMeasurement(m)
|
await upsertMeasurement(m)
|
||||||
|
|
|
||||||
|
|
@ -54,4 +54,19 @@ describe('mergeMeasurements', () => {
|
||||||
expect(result).toHaveLength(1)
|
expect(result).toHaveLength(1)
|
||||||
expect(result[0].weight).toBe(77)
|
expect(result[0].weight).toBe(77)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('excludes ids in excludeIds from both sides', () => {
|
||||||
|
const local = { ...base, id: 'id-1' }
|
||||||
|
const remote = { ...base, id: 'id-2' }
|
||||||
|
const result = mergeMeasurements([local], [remote], new Set(['id-1', 'id-2']))
|
||||||
|
expect(result).toHaveLength(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps ids not in excludeIds', () => {
|
||||||
|
const local = { ...base, id: 'id-1' }
|
||||||
|
const remote = { ...base, id: 'id-2' }
|
||||||
|
const result = mergeMeasurements([local], [remote], new Set(['id-1']))
|
||||||
|
expect(result).toHaveLength(1)
|
||||||
|
expect(result[0].id).toBe('id-2')
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue