From ed714af8d17fcc29a9365c7486f534ffb07b1bba Mon Sep 17 00:00:00 2001 From: eddy Date: Tue, 14 Jul 2026 09:14:18 +0200 Subject: [PATCH] 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 --- .gitignore | 6 + App.tsx | 17 +- README.md | 7 +- app.json | 13 +- docs/build-toolchain.md | 106 ++++++++ package-lock.json | 312 +++++++++++++++++++++-- package.json | 5 +- src/components/LatestMeasurementCard.tsx | 4 +- src/components/MeasurementForm.tsx | 41 +-- src/components/WeightChart.tsx | 116 ++++++--- src/screens/HistoryScreen.tsx | 6 +- src/screens/MeasurementDetailScreen.tsx | 135 ++++++++++ src/screens/NewMeasurementScreen.tsx | 97 +++---- src/sync/syncService.ts | 8 +- tests/syncService.test.ts | 15 ++ 15 files changed, 744 insertions(+), 144 deletions(-) create mode 100644 docs/build-toolchain.md create mode 100644 src/screens/MeasurementDetailScreen.tsx diff --git a/.gitignore b/.gitignore index f4c927b..d03787f 100644 --- a/.gitignore +++ b/.gitignore @@ -111,6 +111,12 @@ test/fixtures/ test/sample-pdfs/ test/output/ +# Expo +.expo/ + +# APK builds +*.apk + # Temporary files temp/ tmp/ diff --git a/App.tsx b/App.tsx index 00a9ac1..bccac9b 100644 --- a/App.tsx +++ b/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() export default function App() { return ( @@ -27,6 +37,11 @@ export default function App() { component={HistoryScreen} options={{ title: 'Historie' }} /> + .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' +``` diff --git a/package-lock.json b/package-lock.json index 3310c9d..b4a933a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index b87035e..a09395c 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/components/LatestMeasurementCard.tsx b/src/components/LatestMeasurementCard.tsx index 295fbb5..7cc0134 100644 --- a/src/components/LatestMeasurementCard.tsx +++ b/src/components/LatestMeasurementCard.tsx @@ -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 }, }) diff --git a/src/components/MeasurementForm.tsx b/src/components/MeasurementForm.tsx index 9888c37..71195b7 100644 --- a/src/components/MeasurementForm.tsx +++ b/src/components/MeasurementForm.tsx @@ -8,6 +8,7 @@ import { generateId } from '../utils/uuid' type Props = { onSubmit: (m: Measurement) => void initialValues?: Partial + 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(() => { - if (initialValues?.measuredAt) return new Date(initialValues.measuredAt) - return new Date() - }) + const [measurementDate, setMeasurementDate] = useState(new Date()) const [values, setValues] = useState>(() => { const init: Record = {} 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) { ) })} - + Opslaan @@ -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' }, diff --git a/src/components/WeightChart.tsx b/src/components/WeightChart.tsx index 7ea663f..5d1a183 100644 --- a/src/components/WeightChart.tsx +++ b/src/components/WeightChart.tsx @@ -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({ {title} 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 ( + + {title} + m.weight)} + labels={labels} + color={COLORS.weight} + unit=" kg" + /> + m.fatMass)} + labels={labels} + color={COLORS.fatMass} + unit=" kg" + /> + m.muscleMass)} + labels={labels} + color={COLORS.muscleMass} + unit=" kg" + /> + + ) +} + export default function WeightChart({ measurements }: Props) { + const scrollRef = useRef(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) => { + const offset = e.nativeEvent.contentOffset.x + const newPage = Math.round(offset / screenWidth) + if (newPage !== page) { + setPage(newPage) + } + } return ( - Voortgang (30 dagen) - - m.weight)} - labels={sparseLabels} - color={COLORS.weight} - unit=" kg" - /> - - m.fatMass)} - labels={sparseLabels} - color={COLORS.fatMass} - unit=" kg" - /> - - m.muscleMass)} - labels={sparseLabels} - color={COLORS.muscleMass} - unit=" kg" - /> + + + + ) } 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 }, diff --git a/src/screens/HistoryScreen.tsx b/src/screens/HistoryScreen.tsx index e84f507..8fd941d 100644 --- a/src/screens/HistoryScreen.tsx +++ b/src/screens/HistoryScreen.tsx @@ -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 }) => ( - handlePress(item)} /> + navigation.navigate('MeasurementDetail', { measurementId: item.id })}> + + )} contentContainerStyle={measurements.length === 0 ? styles.empty : undefined} ListEmptyComponent={Nog geen metingen} diff --git a/src/screens/MeasurementDetailScreen.tsx b/src/screens/MeasurementDetailScreen.tsx new file mode 100644 index 0000000..ee17a30 --- /dev/null +++ b/src/screens/MeasurementDetailScreen.tsx @@ -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(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 ( + + Laden... + + ) + } + + return ( + + + + {new Date(measurement.measuredAt).toLocaleDateString('nl-NL', { + weekday: 'long', + day: 'numeric', + month: 'long', + year: 'numeric', + })} + + + {rows.map(r => ( + + {r.label} + + {typeof measurement[r.key] === 'number' + ? (measurement[r.key] as number).toFixed(1) + : measurement[r.key]}{' '} + {r.suffix} + + + ))} + + + + + Wijzigen + + + Verwijderen + + + + ) +} + +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' }, +}) diff --git a/src/screens/NewMeasurementScreen.tsx b/src/screens/NewMeasurementScreen.tsx index 82eb6f3..91b3913 100644 --- a/src/screens/NewMeasurementScreen.tsx +++ b/src/screens/NewMeasurementScreen.tsx @@ -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 | 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) { | undefined} + isEdit={isEdit} /> ) } diff --git a/src/sync/syncService.ts b/src/sync/syncService.ts index 1bc0991..94d315f 100644 --- a/src/sync/syncService.ts +++ b/src/sync/syncService.ts @@ -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): Measurement[] { const map = new Map() 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 { +export async function syncAll(excludeIds?: Set): Promise { 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 { 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) diff --git a/tests/syncService.test.ts b/tests/syncService.test.ts index fae08ca..1f5878d 100644 --- a/tests/syncService.test.ts +++ b/tests/syncService.test.ts @@ -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') + }) })