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:
eddy 2026-07-14 09:14:18 +02:00
commit ed714af8d1
15 changed files with 730 additions and 130 deletions

6
.gitignore vendored
View file

@ -111,6 +111,12 @@ test/fixtures/
test/sample-pdfs/
test/output/
# Expo
.expo/
# APK builds
*.apk
# Temporary files
temp/
tmp/

17
App.tsx
View file

@ -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}

View file

@ -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

View file

@ -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
View 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
View file

@ -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",

View file

@ -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",

View file

@ -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 },
})

View file

@ -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' },

View file

@ -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 },

View file

@ -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>}

View 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' },
})

View file

@ -3,62 +3,53 @@ 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)
}
}
}
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
}
} else {
const existing = await getMeasurementByDate(m.measuredAt)
if (existing) {
Alert.alert(
@ -66,25 +57,20 @@ export default function NewMeasurementScreen({ navigation, route }: Props) {
`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) },
{ 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}
/>
)
}

View file

@ -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)

View file

@ -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')
})
})