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/output/
|
||||
|
||||
# Expo
|
||||
.expo/
|
||||
|
||||
# APK builds
|
||||
*.apk
|
||||
|
||||
# Temporary files
|
||||
temp/
|
||||
tmp/
|
||||
|
|
|
|||
17
App.tsx
17
App.tsx
|
|
@ -5,8 +5,18 @@ import DashboardScreen from './src/screens/DashboardScreen'
|
|||
import NewMeasurementScreen from './src/screens/NewMeasurementScreen'
|
||||
import HistoryScreen from './src/screens/HistoryScreen'
|
||||
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() {
|
||||
return (
|
||||
|
|
@ -27,6 +37,11 @@ export default function App() {
|
|||
component={HistoryScreen}
|
||||
options={{ title: 'Historie' }}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="MeasurementDetail"
|
||||
component={MeasurementDetailScreen}
|
||||
options={{ title: 'Meting' }}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="Settings"
|
||||
component={SettingsScreen}
|
||||
|
|
|
|||
|
|
@ -15,10 +15,11 @@ React Native (Expo) app voor het handmatig invoeren van meetgegevens van een sli
|
|||
|
||||
## 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
|
||||
- **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
|
||||
- **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 / 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
|
||||
- **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
|
||||
|
||||
|
|
|
|||
13
app.json
13
app.json
|
|
@ -26,6 +26,17 @@
|
|||
"expo-sqlite",
|
||||
"expo-secure-store",
|
||||
"@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-sqlite": "~16.0.10",
|
||||
"expo-status-bar": "~3.0.9",
|
||||
"expo-updates": "~29.0.18",
|
||||
"react": "19.1.0",
|
||||
"react-dom": "19.1.0",
|
||||
"react-native": "0.81.5",
|
||||
"react-native-chart-kit": "^7.0.1",
|
||||
"react-native-safe-area-context": "~5.6.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": {
|
||||
"@types/jest": "29.5.14",
|
||||
|
|
@ -1734,25 +1737,6 @@
|
|||
"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": {
|
||||
"version": "10.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@expo/json-file/-/json-file-10.2.0.tgz",
|
||||
|
|
@ -2161,6 +2145,25 @@
|
|||
"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": {
|
||||
"version": "0.1.8",
|
||||
"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_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": {
|
||||
"version": "7.0.6",
|
||||
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
||||
|
|
@ -4340,6 +4394,15 @@
|
|||
"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": {
|
||||
"version": "5.2.2",
|
||||
"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": {
|
||||
"version": "3.0.26",
|
||||
"resolved": "https://registry.npmjs.org/expo-modules-autolinking/-/expo-modules-autolinking-3.0.26.tgz",
|
||||
|
|
@ -5151,6 +5239,57 @@
|
|||
"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": {
|
||||
"version": "54.0.25",
|
||||
"resolved": "https://registry.npmjs.org/@expo/cli/-/cli-54.0.25.tgz",
|
||||
|
|
@ -5580,6 +5719,36 @@
|
|||
"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": {
|
||||
"version": "7.1.1",
|
||||
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
|
||||
|
|
@ -6056,6 +6225,12 @@
|
|||
"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": {
|
||||
"version": "0.6.3",
|
||||
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
|
||||
|
|
@ -6166,6 +6341,15 @@
|
|||
"integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==",
|
||||
"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": {
|
||||
"version": "2.2.4",
|
||||
"resolved": "https://registry.npmjs.org/invariant/-/invariant-2.2.4.tgz",
|
||||
|
|
@ -9051,6 +9235,12 @@
|
|||
"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": {
|
||||
"version": "5.6.0",
|
||||
"resolved": "https://registry.npmjs.org/pretty-bytes/-/pretty-bytes-5.6.0.tgz",
|
||||
|
|
@ -9263,6 +9453,18 @@
|
|||
"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": {
|
||||
"version": "1.0.4",
|
||||
"resolved": "https://registry.npmjs.org/react-freeze/-/react-freeze-1.0.4.tgz",
|
||||
|
|
@ -9409,6 +9611,38 @@
|
|||
"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": {
|
||||
"version": "0.81.5",
|
||||
"resolved": "https://registry.npmjs.org/@react-native/js-polyfills/-/js-polyfills-0.81.5.tgz",
|
||||
|
|
@ -9957,6 +10191,12 @@
|
|||
"dev": true,
|
||||
"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": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
|
||||
|
|
@ -10304,6 +10544,12 @@
|
|||
"integrity": "sha512-0MP/Cxx5SzeeZ10p/bZI0S6MpgD+yxAhi1BOQ34jgnMXsCq3j1t6tQnZu+KdlL7dvJTLT3g9xN8tl10TqgFMcg==",
|
||||
"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": {
|
||||
"version": "3.35.1",
|
||||
"resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz",
|
||||
|
|
@ -10673,6 +10919,32 @@
|
|||
"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": {
|
||||
"version": "6.27.0",
|
||||
"resolved": "https://registry.npmjs.org/undici/-/undici-6.27.0.tgz",
|
||||
|
|
|
|||
|
|
@ -10,12 +10,15 @@
|
|||
"expo-secure-store": "~15.0.8",
|
||||
"expo-sqlite": "~16.0.10",
|
||||
"expo-status-bar": "~3.0.9",
|
||||
"expo-updates": "~29.0.18",
|
||||
"react": "19.1.0",
|
||||
"react-dom": "19.1.0",
|
||||
"react-native": "0.81.5",
|
||||
"react-native-chart-kit": "^7.0.1",
|
||||
"react-native-safe-area-context": "~5.6.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": {
|
||||
"@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 },
|
||||
title: { fontSize: 18, fontWeight: '700', marginBottom: 4 },
|
||||
date: { fontSize: 14, color: '#666', marginBottom: 12 },
|
||||
row: { flexDirection: 'row', justifyContent: 'space-between', paddingVertical: 4 },
|
||||
rowLabel: { fontSize: 14, color: '#555' },
|
||||
row: { flexDirection: 'row', paddingVertical: 4 },
|
||||
rowLabel: { fontSize: 14, color: '#555', flex: 1 },
|
||||
rowValue: { fontSize: 14, fontWeight: '600' },
|
||||
emptyText: { fontSize: 16, color: '#999', textAlign: 'center', padding: 20 },
|
||||
})
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import { generateId } from '../utils/uuid'
|
|||
type Props = {
|
||||
onSubmit: (m: Measurement) => void
|
||||
initialValues?: Partial<Measurement>
|
||||
isEdit?: boolean
|
||||
}
|
||||
|
||||
type FieldDef = {
|
||||
|
|
@ -29,13 +30,10 @@ const fields: FieldDef[] = [
|
|||
{ 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 [pickerOpen, setPickerOpen] = useState(false)
|
||||
const [measurementDate, setMeasurementDate] = useState<Date>(() => {
|
||||
if (initialValues?.measuredAt) return new Date(initialValues.measuredAt)
|
||||
return new Date()
|
||||
})
|
||||
const [measurementDate, setMeasurementDate] = useState<Date>(new Date())
|
||||
const [values, setValues] = useState<Record<string, string>>(() => {
|
||||
const init: Record<string, string> = {}
|
||||
for (const f of fields) {
|
||||
|
|
@ -47,9 +45,18 @@ export default function MeasurementForm({ onSubmit, initialValues }: Props) {
|
|||
|
||||
|
||||
|
||||
const weight = parseFloat(values.weight) || 0
|
||||
const bodyFatPct = parseFloat(values.bodyFatPercentage) || 0
|
||||
const musclePct = parseFloat(values.musclePercentage) || 0
|
||||
function toFloat(s: string): number {
|
||||
return parseFloat(s.replace(',', '.')) || 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(() => ({
|
||||
fatMass: weight > 0 && bodyFatPct > 0 ? ((weight * bodyFatPct) / 100).toFixed(1) : '',
|
||||
|
|
@ -59,17 +66,17 @@ export default function MeasurementForm({ onSubmit, initialValues }: Props) {
|
|||
const handleSubmit = () => {
|
||||
const now = new Date().toISOString()
|
||||
const m: Measurement = {
|
||||
id: generateId(),
|
||||
id: isEdit && initialValues?.id ? initialValues.id : generateId(),
|
||||
weight,
|
||||
bmi: parseFloat(values.bmi) || 0,
|
||||
bmi: toFloat(values.bmi),
|
||||
bodyFatPercentage: bodyFatPct,
|
||||
fatMass: parseFloat(computed.fatMass) || 0,
|
||||
fatMass: toFloat(computed.fatMass),
|
||||
musclePercentage: musclePct,
|
||||
muscleMass: parseFloat(computed.muscleMass) || 0,
|
||||
visceralFat: parseInt(values.visceralFat, 10) || 0,
|
||||
bmr: parseFloat(values.bmr) || 0,
|
||||
muscleMass: toFloat(computed.muscleMass),
|
||||
visceralFat: toInt(values.visceralFat),
|
||||
bmr: toFloat(values.bmr),
|
||||
measuredAt: measurementDate.toISOString(),
|
||||
createdAt: now,
|
||||
createdAt: isEdit && initialValues?.createdAt ? initialValues.createdAt : now,
|
||||
updatedAt: now,
|
||||
syncStatus: 'local',
|
||||
}
|
||||
|
|
@ -140,7 +147,7 @@ export default function MeasurementForm({ onSubmit, initialValues }: Props) {
|
|||
)
|
||||
})}
|
||||
</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>
|
||||
</TouchableOpacity>
|
||||
</KeyboardAvoidingView>
|
||||
|
|
@ -149,7 +156,7 @@ export default function MeasurementForm({ onSubmit, initialValues }: Props) {
|
|||
|
||||
const styles = StyleSheet.create({
|
||||
container: { flex: 1, backgroundColor: '#f5f5f5' },
|
||||
scroll: { padding: 16, paddingBottom: 0 },
|
||||
scroll: { padding: 16 },
|
||||
field: { marginBottom: 12 },
|
||||
label: { fontSize: 14, fontWeight: '600', color: '#333', marginBottom: 4 },
|
||||
input: { backgroundColor: '#fff', borderRadius: 8, padding: 12, fontSize: 16, borderWidth: 1, borderColor: '#ddd' },
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import React from 'react'
|
||||
import { View, Text, StyleSheet, Dimensions } from 'react-native'
|
||||
import React, { useRef, useState } from 'react'
|
||||
import { View, Text, StyleSheet, Dimensions, ScrollView, NativeSyntheticEvent, NativeScrollEvent } from 'react-native'
|
||||
import { LineChart } from 'react-native-chart-kit'
|
||||
import { Measurement } from '../types'
|
||||
|
||||
|
|
@ -11,6 +11,9 @@ const COLORS = {
|
|||
muscleMass: '#2196F3',
|
||||
}
|
||||
|
||||
const screenWidth = Dimensions.get('window').width
|
||||
const chartWidth = screenWidth - 32
|
||||
|
||||
function SingleChart({
|
||||
title,
|
||||
data,
|
||||
|
|
@ -29,7 +32,7 @@ function SingleChart({
|
|||
<Text style={styles.chartTitle}>{title}</Text>
|
||||
<LineChart
|
||||
data={{ labels, datasets: [{ data, color: () => color, strokeWidth: 2 }] }}
|
||||
width={Dimensions.get('window').width - 32}
|
||||
width={chartWidth}
|
||||
height={180}
|
||||
yAxisSuffix={unit}
|
||||
chartConfig={{
|
||||
|
|
@ -57,7 +60,56 @@ function hexToRgb(hex: string): string {
|
|||
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) {
|
||||
const scrollRef = useRef<ScrollView>(null)
|
||||
const [page, setPage] = useState(0)
|
||||
|
||||
if (measurements.length < 2) {
|
||||
return null
|
||||
}
|
||||
|
|
@ -66,52 +118,38 @@ export default function WeightChart({ measurements }: Props) {
|
|||
(a, b) => new Date(a.measuredAt).getTime() - new Date(b.measuredAt).getTime()
|
||||
)
|
||||
|
||||
const cutoff = 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 lastDate = new Date(sorted[sorted.length - 1].measuredAt)
|
||||
|
||||
const labels = dataPoints.map(m => {
|
||||
const d = new Date(m.measuredAt)
|
||||
return `${d.getDate()}/${d.getMonth() + 1}`
|
||||
})
|
||||
const data30 = filterData(sorted, 30, lastDate)
|
||||
const data365 = filterData(sorted, 365, lastDate)
|
||||
|
||||
const labelStep = labels.length > 8 ? Math.ceil(labels.length / 8) : 1
|
||||
const sparseLabels = labels.map((l, i) => (i % labelStep === 0 ? l : ''))
|
||||
const onScroll = (e: NativeSyntheticEvent<NativeScrollEvent>) => {
|
||||
const offset = e.nativeEvent.contentOffset.x
|
||||
const newPage = Math.round(offset / screenWidth)
|
||||
if (newPage !== page) {
|
||||
setPage(newPage)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<Text style={styles.title}>Voortgang (30 dagen)</Text>
|
||||
|
||||
<SingleChart
|
||||
title="Gewicht"
|
||||
data={dataPoints.map(m => m.weight)}
|
||||
labels={sparseLabels}
|
||||
color={COLORS.weight}
|
||||
unit=" kg"
|
||||
/>
|
||||
|
||||
<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"
|
||||
/>
|
||||
<ScrollView
|
||||
ref={scrollRef}
|
||||
horizontal
|
||||
pagingEnabled
|
||||
showsHorizontalScrollIndicator={false}
|
||||
onMomentumScrollEnd={onScroll}
|
||||
>
|
||||
<ChartSet dataPoints={data30} title="Voortgang (30 dagen)" />
|
||||
<ChartSet dataPoints={data365} title="Voortgang (365 dagen)" />
|
||||
</ScrollView>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: { marginHorizontal: 16, marginBottom: 16 },
|
||||
container: { marginBottom: 16 },
|
||||
page: { width: screenWidth, paddingHorizontal: 16 },
|
||||
title: { fontSize: 18, fontWeight: '700', marginBottom: 8 },
|
||||
chartBlock: { marginBottom: 12 },
|
||||
chartTitle: { fontSize: 13, fontWeight: '600', color: '#555', marginBottom: 4, marginLeft: 4 },
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
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 { initDatabase, getAllMeasurements, deleteMeasurement } from '../database/db'
|
||||
import { Measurement } from '../types'
|
||||
|
|
@ -80,7 +80,9 @@ export default function HistoryScreen({ navigation }: Props) {
|
|||
data={measurements}
|
||||
keyExtractor={item => item.id}
|
||||
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}
|
||||
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 MeasurementForm from '../components/MeasurementForm'
|
||||
import { Measurement } from '../types'
|
||||
import {
|
||||
initDatabase,
|
||||
insertMeasurement,
|
||||
getLatestMeasurement,
|
||||
getMeasurementByDate,
|
||||
upsertMeasurement,
|
||||
} from '../database/db'
|
||||
import { initDatabase, insertMeasurement, upsertMeasurement, getLatestMeasurement, getMeasurementByDate } from '../database/db'
|
||||
|
||||
type Props = {
|
||||
navigation: any
|
||||
route: any
|
||||
route: {
|
||||
params: {
|
||||
measurement?: Measurement
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 [loaded, setLoaded] = useState(false)
|
||||
|
||||
React.useLayoutEffect(() => {
|
||||
navigation.setOptions({ title: isEdit ? 'Meting wijzigen' : 'Nieuwe meting' })
|
||||
}, [navigation, isEdit])
|
||||
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
if (isEdit) {
|
||||
setLatest(editMeasurement)
|
||||
setLoaded(true)
|
||||
return
|
||||
}
|
||||
let cancelled = false
|
||||
const load = async () => {
|
||||
await initDatabase()
|
||||
if (editing) {
|
||||
if (!cancelled) {
|
||||
setLatest(editing)
|
||||
setLoaded(true)
|
||||
}
|
||||
} else {
|
||||
const m = await getLatestMeasurement()
|
||||
if (!cancelled) {
|
||||
setLatest(m || {})
|
||||
setLoaded(true)
|
||||
}
|
||||
const m = await getLatestMeasurement()
|
||||
if (!cancelled) {
|
||||
setLatest(m || {})
|
||||
setLoaded(true)
|
||||
}
|
||||
}
|
||||
load()
|
||||
return () => { cancelled = true }
|
||||
}, [editing])
|
||||
}, [isEdit, editMeasurement])
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (editing) {
|
||||
navigation.setOptions({ title: 'Meting wijzigen' })
|
||||
}
|
||||
}, [editing, navigation])
|
||||
|
||||
const handleSubmit = async (m: Measurement) => {
|
||||
await initDatabase()
|
||||
if (editing) {
|
||||
m.id = editing.id
|
||||
m.createdAt = editing.createdAt
|
||||
if (isEdit) {
|
||||
await upsertMeasurement(m)
|
||||
doSync()
|
||||
return
|
||||
}
|
||||
const existing = await getMeasurementByDate(m.measuredAt)
|
||||
if (existing) {
|
||||
Alert.alert(
|
||||
'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: () => doSave(m, existing.id) },
|
||||
]
|
||||
)
|
||||
return
|
||||
} else {
|
||||
const existing = await getMeasurementByDate(m.measuredAt)
|
||||
if (existing) {
|
||||
Alert.alert(
|
||||
'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: () => {
|
||||
m.id = existing.id
|
||||
doSave(m)
|
||||
}},
|
||||
]
|
||||
)
|
||||
return
|
||||
}
|
||||
await insertMeasurement(m)
|
||||
}
|
||||
doSave(m)
|
||||
}
|
||||
|
||||
const doSave = async (m: Measurement, existingId?: string) => {
|
||||
if (existingId) {
|
||||
m.id = existingId
|
||||
await upsertMeasurement(m)
|
||||
} else {
|
||||
await insertMeasurement(m)
|
||||
}
|
||||
doSync()
|
||||
}
|
||||
|
||||
const doSync = async () => {
|
||||
const doSave = async (m: Measurement) => {
|
||||
try {
|
||||
const { syncAll } = await import('../sync/syncService')
|
||||
await syncAll()
|
||||
|
|
@ -110,6 +96,7 @@ export default function NewMeasurementScreen({ navigation, route }: Props) {
|
|||
<MeasurementForm
|
||||
onSubmit={handleSubmit}
|
||||
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 }
|
||||
|
||||
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>()
|
||||
|
||||
for (const m of local) {
|
||||
if (excludeIds?.has(m.id)) continue
|
||||
map.set(m.id, m)
|
||||
}
|
||||
|
||||
for (const m of remote) {
|
||||
if (excludeIds?.has(m.id)) continue
|
||||
const existing = map.get(m.id)
|
||||
if (!existing || new Date(m.updatedAt) > new Date(existing.updatedAt)) {
|
||||
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()
|
||||
if (!config) {
|
||||
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 remote = await fetchRemote(config)
|
||||
|
||||
const merged = mergeMeasurements(local, remote)
|
||||
const merged = mergeMeasurements(local, remote, excludeIds)
|
||||
|
||||
for (const m of merged) {
|
||||
await upsertMeasurement(m)
|
||||
|
|
|
|||
|
|
@ -54,4 +54,19 @@ describe('mergeMeasurements', () => {
|
|||
expect(result).toHaveLength(1)
|
||||
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