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/sample-pdfs/
test/output/ test/output/
# Expo
.expo/
# APK builds
*.apk
# Temporary files # Temporary files
temp/ temp/
tmp/ tmp/

17
App.tsx
View file

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

View file

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

View file

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

View file

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

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

View file

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

View file

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

View file

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

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

View file

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

View file

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