Compare commits

..
Author SHA1 Message Date
eddy
ebe0eafa23 Merge branch 'fix/issue-3-mergeMeasurements-refactor' 2026-08-06 12:46:26 +02:00
38137fabb4 Merge pull request 'refactor: syncAll pull-fase gebruikt mergeMeasurements als canonieke merge (issue #3)' (#19) from fix/issue-3-mergeMeasurements-refactor into main 2026-08-06 04:51:35 +02:00
eddy
97c0e8ad67 fix: pull-fase telt remote deletions als deleted ipv pulled 2026-08-06 04:48:30 +02:00
eddy
e3960a389e refactor: syncAll pull-fase gebruikt mergeMeasurements als canonieke merge (issue #3) 2026-08-06 04:42:47 +02:00
eddy
edbf69d6d9 Merge pull request #18: toon positieve comments bij gelijk/gestegen gewicht (issue #17) 2026-08-05 22:11:31 +02:00
eddy
a1e8a7ed48 feat: toon positieve comments bij gelijk/gestegen gewicht (issue #17)
- 'Vetje naar beneden' als vetmassa gedaald is
- 💪 als spiermassa gestegen is
- Combineer beide op één regel indien van toepassing
- Alleen tonen bij diff >= 0 kg
2026-08-05 22:07:30 +02:00
aafa041840 Merge pull request 'fix: remove hardcoded API token from SettingsScreen' (#16) from fix/issue-2-remove-hardcoded-token into main 2026-08-05 21:32:45 +02:00
eddy
d94628ce86 fix: remove hardcoded API token from SettingsScreen
- Replace hardcoded Bearer token default with empty string
- Remove auto-save in useEffect that wrote defaults to SecureStore
  without user consent on first app launch

Fixes #2
2026-08-05 21:20:34 +02:00
eddy
a77b31f95c feat: outbox-pattern sync + progressive dashboard loading
- Sync herschreven: push-vóór-pull, outbox met syncStatus (pending/pending-delete/synced), local-first merge
- Dashboard: progressieve loading — laatste 2 metingen direct, grafieken volgen daarna
- measurementStore versimpeld naar refreshCache/getCached/getVersion (20 regels)
- db.ts: getPendingRecords(), updateSyncStatus(), migratie local→synced, syncStatus DEFAULT 'pending'
- WeightChart: filtert 0-waarden uit vetmassa/spiermassa, verbergt chart bij <2 datapunten
- HistoryScreen/MeasurementDetailScreen: delete flow naar DB-only (setDeletedAt + updateSyncStatus)
- eas.json: appVersionSource toegevoegd tegen waarschuwing
- README bijgewerkt met outbox-pattern documentatie
- 16/16 tests, TypeScript clean
2026-07-25 17:27:54 +02:00
eddy
a32b9e0a88 fix: code review cleanup — filter deletedAt, deduplicate, use toast util
- getMeasurementByDate: AND deletedAt IS NULL (soft-deleted meting triggerde dubbele-datum alert)
- Dashboard/History: verwijder redundante JS .filter(m => !m.deletedAt) (SQL filterde al)
- HistoryScreen confirmDelete: fix dubbele load() call
- NewMeasurementScreen: gebruik toast utility i.p.v. direct ToastAndroid
2026-07-25 15:54:10 +02:00
eddy
4c34dff629 fix: sync refactor — fix 6 bugs + cache refresh na sync
- setDeletedAt update nu ook updatedAt (bug 2)
- getAllMeasurements filtert deletedAt IS NULL, nieuwe getAllMeasurementsForSync voor sync
- mergeMeasurements detecteert server deleted:true → deletedAt (bug 1)
- syncAll: pull-push split met remoteUpdatedMap, geen double-push (bug 3)
- hard delete alleen na API-succes (bug 4)
- DashboardScreen: refreshCache() na syncAll zodat UI niet op Laden blijft hangen
- deleteMeasurement() dead code verwijderd (bug 5)
- tests: 3 nieuwe testcases voor deleted → deletedAt mapping, 15/15 pass
2026-07-25 15:33:22 +02:00
eddy
55572a1455 docs: uitgebreide README met alle projectaspecten 2026-07-20 20:19:48 +02:00
eddy
491ea1ff16 docs: update README met huidige architectuur en vibecoding vermelding 2026-07-20 20:17:03 +02:00
eddy
4a7c89bdd8 fix: update adaptive icon foreground naar nieuw icoon 2026-07-20 20:01:19 +02:00
eddy
b65c7a7043 docs: blogartikel over de REST API migratie 2026-07-20 19:50:19 +02:00
eddy
2e6c3fd588 assets: nieuw app icoon van weegschaal-app.jpeg 2026-07-20 19:34:40 +02:00
eddy
e9a17d32a2 ui: vervang share icoon door MaterialIcons standaard share icon 2026-07-20 19:23:48 +02:00
eddy
dc85cd3ff0 feat: share knop op dashboard kaart — deelt kaart als PNG via expo-sharing 2026-07-20 18:58:24 +02:00
eddy
86d6cde478 fix: plaats voorlaatste meting boven laatste, fix MeasurementRow onPress in History 2026-07-20 18:40:26 +02:00
eddy
73d327f914 ui: toon voorlaatste meting en gewichtsverschil in dashboard card 2026-07-20 18:16:56 +02:00
eddy
df91f4d46f ui: voeg laad-indicator toe aan Dashboard 2026-07-20 17:52:26 +02:00
eddy
e93d12565e db: voeg ALTER TABLE migratie toe voor bestaande databases zonder deletedAt 2026-07-20 17:39:32 +02:00
eddy
d0dd2f4e39 test: herschrijf tests voor timestamp-based sync, vervang updateMeasurement door setDeletedAt 2026-07-20 17:03:44 +02:00
eddy
8e1783306a ui: filter deletedAt in History en Dashboard, soft delete in History 2026-07-20 17:01:39 +02:00
eddy
b043a5c1d2 detail: soft delete via deletedAt i.p.v. syncStatus 2026-07-20 17:00:59 +02:00
eddy
143572bc81 save: directe uploadMeasurement push, verwijder syncStatus uit MeasurementForm 2026-07-20 17:00:41 +02:00
eddy
23db445366 settings: vervang WebDAV configuratie door REST API configuratie 2026-07-20 17:00:21 +02:00
eddy
4b7b7e3d5c db: fix insertMeasurement/upsertMeasurement — remove syncStatus param 2026-07-20 16:58:55 +02:00
eddy
79c91eb537 db: voeg deletedAt toe, vervang syncStatus logica door timestamp-based functies 2026-07-20 16:58:12 +02:00
eddy
cec6c54e61 sync: herschrijf syncService voor timestamp-based REST sync 2026-07-20 16:56:28 +02:00
eddy
a13f2b821b feat: REST API client module 2026-07-20 16:55:50 +02:00
eddy
ac681dbfaa types: vervang WebDAVConfig door ApiConfig, verwijder SyncStatus, voeg deletedAt toe 2026-07-20 16:55:07 +02:00
eddy
ed714af8d1 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
2026-07-14 09:14:59 +02:00
32 changed files with 3404 additions and 542 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}

145
README.md
View file

@ -1,24 +1,117 @@
# Weegschaal App # Weegschaal App
React Native (Expo) app voor het handmatig invoeren van meetgegevens van een slimme weegschaal. Data wordt lokaal opgeslagen in SQLite en gesynchroniseerd met Nextcloud via WebDAV. _*Gemaakt met vibecoding — DeepSeek V4 Pro*_
## Meetwaarden React Native (Expo) app voor het invoeren en synchroniseren van lichaamsmetingen (weegschaal). Data wordt lokaal opgeslagen in SQLite en gesynchroniseerd via een eigen REST API met outbox-pattern sync.
- Gewicht (kg) ## Inhoud
- BMI
- Vetpercentage (%) - [Functionaliteit](#functionaliteit)
- Vetmassa (kg) — automatisch berekend - [Schermen](#schermen)
- Spierpercentage (%) - [Synchronisatie](#synchronisatie)
- Spiermassa (kg) — automatisch berekend - [Server-side API](#server-side-api)
- Buikvet (index) - [Beveiliging](#beveiliging)
- Stofwisseling in rust (kcal) - [Offline resilience](#offline-resilience)
- [Ontwikkeling](#ontwikkeling)
- [Build & deploy](#build--deploy)
- [Testen](#testen)
- [Licentie](#licentie)
## Functionaliteit
- **Metingen invoeren** van een slimme weegschaal: gewicht, BMI, vetpercentage, vetmassa, spierpercentage, spiermassa, buikvet en stofwisseling
- **Dashboard** met laatste en voorlaatste meting (direct zichtbaar via progressieve loading), gewichtsverschil, en grafieken (gewicht, vetmassa, spiermassa)
- **Delen** van meetresultaten als PNG-afbeelding via WhatsApp, e-mail, etc.
- **Historie** van alle metingen, gesorteerd op datum
- **Offline-first**: metingen worden lokaal opgeslagen en later automatisch gesynchroniseerd
- **Instellingen** voor API-configuratie en handmatige sync
## 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 | Scherm | Beschrijving |
- **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 |--------|-------------|
- **Historie** — FlatList gesorteerd op ingevoerde datum (nieuwste bovenaan), pull-to-refresh | Dashboard | Progressive loading: laatste 2 metingen direct zichtbaar, grafieken volgen daarna |
- **Instellingen** — WebDAV configuratie (URL, gebruikersnaam, wachtwoord met oogje voor tonen/verbergen), handmatige sync, wissen met bevestigingsdialoog | Nieuwe meting / Wijzigen | Formulier met datumkiezer, waarden vooraf ingevuld vanaf laatste meting |
| Meting detail | Alle meetwaarden met knoppen voor wijzigen en verwijderen |
| Historie | FlatList van alle metingen, pull-to-refresh, tik voor detail |
| Instellingen | API URL + token, opslaan/wissen, handmatige sync |
## Synchronisatie
De app synchroniseert via een zelfgebouwde REST API. Geen WebDAV, geen bestandssync — pure delta-sync.
### Hoe het werkt
1. **Outbox pattern**: elke meting heeft een `syncStatus` — `pending` (nieuw/gewijzigd), `pending-delete` (verwijderd), of `synced` (in sync met server)
2. **Push vóór pull**: eerst worden lokale wijzigingen (`pending` + `pending-delete`) naar de server gepusht, daarna pas remote changes opgehaald
3. **Local-first merge**: remote data overschrijft nooit lokale `pending` of `pending-delete` records — de gebruiker wint altijd
4. **Verwijderen**: `setDeletedAt(id)` lokaal + `updateSyncStatus(id, 'pending-delete')` → push in outbox-fase → server DELETE → hard delete in pull-fase
5. **Cursor tracking**: `lastSyncAt` (SecureStore) voor delta-pull, alleen records sinds laatste sync
### Waarom outbox pattern?
Betrouwbaarder dan timestamp-only push: `syncStatus` markeert expliciet wat gepusht moet worden, onafhankelijk van klokverschillen. Push vóór pull voorkomt dat eigen wijzigingen overschreven worden door oudere remote data. Local-first merge garandeert dat de gebruiker nooit data verliest.
### Offline resilience
Bij netwerkfout tijdens opslaan blijft de meting lokaal staan met `syncStatus='pending'`. De outbox (`getPendingRecords()`) pikt hem automatisch op bij de volgende sync of dashboard-focus.
## Server-side API
**Stack**: nginx, PHP 8.5 (Slim Framework 4), MariaDB, Let's Encrypt SSL, SELinux
**Endpoints**:
| Methode | Pad | Auth | Beschrijving |
|---------|-----|------|-------------|
| `GET` | `/api/v1/measurements?since=ISO` | Bearer | Delta-sync |
| `POST` | `/api/v1/measurements` | Bearer | Upsert (INSERT ON DUPLICATE KEY) |
| `DELETE` | `/api/v1/measurements/:id` | Bearer | Soft delete (`deleted=1`, update `updated_at`) |
**Database**: MariaDB met drie tabellen — `users`, `auth_tokens`, `measurements`. De `measurements` tabel gebruikt `ON DUPLICATE KEY UPDATE` voor conflictvrije upserts.
**Bestandsstructuur**:
```
/usr/share/nginx/html/weegschaal-api/
├── composer.json
├── config.php
├── public/
│ └── index.php
└── src/
├── Auth.php ← Bearer token validatie middleware
├── Database.php ← PDO singleton
├── Measurements.php ← CRUD + sync queries
└── helpers.php ← JSON response, datetime conversie, logging
```
## Beveiliging
### Authenticatie
Bearer token-validatie via `auth_tokens` tabel. Tokens worden handmatig aangemaakt via SQL.
### Logging
Elke mislukte authenticatie en 404 wordt gelogd naar `/var/log/weegschaal-api/auth.log`:
```
[2026-07-20 18:06:07] 401 94.157.121.179 GET /api/v1/measurements - invalid Bearer token
[2026-07-20 18:06:07] 404 94.157.121.179 GET /.env - unknown route
```
### Fail2ban
Jail `weegschaal-api`: 5 foutieve pogingen binnen 5 minuten = 1 uur IP-ban. Filter regex:
```
^\[.*\] 401 <HOST> .* - .*$
```
Logrotering wekelijks, 12 weken retentie.
### SELinux
Logmap heeft context `httpd_log_t` gezet via `semanage fcontext`.
## Ontwikkeling ## Ontwikkeling
@ -29,15 +122,25 @@ npx expo start
Scannen met Expo Go op Android of iOS. Scannen met Expo Go op Android of iOS.
## Synchronisatie ## Build & deploy
Data wordt opgeslagen als `weegschaal/measurements.json` in je Nextcloud WebDAV. Vul in het Instellingen scherm het volgende in: ```bash
# APK bouwen (lokaal via EAS)
npx eas build --local --profile apk --platform android
- **URL**: `https://<jouw-nextcloud>/remote.php/dav/files/<gebruikersnaam>/` # Draadloos sideloaden via ADB
- **Gebruikersnaam**: je Nextcloud gebruikersnaam adb-wireless-connect
- **Wachtwoord**: je Nextcloud app-wachtwoord adb install -r build-*.apk
```
Bij sync worden lokale en externe metingen samengevoegd (last-write-wins op basis van `updatedAt`). De map `weegschaal/` wordt automatisch aangemaakt via MKCOL indien deze nog niet bestaat. Vereisten: Android SDK 36, OpenJDK 21, ADB 37+, Gradle 8.14.
## Database migratie
Bestaande SQLite-databases worden automatisch gemigreerd bij `initDatabase()`:
- `deletedAt` kolom: toegevoegd via `ALTER TABLE` als deze nog niet bestaat
- `syncStatus` migratie: bestaande records met `syncStatus='local'` worden omgezet naar `'synced'`, nieuwe records krijgen DEFAULT `'pending'`
## Testen ## Testen
@ -45,6 +148,8 @@ Bij sync worden lokale en externe metingen samengevoegd (last-write-wins op basi
npm test npm test
``` ```
Tests richten zich op de merge-logica (`mergeMeasurements`) en database-operaties.
## Licentie ## Licentie
Public domain (Unlicense) Public domain (Unlicense)

View file

@ -13,9 +13,9 @@
"package": "nl.eddydevink.weegschaal", "package": "nl.eddydevink.weegschaal",
"adaptiveIcon": { "adaptiveIcon": {
"backgroundColor": "#E6F4FE", "backgroundColor": "#E6F4FE",
"foregroundImage": "./assets/android-icon-foreground.png", "foregroundImage": "./assets/icon.png",
"backgroundImage": "./assets/android-icon-background.png", "backgroundImage": "./assets/android-icon-background.png",
"monochromeImage": "./assets/android-icon-monochrome.png" "monochromeImage": "./assets/icon.png"
}, },
"predictiveBackGestureEnabled": false "predictiveBackGestureEnabled": false
}, },
@ -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"
}
} }
} }

Binary file not shown.

Before

Width:  |  Height:  |  Size: 384 KiB

After

Width:  |  Height:  |  Size: 218 KiB

Before After
Before After

70
docs/blog-sync-upgrade.md Normal file
View file

@ -0,0 +1,70 @@
# Van WebDAV naar REST API: een volwassen sync-architectuur voor de weegschaal-app
*20 juli 2026*
De afgelopen dagen heb ik de synchronisatie van mijn weegschaal-app volledig herzien. De app slaat metingen lokaal op in SQLite en synchroniseert ze met een centrale server. Tot nu toe gebeurde dat via WebDAV naar Nextcloud — een simpel JSON-bestand dat telkens in zijn geheel werd gedownload, samengevoegd en teruggeüpload. Functioneel, maar niet ideaal.
## Waarom WebDAV niet voldeed
Het oude sync-mechanisme was primitief: bij elke sync werd het volledige `measurements.json` bestand van de WebDAV-server gehaald, lokaal gemerged op basis van `updatedAt`, en daarna in zijn geheel teruggeschreven. Met 1300+ metingen werd dat bestand steeds groter, en elke sync — ook als er maar één meting was veranderd — verstuurde het hele bestand over de lijn. Bovendien leverde Basic Auth via WebDAV geen fijnmazige controle over wie toegang had, en was er geen logging van foutieve inlogpogingen.
## De nieuwe architectuur
De oplossing is een eigen REST API, gehost op een subdomein van mijn domein, met een MariaDB-backend en Bearer token-authenticatie. De stack:
- **Server**: Fedora Linux, nginx, PHP 8.5 (Slim Framework 4), MariaDB
- **Client**: React Native (Expo), expo-sqlite, expo-secure-store
- **Authenticatie**: Bearer token, handmatig aangemaakt via SQL
- **Sync**: incrementeel via `?since=` timestamps — last-writer-wins
### Endpoints
De API kent drie endpoints, allemaal beschermd met Bearer token-validatie:
| Methode | Pad | Werking |
|---------|-----|---------|
| `GET` | `/api/v1/measurements?since=ISO` | Delta-sync: alleen metingen met `updated_at > since` |
| `POST` | `/api/v1/measurements` | Upsert op client-ID — de server accepteert het client-gegenereerde UUID |
| `DELETE` | `/api/v1/measurements/:id` | Soft delete: zet `deleted=1` en update `updated_at` |
Geen PUT, geen register/token-endpoints. Minimaal en doelgericht.
### Incrementele sync
De sync-flow is nu gebaseerd op timestamps, zonder `syncStatus` enum. Het mechanisme is hetzelfde als wat CouchDB, PouchDB en tal van productie-apps gebruiken:
1. **Delta ophalen**: `GET ?since=lastSyncAt` → alleen gewijzigde records van de server
2. **Mergen**: last-writer-wins op `updatedAt`. Remote records met `deleted=true` overschrijven lokaal
3. **Pushen**: alle lokale records met `updatedAt > lastSyncAt` worden naar de server gestuurd
4. **Verwijderde records**: lokaal een `deletedAt` timestamp gezet, bij volgende sync via `DELETE` naar de server
5. **High-water mark**: `lastSyncAt` wordt bijgewerkt naar de huidige tijd
Bij een netwerkfout blijft een meting gewoon lokaal staan — de volgende sync pakt hem automatisch op omdat `updatedAt > lastSyncAt`.
## Fail2ban-vriendelijke logging
Een API die op internet staat trekt vroeg of laat ongenode gasten aan. Om hierop voorbereid te zijn logt de API elke mislukte authenticatiepoging naar `/var/log/weegschaal-api/auth.log` in een formaat dat fail2ban direct kan verwerken:
```
[2026-07-20 18:06:07] 401 94.157.121.179 GET /api/v1/measurements - invalid Bearer token
[2026-07-20 18:06:07] 404 94.157.121.179 GET /.env - unknown route
```
De fail2ban jail grijpt in bij 5 foutieve pogingen binnen 5 minuten en bant het IP een uur lang. Ook 404's op niet-bestaande routes worden gelogd — een klassiek signaal van fuzzing-tools.
De logrotering (12 weken, wekelijks) en SELinux-context (`httpd_log_t`) zijn uiteraard geregeld.
## Resultaten delen
Een onverwacht leuke toevoeging: de share-knop op het dashboard. Rechtsboven in de kaart met je laatste en voorlaatste meting zit een deel-icoon. Tik erop, en de hele kaart wordt als PNG-afbeelding geëxporteerd. Je kunt hem direct delen via WhatsApp, Signal, e-mail — wat je maar wilt. De techniek erachter: `react-native-view-shot` vangt de view, `expo-sharing` opent het native deelmenu.
## Wat nog meer is veranderd
- **App-icoon**: vernieuwd met een eigen afbeelding
- **Dashboard**: toont naast de laatste meting nu ook de voorlaatste en het gewichtsverschil
- **Laad-indicator**: geen verwarrende "Nog geen metingen" meer tijdens het laden — het dashboard toont nu "Laden..."
- **Database-migratie**: een `ALTER TABLE` zorgt dat bestaande SQLite-databases de nieuwe `deletedAt`-kolom krijgen zonder dataverlies
## Conclusie
Wat begon als een simpel sync-scriptje via WebDAV is uitgegroeid tot een volwaardige REST API met productie-grade logging, fail2ban-integratie, en een UX die het delen van meetresultaten net zo makkelijk maakt als een screenshot. De app doet nu wat hij moet doen: metingen opslaan, synchroniseren, en — sinds kort — met één tik delen met wie je maar wilt.

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'
```

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,139 @@
# Sync refactor — Implementation Plan ✅ (completed 2026-07-25)
> Fix 6 sync bugs + herstel architectuur naar best-practice bidirectional timestamp sync.
>
> **Status:** Alle 6 bugs gefixt. 15/15 tests passen. APK build + geinstalleerd.
## Bug Overzicht
| # | Bug | Oorzaak | Fix |
|---|-----|---------|-----|
| 1 | Server `deleted:true` wordt genegeerd | `mergeMeasurements` mapt `deleted` niet naar `deletedAt` | Detecteer `(r as any).deleted` in merge, zet `deletedAt = r.updatedAt` |
| 2 | `setDeletedAt` update `updatedAt` niet | Alleen `deletedAt` wordt gezet, `updatedAt` blijft oud | Voeg `updatedAt = ?` toe aan de UPDATE |
| 3 | Pull-then-push double-push | Na upsert van remote records hebben die `updatedAt > lastSyncAt` → worden teruggestuurd | Skip in push-fase records die uit remote pull komen en waar remote.updatedAt ≥ local.updatedAt |
| 4 | Hard delete ondanks falende API | `removeMeasurement()` wordt ALTIJD aangeroepen, ook na catch | Alleen hard delete bij API-succes |
| 5 | `syncStatus` dead column | In schema, nooit gebruikt | Laat staan, geen impact |
| 6 | `getAllMeasurements` filtert `deletedAt` niet | UI-schermen moeten zelf filteren (`.filter(m => !m.deletedAt)`) | Rename naar `getAllMeasurementsForSync` (ongefilterd), vervang UI-calls door `getAllMeasurements` met `WHERE deletedAt IS NULL` |
## Sync Protocol (vastgelegd)
```
lastSyncAt = SecureStore.getItem(LAST_SYNC_KEY)
── PULL ──
remote[] = GET /api/v1/measurements?since=lastSyncAt
local[] = getAllMeasurementsForSync() // ALLE records (incl. deleted)
remoteUpdatedMap: Map<id, updatedAt> // track welke IDs + timestamps we binnenhaalden
for each r in remote:
remoteUpdatedMap.set(r.id, r.updatedAt)
if (r as any).deleted:
r.deletedAt = r.updatedAt // Bug 1 fix
── MERGE ──
remoteDeletedIds: Set<id> // track welke remote records 'deleted' zijn
for each r in remote:
if (r as any).deleted:
remoteDeletedIds.add(r.id)
merged = mergeMeasurements(local, remote)
for each m in merged:
remoteUA = remoteUpdatedMap.get(m.id)
if remoteUA && new Date(remoteUA) >= new Date(m.updatedAt):
// remote won de merge
if remoteDeletedIds.has(m.id):
removeMeasurement(m.id) // hard-delete lokaal (server heeft 'm al)
else:
upsertMeasurement(m) // remote gewijzigd, niet verwijderd
else:
upsertMeasurement(m) // local won of nieuw → gewoon bewaren
── PUSH ──
cutoff = lastSyncAt ?? '1970-01-01T00:00:00.000Z'
localChanges[] = getMeasurementsUpdatedSince(cutoff)
for each m in localChanges:
remoteUA = remoteUpdatedMap.get(m.id)
if remoteUA && new Date(remoteUA) >= new Date(m.updatedAt):
continue // remote won merge → niet terugpushen (Bug 3 fix)
if m.deletedAt:
DELETE /api/v1/measurements/:id
if success:
removeMeasurement(m.id) // Bug 4 fix: alleen hard-delete bij API-succes
else:
POST /api/v1/measurements
SecureStore.setItem(LAST_SYNC_KEY, now())
```
## Delete Protocol (vastgelegd)
```
Lokaal: setDeletedAt(id) + updatedAt = now() (Bug 2 fix)
Cache: removeFromCache(id)
API: DELETE /api/v1/measurements/:id
if success:
Lokaal: removeMeasurement(id) (Bug 4 fix: alleen bij succes)
// bij falen: blijft met deletedAt in DB → syncAll pakt op
```
## Files om te wijzigen
### 1. `src/sync/syncService.ts` — REWRITE syncAll + mergeMeasurements
**Wijzigingen:**
- `mergeMeasurements`: detecteer `(r as any).deleted` → zet `deletedAt = r.updatedAt`
- `syncAll`:
- Track `remoteUpdatedMap` in pull-fase
- Merge via bestaande `mergeMeasurements`
- Push: skip records waar remote.updatedAt ≥ local.updatedAt (remote won)
- Alleen `removeMeasurement` na succesvolle API DELETE
- Exporteer `cleanupDeletedRecord(id)` voor gebruik door screens (DRY)
### 2. `src/database/db.ts` — setDeletedAt fix + query filters
**Wijzigingen:**
- `setDeletedAt(id)`: voeg `updatedAt = ?` toe aan SQL
- `getAllMeasurements()`: voeg `WHERE deletedAt IS NULL` toe
- `getLatestMeasurement()`: voeg `WHERE deletedAt IS NULL` toe
- Nieuw: `getAllMeasurementsForSync()` — ongefilterd (voor syncService)
- Verwijder: `deleteMeasurement()` (dode code, duplicate van `removeMeasurement`)
### 3. `src/screens/HistoryScreen.tsx` — delete fix
**Wijzigingen:**
- `confirmDelete`: alleen `removeMeasurement()` na API-succes
- `load`: gebruik `getMeasurementsSince('1970-01-01')` i.p.v. `getAllMeasurements()` (of de nieuwe `getAllMeasurements` die filtert)
### 4. `src/screens/MeasurementDetailScreen.tsx` — delete fix
**Wijzigingen:**
- `handleDelete`: alleen `removeMeasurement()` na API-succes
### 5. `src/store/measurementStore.ts` — geen wijzigingen nodig
De store gebruikt `getLatestMeasurements(count)` en `getMeasurementsSince(date)` die al filteren. `/loadFromDb` gebruikt `getAllMeasurements()` → die gaat nu ook filteren. ✓
### 6. `tests/syncService.test.ts` — nieuwe test cases
**Wijzigingen:**
- Test: `deleted` van server wordt correct naar `deletedAt` gemapt
- Test: syncAll push skips remote-won records
- Test: syncAll push stuurt local-won records wel
### 7. `tests/database.test.ts` — update voor nieuwe functies
**Wijzigingen:**
- Test `setDeletedAt` update ook `updatedAt`
- Test `getAllMeasurementsForSync` returned deleted records
## Uitvoeringsvolgorde
1. `db.ts` — setDeletedAt fix + query filters
2. `syncService.ts` — merge fix + syncAll refactor
3. `HistoryScreen.tsx` + `MeasurementDetailScreen.tsx` — delete fix
4. Tests updaten + TypeScript check + `npm test`
5. Build APK, testen

View file

@ -0,0 +1,266 @@
# REST API Sync — Design Doc
Vervang WebDAV-sync door een eigen REST API op `weegschaal.eddydevink.nl`,
met MariaDB-backend en Bearer token-authenticatie. Incrementele sync i.p.v. volledig bestand.
## Fase 1 — Server-side: PHP API
### Infrastructuur
- **DNS**: `weegschaal.eddydevink.nl` → zelfde server als `git.eddydevink.nl` (nextcloud)
- **nginx**: server block in `/etc/nginx/conf.d/weegschaal-api.conf`, proxy naar PHP-FPM socket
- **SSL**: `certbot --nginx -d weegschaal.eddydevink.nl`
- **Document root**: `/usr/share/nginx/html/weegschaal-api/`
### MariaDB
Database `weegschaal` met gebruiker `weegschaal@localhost` (random wachtwoord).
```sql
CREATE TABLE users (
id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(255) NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE auth_tokens (
id INT AUTO_INCREMENT PRIMARY KEY,
user_id INT NOT NULL REFERENCES users(id),
token VARCHAR(64) NOT NULL UNIQUE,
description VARCHAR(255),
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE measurements (
id VARCHAR(36) NOT NULL,
user_id INT NOT NULL,
weight DECIMAL(5,2) NOT NULL,
bmi DECIMAL(4,1) NOT NULL,
body_fat_percentage DECIMAL(4,1) NOT NULL,
fat_mass DECIMAL(5,2) NOT NULL,
muscle_percentage DECIMAL(4,1) NOT NULL,
muscle_mass DECIMAL(5,2) NOT NULL,
visceral_fat TINYINT NOT NULL,
bmr DECIMAL(6,1) NOT NULL,
measured_at DATE NOT NULL,
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL,
deleted TINYINT NOT NULL DEFAULT 0,
PRIMARY KEY (user_id, id),
INDEX idx_updated_at (user_id, updated_at)
);
```
`deleted` flag voor soft deletes op server. De client stuurt harde DELETEs;
de server zet `deleted=1` en update `updated_at` zodat de volgende `?since=`
query deze doorgeeft zodat andere clients de delete kunnen verwerken.
### Endpoints (Slim Framework)
| Methode | Pad | Beschrijving |
|---------|-----|-------------|
| `GET` | `/api/v1/measurements` | Alle metingen (`?since=ISO` voor delta) |
| `POST` | `/api/v1/measurements` | Nieuwe meting of upsert op ID |
| `DELETE` | `/api/v1/measurements/:id` | Meting verwijderen (soft delete) |
**Geen** auth endpoints — eerste token wordt handmatig via SQL INSERT aangemaakt.
### Auth middleware
- `Authorization: Bearer <token>` → valideert tegen `auth_tokens` tabel
- Zet `user_id` in request context
- 401 bij ongeldige/missing token
### Bestandsstructuur
```
/usr/share/nginx/html/weegschaal-api/
├── composer.json
├── public/
│ └── index.php ← Entry point, routing
├── src/
│ ├── Database.php ← PDO singleton
│ ├── Auth.php ← Token validation middleware
│ ├── Measurements.php ← CRUD + sync queries
│ └── helpers.php
└── config.php ← DB credentials
```
### Sync-logica server
- `GET ?since=ISO` → `WHERE user_id = ? AND updated_at > ? ORDER BY updated_at ASC`
- `POST` → `INSERT ... ON DUPLICATE KEY UPDATE` — upsert op client-ID
- `DELETE` → set `deleted=1`, update `updated_at=NOW()` (soft delete, zodat delta-sync werkt voor andere clients)
- Geretourneerde records bevatten `deleted: true` veld zodat de client weet dat dit een soft-deleted record is
---
## Fase 2 — Client-side: App aanpassen
### Types (`src/types.ts`)
`WebDAVConfig` en `SyncStatus` vervallen. Toegevoegd: `ApiConfig`, `deletedAt`:
```typescript
export type Measurement = {
id: string
weight: number
bmi: number
bodyFatPercentage: number
fatMass: number
musclePercentage: number
muscleMass: number
visceralFat: number
bmr: number
measuredAt: string
createdAt: string
updatedAt: string
deletedAt?: string
}
export type ApiConfig = {
url: string
token: string
}
```
### SettingsScreen (`src/screens/SettingsScreen.tsx`)
- Vervang URL/username/password door API-URL + token (met oogje)
- SecureStore key: `weegschaal_api_config`
- `getApiConfig()` (exported) i.p.v. `getWebDAVConfig()`
- Sync knop blijft
### Nieuwe module `src/sync/api.ts`
```typescript
const createApi = (config: ApiConfig) => ({
getMeasurements: (since?: string) => GET /api/v1/measurements?since=...
createMeasurement: (m: Measurement) => POST /api/v1/measurements
deleteMeasurement: (id: string) => DELETE /api/v1/measurements/:id
})
```
Alle calls met `Authorization: Bearer <token>` header.
### syncService.ts — volledig herschreven
#### `mergeMeasurements(local, remote)`
Simpele last-writer-wins op `updatedAt`:
1. Bouw map van alle IDs (lokaal + remote)
2. Voor elke ID: als beide bestaan → behoud de met de hoogste `updatedAt`
3. Remote metingen die winnen krijgen `deletedAt` overgenomen uit server indien de server record `deleted: true` had
#### `syncAll()`
1. Lees `lastSyncAt` uit SecureStore (`weegschaal_last_sync`)
2. `GET /api/v1/measurements?since=lastSyncAt` → remote delta
3. `getAllMeasurements()` → lokale metingen
4. Merge remote delta in lokale DB (last-writer-wins)
5. Verwijder lokale records waar remote `deleted: true` en remote `updatedAt > local updatedAt`
6. Verzamel lokale metingen met `updatedAt > lastSyncAt` (alleen niet-verwijderde)
7. Voor `deletedAt` metingen met `deletedAt > lastSyncAt` → DELETE naar API → bij succes lokaal verwijderen
8. Voor overige metingen met `updatedAt > lastSyncAt` → POST naar API
9. `lastSyncAt = now()`
#### `uploadMeasurement(m)`
Losstaande functie voor directe push na opslaan:
1. POST naar API
2. Bij succes → return
3. Bij netwerkfout → throw; meting blijft lokaal, `syncAll` pakt hem later op (want `updatedAt > lastSyncAt`)
### NewMeasurementScreen
1. Lokaal opslaan (insert of upsert, met `updatedAt = now()`)
2. Direct `uploadMeasurement(m)` proberen
3. Bij succes → navigeer terug met "Opgeslagen & gesynct"
4. Bij falen → navigeer terug met "Opgeslagen (sync mislukt)"
### MeasurementDetailScreen
Bij delete:
1. Zet `deletedAt = now()` lokaal
2. Fire-and-forget: DELETE API call
3. Bij succes → record lokaal verwijderen
4. Bij falen → record blijft met `deletedAt`, syncAll pakt het op
### HistoryScreen
Bij delete (vanuit alert in HistoryScreen):
1. Zet `deletedAt = now()` lokaal
2. Fire-and-forget: DELETE API call
3. Bij succes → record lokaal verwijderen
4. Bij falen → record blijft met `deletedAt`
Filter bij laden: `!m.deletedAt`.
### DashboardScreen
Laatste meting en grafiek: alleen metingen zonder `deletedAt`.
Laatste meting = meest recente `measuredAt` zonder `deletedAt`.
### Database (`db.ts`)
Nieuw kolom in schema: `deletedAt TEXT`.
Nieuwe functies:
- `getMeasurementsUpdatedSince(since: string)` — metingen met `updatedAt > since`
- `setDeletedAt(id: string)` — zet `deletedAt = now()`
- `removeMeasurement(id: string)` — harde delete
Verwijderd/onbruikbaar:
- `getUnsyncedMeasurements()` — niet meer nodig
- `getPendingMeasurements()` — niet meer nodig
- `markAsDeleted()` — vervangen door `setDeletedAt`
- `syncStatus` kolom — blijft in DB maar wordt niet meer gebruikt in code
---
## Fase 3 — Migratie (eerste sync)
Eerste keer dat de app de nieuwe API gebruikt (`lastSyncAt` is `null`):
- `GET /api/v1/measurements` (zonder `?since`) → alle remote metingen
- Merge met lokale DB: last-writer-wins, remote deleted overschrijft lokaal
- Alle lokale metingen (die niet deleted zijn) worden gepusht (`updatedAt > null` = alles)
- `lastSyncAt = now()`
- Vanaf dan: delta-sync
---
## Foutafhandeling
| Situatie | Gedrag |
|----------|--------|
| Geen netwerk bij opslaan | Lokaal opgeslagen, `updatedAt > lastSyncAt` dus next sync pakt op |
| Geen netwerk bij sync | Throw error, scherm toont melding |
| Geen API-config | Throw `'Geen API configuratie. Stel deze eerst in in Instellingen.'` |
| HTTP 401 (ongeldige token) | Throw `'Authenticatie mislukt. Controleer je token in Instellingen.'` |
| HTTP 5xx | Throw met statuscode, scherm toont melding |
---
## Testen
### Client-side (Jest)
- `mergeMeasurements()` unit tests: local wins, remote wins, deleted remote vs local
- `api.ts` calls mocken met fetch-mock
- `syncAll()` integratietest met gemockte API
### Server-side
- Handmatig testen met curl na deployment
---
## Implementatievolgorde
1. Server-side: nginx, MariaDB, PHP API → testen met curl
2. Client-side: types, api.ts, db.ts, syncService.ts, SettingsScreen → testen
3. Client-side: DashboardScreen, HistoryScreen, MeasurementDetailScreen, NewMeasurementScreen
4. Client-side: unit tests
5. Build APK, sideloaden, testen op telefoon

View file

@ -1,6 +1,7 @@
{ {
"cli": { "cli": {
"version": ">= 5.9.1" "version": ">= 5.9.1",
"appVersionSource": "local"
}, },
"build": { "build": {
"development": { "development": {

385
package-lock.json generated
View file

@ -13,14 +13,19 @@
"@react-navigation/native-stack": "^7.17.6", "@react-navigation/native-stack": "^7.17.6",
"expo": "~54.0.0", "expo": "~54.0.0",
"expo-secure-store": "~15.0.8", "expo-secure-store": "~15.0.8",
"expo-sharing": "~14.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-view-shot": "4.0.3",
"react-native-web": "^0.21.0"
}, },
"devDependencies": { "devDependencies": {
"@types/jest": "29.5.14", "@types/jest": "29.5.14",
@ -1734,25 +1739,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 +2147,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",
@ -3725,6 +3730,15 @@
"node": "18 || 20 || >=22" "node": "18 || 20 || >=22"
} }
}, },
"node_modules/base64-arraybuffer": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/base64-arraybuffer/-/base64-arraybuffer-1.0.2.tgz",
"integrity": "sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==",
"license": "MIT",
"engines": {
"node": ">= 0.6.0"
}
},
"node_modules/base64-js": { "node_modules/base64-js": {
"version": "1.5.1", "version": "1.5.1",
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
@ -4326,6 +4340,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 +4405,24 @@
"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-line-break": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/css-line-break/-/css-line-break-2.1.0.tgz",
"integrity": "sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w==",
"license": "MIT",
"dependencies": {
"utrie": "^1.0.2"
}
},
"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 +5160,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",
@ -5124,6 +5232,15 @@
"node": ">=20.16.0" "node": ">=20.16.0"
} }
}, },
"node_modules/expo-sharing": {
"version": "14.0.8",
"resolved": "https://registry.npmjs.org/expo-sharing/-/expo-sharing-14.0.8.tgz",
"integrity": "sha512-A1pPr2iBrxypFDCWVAESk532HK+db7MFXbvO2sCV9ienaFXAk7lIBm6bkqgE6vzRd9O3RGdEGzYx80cYlc089Q==",
"license": "MIT",
"peerDependencies": {
"expo": "*"
}
},
"node_modules/expo-sqlite": { "node_modules/expo-sqlite": {
"version": "16.0.10", "version": "16.0.10",
"resolved": "https://registry.npmjs.org/expo-sqlite/-/expo-sqlite-16.0.10.tgz", "resolved": "https://registry.npmjs.org/expo-sqlite/-/expo-sqlite-16.0.10.tgz",
@ -5151,6 +5268,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 +5748,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",
@ -5975,6 +6173,19 @@
"license": "MIT", "license": "MIT",
"peer": true "peer": true
}, },
"node_modules/html2canvas": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/html2canvas/-/html2canvas-1.4.1.tgz",
"integrity": "sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA==",
"license": "MIT",
"dependencies": {
"css-line-break": "^2.1.0",
"text-segmentation": "^1.0.3"
},
"engines": {
"node": ">=8.0.0"
}
},
"node_modules/http-errors": { "node_modules/http-errors": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
@ -6056,6 +6267,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 +6383,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 +9277,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 +9495,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 +9653,51 @@
"react-native": "*" "react-native": "*"
} }
}, },
"node_modules/react-native-view-shot": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/react-native-view-shot/-/react-native-view-shot-4.0.3.tgz",
"integrity": "sha512-USNjYmED7C0me02c1DxKA0074Hw+y/nxo+xJKlffMvfUWWzL5ELh/TJA/pTnVqFurIrzthZDPtDM7aBFJuhrHQ==",
"license": "MIT",
"dependencies": {
"html2canvas": "^1.4.1"
},
"peerDependencies": {
"react": "*",
"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 +10246,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 +10599,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",
@ -10507,6 +10808,15 @@
"node": "*" "node": "*"
} }
}, },
"node_modules/text-segmentation": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/text-segmentation/-/text-segmentation-1.0.3.tgz",
"integrity": "sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw==",
"license": "MIT",
"dependencies": {
"utrie": "^1.0.2"
}
},
"node_modules/thenify": { "node_modules/thenify": {
"version": "3.3.1", "version": "3.3.1",
"resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz",
@ -10673,6 +10983,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",
@ -10815,6 +11151,15 @@
"node": ">= 0.4.0" "node": ">= 0.4.0"
} }
}, },
"node_modules/utrie": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/utrie/-/utrie-1.0.2.tgz",
"integrity": "sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw==",
"license": "MIT",
"dependencies": {
"base64-arraybuffer": "^1.0.2"
}
},
"node_modules/uuid": { "node_modules/uuid": {
"version": "7.0.3", "version": "7.0.3",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-7.0.3.tgz", "resolved": "https://registry.npmjs.org/uuid/-/uuid-7.0.3.tgz",

View file

@ -8,14 +8,19 @@
"@react-navigation/native-stack": "^7.17.6", "@react-navigation/native-stack": "^7.17.6",
"expo": "~54.0.0", "expo": "~54.0.0",
"expo-secure-store": "~15.0.8", "expo-secure-store": "~15.0.8",
"expo-sharing": "~14.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-view-shot": "4.0.3",
"react-native-web": "^0.21.0"
}, },
"devDependencies": { "devDependencies": {
"@types/jest": "29.5.14", "@types/jest": "29.5.14",
@ -25,8 +30,8 @@
}, },
"scripts": { "scripts": {
"start": "expo start", "start": "expo start",
"android": "expo start --android", "android": "expo run:android",
"ios": "expo start --ios", "ios": "expo run:ios",
"web": "expo start --web", "web": "expo start --web",
"test": "jest" "test": "jest"
}, },

View file

@ -1,8 +1,11 @@
import React from 'react' import React, { useRef } from 'react'
import { View, Text, StyleSheet } from 'react-native' import { View, Text, StyleSheet, TouchableOpacity } from 'react-native'
import ViewShot from 'react-native-view-shot'
import * as Sharing from 'expo-sharing'
import { MaterialIcons } from '@expo/vector-icons'
import { Measurement } from '../types' import { Measurement } from '../types'
type Props = { measurement: Measurement | null } type Props = { measurements: Measurement[] }
const rows: { label: string; key: keyof Measurement; suffix: string }[] = [ const rows: { label: string; key: keyof Measurement; suffix: string }[] = [
{ label: 'Gewicht', key: 'weight', suffix: 'kg' }, { label: 'Gewicht', key: 'weight', suffix: 'kg' },
@ -15,8 +18,10 @@ const rows: { label: string; key: keyof Measurement; suffix: string }[] = [
{ label: 'Stofwisseling', key: 'bmr', suffix: 'kcal' }, { label: 'Stofwisseling', key: 'bmr', suffix: 'kcal' },
] ]
export default function LatestMeasurementCard({ measurement }: Props) { export default function LatestMeasurementCard({ measurements }: Props) {
if (!measurement) { const viewShotRef = useRef<ViewShot>(null)
if (measurements.length === 0) {
return ( return (
<View style={styles.card}> <View style={styles.card}>
<Text style={styles.emptyText}>Nog geen metingen</Text> <Text style={styles.emptyText}>Nog geen metingen</Text>
@ -24,31 +29,89 @@ export default function LatestMeasurementCard({ measurement }: Props) {
) )
} }
const latest = measurements[0]
const previous = measurements[1]
const diff = previous ? (latest.weight - previous.weight) : null
const fatMassDiff = previous ? (latest.fatMass - previous.fatMass) : null
const muscleMassDiff = previous ? (latest.muscleMass - previous.muscleMass) : null
let positiveComment: string | null = null
if (previous && diff !== null && diff >= 0) {
const parts: string[] = []
if (fatMassDiff !== null && fatMassDiff < 0) parts.push('Vetje naar beneden')
if (muscleMassDiff !== null && muscleMassDiff > 0) parts.push('💪')
if (parts.length > 0) positiveComment = parts.join(' ')
}
const handleShare = async () => {
try {
const uri = await viewShotRef.current?.capture?.()
if (uri) {
await Sharing.shareAsync(uri, { mimeType: 'image/png' })
}
} catch {}
}
return ( return (
<View style={styles.card}> <View style={styles.card}>
<Text style={styles.title}>Laatste meting</Text> <View style={styles.header}>
<Text style={styles.date}>{new Date(measurement.measuredAt).toLocaleDateString('nl-NL')}</Text> <View style={{ flex: 1 }} />
{rows.map(r => ( <TouchableOpacity onPress={handleShare} hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}>
<View key={r.key} style={styles.row}> <MaterialIcons name="share" size={24} color="#2196F3" />
<Text style={styles.rowLabel}>{r.label}</Text> </TouchableOpacity>
<Text style={styles.rowValue}> </View>
{typeof measurement[r.key] === 'number'
? (measurement[r.key] as number).toFixed(1) <ViewShot ref={viewShotRef} options={{ format: 'png', quality: 1 }}>
: measurement[r.key]}{' '} <View style={styles.shotContent}>
{r.suffix} {previous && (
</Text> <View style={styles.previous}>
<Text style={styles.previousTitle}>Voorlaatste meting</Text>
<Text style={styles.previousDate}>
{new Date(previous.measuredAt).toLocaleDateString('nl-NL')} — {previous.weight.toFixed(1)} kg
</Text>
<Text style={styles.diffLabel}>
Verschil {diff !== null && (diff > 0 ? '+' : '')}{diff?.toFixed(1)} kg
</Text>
{positiveComment && (
<Text style={styles.positiveComment}>{positiveComment}</Text>
)}
<View style={styles.divider} />
</View>
)}
<Text style={styles.title}>Laatste meting</Text>
<Text style={styles.date}>{new Date(latest.measuredAt).toLocaleDateString('nl-NL')}</Text>
{rows.map(r => (
<View key={r.key} style={styles.row}>
<Text style={styles.rowLabel}>{r.label}</Text>
<Text style={styles.rowValue}>
{typeof latest[r.key] === 'number'
? (latest[r.key] as number).toFixed(1)
: latest[r.key]}{' '}
{r.suffix}
</Text>
</View>
))}
</View> </View>
))} </ViewShot>
</View> </View>
) )
} }
const styles = StyleSheet.create({ 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 },
header: { flexDirection: 'row', alignItems: 'center', marginBottom: 8 },
shotContent: { backgroundColor: '#fff', padding: 16 },
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 },
previous: { marginBottom: 4 },
divider: { height: 1, backgroundColor: '#eee', marginVertical: 12 },
previousTitle: { fontSize: 18, fontWeight: '700', marginBottom: 4 },
previousDate: { fontSize: 14, color: '#666', marginBottom: 4 },
diffLabel: { fontSize: 16, fontWeight: '700', color: '#2196F3' },
positiveComment: { fontSize: 14, color: '#4CAF50', marginTop: 4 },
}) })

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,20 +66,18 @@ 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',
deleted: 0,
} }
onSubmit(m) onSubmit(m)
} }
@ -141,7 +146,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>
@ -150,7 +155,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, useWindowDimensions } 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,
@ -24,13 +27,12 @@ function SingleChart({
color: string color: string
unit: string unit: string
}) { }) {
const { width } = useWindowDimensions()
return ( return (
<View style={styles.chartBlock}> <View style={styles.chartBlock}>
<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={width - 32} width={chartWidth}
height={180} height={180}
yAxisSuffix={unit} yAxisSuffix={unit}
chartConfig={{ chartConfig={{
@ -58,7 +60,63 @@ 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)
const fatMassPoints = dataPoints.filter(m => m.bodyFatPercentage > 0)
const muscleMassPoints = dataPoints.filter(m => m.musclePercentage > 0)
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"
/>
{fatMassPoints.length >= 2 && (
<SingleChart
title="Vetmassa"
data={fatMassPoints.map(m => m.fatMass)}
labels={prepareLabels(fatMassPoints)}
color={COLORS.fatMass}
unit=" kg"
/>
)}
{muscleMassPoints.length >= 2 && (
<SingleChart
title="Spiermassa"
data={muscleMassPoints.map(m => m.muscleMass)}
labels={prepareLabels(muscleMassPoints)}
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
} }
@ -67,52 +125,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,7 +1,8 @@
import * as SQLite from 'expo-sqlite' import * as SQLite from 'expo-sqlite'
import { Measurement, SyncStatus } from '../types' import { Measurement } from '../types'
let db: SQLite.SQLiteDatabase | null = null let db: SQLite.SQLiteDatabase | null = null
let dbReady = false
export async function initDatabase(): Promise<void> { export async function initDatabase(): Promise<void> {
if (db) return if (db) return
@ -20,81 +21,112 @@ export async function initDatabase(): Promise<void> {
measuredAt TEXT NOT NULL, measuredAt TEXT NOT NULL,
createdAt TEXT NOT NULL, createdAt TEXT NOT NULL,
updatedAt TEXT NOT NULL, updatedAt TEXT NOT NULL,
syncStatus TEXT NOT NULL DEFAULT 'local', syncStatus TEXT NOT NULL DEFAULT 'pending',
deleted INTEGER NOT NULL DEFAULT 0 deletedAt TEXT
); );
`) `)
await db.execAsync(`ALTER TABLE measurements ADD COLUMN deleted INTEGER NOT NULL DEFAULT 0`) try {
.catch(() => {}) await db.execAsync('ALTER TABLE measurements ADD COLUMN deletedAt TEXT')
} catch {}
// Migrate existing records to crossbreed syncStatus model
if (!dbReady) {
try {
await db.execAsync("UPDATE measurements SET syncStatus = 'synced' WHERE syncStatus = 'local'")
} catch {}
dbReady = true
}
} }
export async function insertMeasurement(m: Measurement): Promise<void> { export async function insertMeasurement(m: Measurement): Promise<void> {
await db!.runAsync( await db!.runAsync(
`INSERT INTO measurements (id, weight, bmi, bodyFatPercentage, fatMass, musclePercentage, muscleMass, visceralFat, bmr, measuredAt, createdAt, updatedAt, syncStatus, deleted) `INSERT INTO measurements (id, weight, bmi, bodyFatPercentage, fatMass, musclePercentage, muscleMass, visceralFat, bmr, measuredAt, createdAt, updatedAt, deletedAt)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[m.id, m.weight, m.bmi, m.bodyFatPercentage, m.fatMass, m.musclePercentage, m.muscleMass, m.visceralFat, m.bmr, m.measuredAt, m.createdAt, m.updatedAt, m.syncStatus, 0] [m.id, m.weight, m.bmi, m.bodyFatPercentage, m.fatMass, m.musclePercentage, m.muscleMass, m.visceralFat, m.bmr, m.measuredAt, m.createdAt, m.updatedAt, m.deletedAt ?? null]
) )
} }
function activeOnly(): string {
return 'deleted = 0'
}
export async function getAllMeasurements(): Promise<Measurement[]> { export async function getAllMeasurements(): Promise<Measurement[]> {
const rows = await db!.getAllAsync<any>( const rows = await db!.getAllAsync<any>(
`SELECT * FROM measurements WHERE ${activeOnly()} ORDER BY measuredAt DESC` 'SELECT * FROM measurements WHERE deletedAt IS NULL ORDER BY measuredAt DESC'
)
return rows as Measurement[]
}
export async function getAllMeasurementsForSync(): Promise<Measurement[]> {
const rows = await db!.getAllAsync<any>(
'SELECT * FROM measurements ORDER BY measuredAt DESC'
) )
return rows as Measurement[] return rows as Measurement[]
} }
export async function getLatestMeasurement(): Promise<Measurement | null> { export async function getLatestMeasurement(): Promise<Measurement | null> {
const rows = await db!.getAllAsync<any>( const rows = await db!.getAllAsync<any>(
`SELECT * FROM measurements WHERE ${activeOnly()} ORDER BY measuredAt DESC LIMIT 1` 'SELECT * FROM measurements WHERE deletedAt IS NULL ORDER BY measuredAt DESC LIMIT 1'
) )
return rows.length > 0 ? (rows[0] as Measurement) : null return rows.length > 0 ? (rows[0] as Measurement) : null
} }
export async function getLatestMeasurements(count: number): Promise<Measurement[]> {
const rows = await db!.getAllAsync<any>(
'SELECT * FROM measurements WHERE deletedAt IS NULL ORDER BY measuredAt DESC LIMIT ?',
[count]
)
return rows as Measurement[]
}
export async function getMeasurementsSince(date: string): Promise<Measurement[]> {
const rows = await db!.getAllAsync<any>(
'SELECT * FROM measurements WHERE measuredAt >= ? AND deletedAt IS NULL ORDER BY measuredAt DESC',
[date]
)
return rows as Measurement[]
}
export async function getMeasurementByDate(date: string): Promise<Measurement | null> { export async function getMeasurementByDate(date: string): Promise<Measurement | null> {
const day = date.slice(0, 10) const day = date.slice(0, 10)
const rows = await db!.getAllAsync<any>( const rows = await db!.getAllAsync<any>(
`SELECT * FROM measurements WHERE ${activeOnly()} AND measuredAt LIKE ? LIMIT 1`, "SELECT * FROM measurements WHERE measuredAt LIKE ? AND deletedAt IS NULL LIMIT 1",
[`${day}%`] [`${day}%`]
) )
return rows.length > 0 ? (rows[0] as Measurement) : null return rows.length > 0 ? (rows[0] as Measurement) : null
} }
export async function updateMeasurement(id: string, syncStatus: SyncStatus): Promise<void> {
await db!.runAsync(
'UPDATE measurements SET syncStatus = ?, updatedAt = ? WHERE id = ?',
[syncStatus, new Date().toISOString(), id]
)
}
export async function upsertMeasurement(m: Measurement): Promise<void> { export async function upsertMeasurement(m: Measurement): Promise<void> {
await db!.runAsync( await db!.runAsync(
`INSERT OR REPLACE INTO measurements (id, weight, bmi, bodyFatPercentage, fatMass, musclePercentage, muscleMass, visceralFat, bmr, measuredAt, createdAt, updatedAt, syncStatus, deleted) `INSERT OR REPLACE INTO measurements (id, weight, bmi, bodyFatPercentage, fatMass, musclePercentage, muscleMass, visceralFat, bmr, measuredAt, createdAt, updatedAt, deletedAt)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[m.id, m.weight, m.bmi, m.bodyFatPercentage, m.fatMass, m.musclePercentage, m.muscleMass, m.visceralFat, m.bmr, m.measuredAt, m.createdAt, m.updatedAt, m.syncStatus, m.deleted] [m.id, m.weight, m.bmi, m.bodyFatPercentage, m.fatMass, m.musclePercentage, m.muscleMass, m.visceralFat, m.bmr, m.measuredAt, m.createdAt, m.updatedAt, m.deletedAt ?? null]
) )
} }
export async function getUnsyncedMeasurements(): Promise<Measurement[]> { export async function getMeasurementsUpdatedSince(since: string): Promise<Measurement[]> {
const rows = await db!.getAllAsync<any>( const rows = await db!.getAllAsync<any>(
"SELECT * FROM measurements WHERE syncStatus = 'local' ORDER BY updatedAt ASC" 'SELECT * FROM measurements WHERE updatedAt > ? ORDER BY updatedAt ASC',
[since]
) )
return rows as Measurement[] return rows as Measurement[]
} }
export async function deleteMeasurement(id: string): Promise<void> { export async function setDeletedAt(id: string): Promise<void> {
const now = new Date().toISOString()
await db!.runAsync( await db!.runAsync(
"UPDATE measurements SET deleted = 1, syncStatus = 'local', updatedAt = ? WHERE id = ?", 'UPDATE measurements SET deletedAt = ?, updatedAt = ? WHERE id = ?',
[new Date().toISOString(), id] [now, now, id]
) )
} }
export async function getAllMeasurementsIncludingDeleted(): Promise<Measurement[]> { export async function removeMeasurement(id: string): Promise<void> {
await db!.runAsync('DELETE FROM measurements WHERE id = ?', [id])
}
export async function updateSyncStatus(id: string, status: string): Promise<void> {
await db!.runAsync('UPDATE measurements SET syncStatus = ? WHERE id = ?', [status, id])
}
export async function getPendingRecords(): Promise<Measurement[]> {
const rows = await db!.getAllAsync<any>( const rows = await db!.getAllAsync<any>(
'SELECT * FROM measurements ORDER BY measuredAt DESC' "SELECT * FROM measurements WHERE syncStatus IN ('pending', 'pending-delete') ORDER BY updatedAt ASC"
) )
return rows as Measurement[] return rows as Measurement[]
} }

View file

@ -1,8 +1,9 @@
import React, { useCallback, useState } from 'react' import React, { useCallback, useRef, useState } from 'react'
import { View, ScrollView, TouchableOpacity, Text, StyleSheet } from 'react-native' import { View, ScrollView, TouchableOpacity, Text, StyleSheet } from 'react-native'
import { useFocusEffect } from '@react-navigation/native' import { useFocusEffect } from '@react-navigation/native'
import { useSafeAreaInsets } from 'react-native-safe-area-context' import { useSafeAreaInsets } from 'react-native-safe-area-context'
import { initDatabase, getAllMeasurements, getLatestMeasurement } from '../database/db' import { getCached, getVersion, refreshCache } from '../store/measurementStore'
import { initDatabase, getLatestMeasurements } from '../database/db'
import { Measurement } from '../types' import { Measurement } from '../types'
import LatestMeasurementCard from '../components/LatestMeasurementCard' import LatestMeasurementCard from '../components/LatestMeasurementCard'
import WeightChart from '../components/WeightChart' import WeightChart from '../components/WeightChart'
@ -11,22 +12,76 @@ type Props = { navigation: any }
export default function DashboardScreen({ navigation }: Props) { export default function DashboardScreen({ navigation }: Props) {
const insets = useSafeAreaInsets() const insets = useSafeAreaInsets()
const [latest, setLatest] = useState<Measurement | null>(null) const [latestTwo, setLatestTwo] = useState<Measurement[]>([])
const [all, setAll] = useState<Measurement[]>([]) const [all, setAll] = useState<Measurement[]>([])
const [chartKey, setChartKey] = useState(0)
const seenVersion = useRef(0)
useFocusEffect( useFocusEffect(
useCallback(() => { useCallback(() => {
let active = true
const load = async () => { const load = async () => {
await initDatabase() await initDatabase()
const allM = await getAllMeasurements()
setAll(allM) const two = await getLatestMeasurements(2)
const latestM = await getLatestMeasurement() if (!active) return
setLatest(latestM) setLatestTwo(two)
await refreshCache()
if (!active) return
const v = getVersion()
if (v !== seenVersion.current) {
setAll(getCached())
setChartKey(v)
seenVersion.current = v
}
try {
const { syncAll } = await import('../sync/syncService')
const result = await syncAll()
if (!active) return
if (result.deleted > 0 || result.pulled > 0) {
await refreshCache()
const v2 = getVersion()
setAll(getCached())
setChartKey(v2)
seenVersion.current = v2
}
} catch {
console.warn('Sync failed')
}
} }
load() load()
return () => { active = false }
}, []) }, [])
) )
if (all.length === 0 && latestTwo.length === 0) {
return (
<View style={styles.outer}>
<View style={styles.navBar}>
<TouchableOpacity onPress={() => navigation.navigate('History')}>
<Text style={styles.navLink}>Historie</Text>
</TouchableOpacity>
<TouchableOpacity onPress={() => navigation.navigate('Settings')}>
<Text style={styles.navLink}>Instellingen</Text>
</TouchableOpacity>
</View>
<View style={styles.loadingContainer}>
<Text style={styles.loading}>Laden...</Text>
</View>
<TouchableOpacity
style={[styles.fab, { marginBottom: insets.bottom + 16 }]}
onPress={() => navigation.navigate('NewMeasurement')}
>
<Text style={styles.fabText}>+ Nieuwe meting</Text>
</TouchableOpacity>
</View>
)
}
const display = all.length > 0 ? all : latestTwo
return ( return (
<View style={styles.outer}> <View style={styles.outer}>
<View style={styles.navBar}> <View style={styles.navBar}>
@ -38,8 +93,8 @@ export default function DashboardScreen({ navigation }: Props) {
</TouchableOpacity> </TouchableOpacity>
</View> </View>
<ScrollView style={styles.container}> <ScrollView style={styles.container}>
<LatestMeasurementCard measurement={latest} /> <LatestMeasurementCard measurements={display} />
<WeightChart measurements={all} /> {all.length >= 2 && <WeightChart key={chartKey} measurements={all} />}
</ScrollView> </ScrollView>
<TouchableOpacity <TouchableOpacity
@ -59,4 +114,6 @@ const styles = StyleSheet.create({
navLink: { fontSize: 16, color: '#2196F3', fontWeight: '600' }, navLink: { fontSize: 16, color: '#2196F3', fontWeight: '600' },
fab: { backgroundColor: '#4CAF50', padding: 16, borderRadius: 8, margin: 16, alignItems: 'center' }, fab: { backgroundColor: '#4CAF50', padding: 16, borderRadius: 8, margin: 16, alignItems: 'center' },
fabText: { color: '#fff', fontSize: 16, fontWeight: '700' }, fabText: { color: '#fff', fontSize: 16, fontWeight: '700' },
loadingContainer: { flex: 1, justifyContent: 'center', alignItems: 'center' },
loading: { fontSize: 16, color: '#999' },
}) })

View file

@ -1,7 +1,8 @@
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, Alert } 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, setDeletedAt, updateSyncStatus } from '../database/db'
import { refreshCache } from '../store/measurementStore'
import { Measurement } from '../types' import { Measurement } from '../types'
import MeasurementRow from '../components/MeasurementRow' import MeasurementRow from '../components/MeasurementRow'
@ -61,13 +62,9 @@ export default function HistoryScreen({ navigation }: Props) {
style: 'destructive', style: 'destructive',
onPress: async () => { onPress: async () => {
await initDatabase() await initDatabase()
await deleteMeasurement(m.id) await setDeletedAt(m.id)
try { await updateSyncStatus(m.id, 'pending-delete')
const { syncAll } = await import('../sync/syncService') await refreshCache()
await syncAll()
} catch (e) {
console.warn('Sync failed after delete:', e)
}
load() load()
}, },
}, },
@ -82,7 +79,10 @@ 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)} /> <MeasurementRow
measurement={item}
onPress={() => navigation.navigate('MeasurementDetail', { measurementId: item.id })}
/>
)} )}
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,134 @@
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, setDeletedAt, updateSyncStatus } from '../database/db'
import { refreshCache } from '../store/measurementStore'
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 setDeletedAt(measurementId)
await updateSyncStatus(measurementId, 'pending-delete')
await refreshCache()
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

@ -1,101 +1,90 @@
import React, { useCallback, useEffect, useState } from 'react' import React, { useCallback, useState } from 'react'
import { View, Text, Alert } from 'react-native' 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, import { refreshCache } from '../store/measurementStore'
insertMeasurement, import { showSuccess, showError } from '../utils/toast'
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)
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) },
]
)
return
}
doSave(m)
}
const doSave = async (m: Measurement, existing?: Measurement) => {
if (existing) {
m.id = existing.id
m.createdAt = existing.createdAt
await upsertMeasurement(m) await upsertMeasurement(m)
} else { } 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
upsertMeasurement(m).then(() => doSave(m))
}},
]
)
return
}
await insertMeasurement(m) await insertMeasurement(m)
} }
doSync() await doSave(m)
} }
const doSync = async () => { const doSave = async (m: Measurement) => {
await refreshCache()
navigation.goBack()
uploadInBackground(m)
}
const uploadInBackground = async (m: Measurement) => {
try { try {
const { syncAll } = await import('../sync/syncService') const { uploadMeasurement } = await import('../sync/syncService')
await syncAll() await uploadMeasurement(m)
Alert.alert('Opgeslagen & gesynct', 'Meting is opgeslagen en gesynchroniseerd', [ showSuccess('Opgeslagen & gesynct')
{ text: 'OK', onPress: () => navigation.goBack() },
])
} catch { } catch {
Alert.alert('Opgeslagen', 'Meting is opgeslagen (sync mislukt)', [ showError('Opgeslagen (sync mislukt)')
{ text: 'OK', onPress: () => navigation.goBack() },
])
} }
} }
@ -111,6 +100,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

@ -1,11 +1,11 @@
import React, { useEffect, useState } from 'react' import React, { useEffect, useState } from 'react'
import { View, Text, TextInput, TouchableOpacity, StyleSheet, Alert, ScrollView } from 'react-native' import { View, Text, TextInput, TouchableOpacity, StyleSheet, Alert, ScrollView } from 'react-native'
import * as SecureStore from 'expo-secure-store' import * as SecureStore from 'expo-secure-store'
import { WebDAVConfig } from '../types' import { ApiConfig } from '../types'
const CONFIG_KEY = 'weegschaal_webdav_config' const CONFIG_KEY = 'weegschaal_api_config'
async function loadConfig(): Promise<WebDAVConfig | null> { async function loadConfig(): Promise<ApiConfig | null> {
try { try {
const json = await SecureStore.getItemAsync(CONFIG_KEY) const json = await SecureStore.getItemAsync(CONFIG_KEY)
return json ? JSON.parse(json) : null return json ? JSON.parse(json) : null
@ -14,7 +14,7 @@ async function loadConfig(): Promise<WebDAVConfig | null> {
} }
} }
async function saveConfig(config: WebDAVConfig): Promise<void> { export async function saveConfig(config: ApiConfig): Promise<void> {
await SecureStore.setItemAsync(CONFIG_KEY, JSON.stringify(config)) await SecureStore.setItemAsync(CONFIG_KEY, JSON.stringify(config))
} }
@ -22,50 +22,43 @@ async function deleteConfig(): Promise<void> {
await SecureStore.deleteItemAsync(CONFIG_KEY) await SecureStore.deleteItemAsync(CONFIG_KEY)
} }
export async function getWebDAVConfig(): Promise<WebDAVConfig | null> { export async function getApiConfig(): Promise<ApiConfig | null> {
return loadConfig() return loadConfig()
} }
export default function SettingsScreen() { export default function SettingsScreen() {
const [url, setUrl] = useState('https://nextcloud.eddydevink.nl/remote.php/dav/files/eddy') const [url, setUrl] = useState('https://weegschaal.eddydevink.nl')
const [username, setUsername] = useState('') const [token, setToken] = useState('')
const [password, setPassword] = useState('') const [showToken, setShowToken] = useState(false)
const [showPassword, setShowPassword] = useState(false)
const [syncing, setSyncing] = useState(false) const [syncing, setSyncing] = useState(false)
useEffect(() => { useEffect(() => {
loadConfig().then(c => { loadConfig().then(c => {
if (c) { if (c) {
setUrl(c.url) setUrl(c.url)
setUsername(c.username) setToken(c.token)
setPassword(c.password)
} }
}) })
}, []) }, [])
const handleSave = async () => { const handleSave = async () => {
await saveConfig({ url, username, password }) await saveConfig({ url, token })
Alert.alert('Opgeslagen', 'WebDAV instellingen zijn opgeslagen') Alert.alert('Opgeslagen', 'API instellingen zijn opgeslagen')
} }
const handleClear = () => { const handleClear = () => {
Alert.alert( Alert.alert('Instellingen wissen?', 'API configuratie definitief verwijderen?', [
'Instellingen wissen?', { text: 'Annuleren', style: 'cancel' },
'WebDAV configuratie definitief verwijderen? Je kunt deze later opnieuw invoeren.', {
[ text: 'Ja, wissen',
{ text: 'Annuleren', style: 'cancel' }, style: 'destructive',
{ onPress: async () => {
text: 'Ja, wissen', await deleteConfig()
style: 'destructive', setUrl('')
onPress: async () => { setToken('')
await deleteConfig()
setUrl('')
setUsername('')
setPassword('')
},
}, },
] },
) ])
} }
const handleSync = async () => { const handleSync = async () => {
@ -73,7 +66,11 @@ export default function SettingsScreen() {
try { try {
const { syncAll } = await import('../sync/syncService') const { syncAll } = await import('../sync/syncService')
const result = await syncAll() const result = await syncAll()
Alert.alert('Sync voltooid', `Lokaal: ${result.local}, WebDAV: ${result.remote}, samengevoegd: ${result.merged}`) let msg = `Opgehaald: ${result.pulled}, gepusht: ${result.pushed}, verwijderd: ${result.deleted}`
if (result.errors.length > 0) {
msg += `\n\nFouten: ${result.errors.join(', ')}`
}
Alert.alert('Sync voltooid', msg)
} catch (e: any) { } catch (e: any) {
Alert.alert('Sync fout', e?.message ?? 'Onbekende fout') Alert.alert('Sync fout', e?.message ?? 'Onbekende fout')
} finally { } finally {
@ -83,38 +80,30 @@ export default function SettingsScreen() {
return ( return (
<ScrollView style={styles.container} contentContainerStyle={styles.content}> <ScrollView style={styles.container} contentContainerStyle={styles.content}>
<Text style={styles.sectionTitle}>WebDAV (Nextcloud)</Text> <Text style={styles.sectionTitle}>REST API</Text>
<Text style={styles.label}>URL</Text> <Text style={styles.label}>API URL</Text>
<TextInput <TextInput
style={styles.input} style={styles.input}
value={url} value={url}
onChangeText={setUrl} onChangeText={setUrl}
placeholder="https://nextcloud.eddydevink.nl/remote.php/dav/files/gebruiker/" placeholder="https://weegschaal.eddydevink.nl"
autoCapitalize="none" autoCapitalize="none"
autoCorrect={false} autoCorrect={false}
/> />
<Text style={styles.label}>Gebruikersnaam</Text> <Text style={styles.label}>Token</Text>
<TextInput
style={styles.input}
value={username}
onChangeText={setUsername}
autoCapitalize="none"
autoCorrect={false}
/>
<Text style={styles.label}>Wachtwoord</Text>
<View style={styles.passwordRow}> <View style={styles.passwordRow}>
<TextInput <TextInput
style={styles.passwordInput} style={styles.passwordInput}
value={password} value={token}
onChangeText={setPassword} onChangeText={setToken}
secureTextEntry={!showPassword} secureTextEntry={!showToken}
autoCapitalize="none" autoCapitalize="none"
autoCorrect={false}
/> />
<TouchableOpacity style={styles.eyeButton} onPress={() => setShowPassword(!showPassword)}> <TouchableOpacity style={styles.eyeButton} onPress={() => setShowToken(!showToken)}>
<Text style={styles.eyeIcon}>{showPassword ? '👁' : '👁‍🗨'}</Text> <Text style={styles.eyeIcon}>{showToken ? '👁' : '🔒'}</Text>
</TouchableOpacity> </TouchableOpacity>
</View> </View>

View file

@ -0,0 +1,20 @@
import { Measurement } from '../types'
import { initDatabase, getAllMeasurements } from '../database/db'
let cache: Measurement[] = []
let version = 0
export async function refreshCache(): Promise<Measurement[]> {
await initDatabase()
cache = await getAllMeasurements()
version++
return cache
}
export function getCached(): Measurement[] {
return cache
}
export function getVersion(): number {
return version
}

70
src/sync/api.ts Normal file
View file

@ -0,0 +1,70 @@
import { Measurement } from '../types'
import { ApiConfig } from '../types'
export class ApiError extends Error {
status: number
constructor(message: string, status: number) {
super(message)
this.name = 'ApiError'
this.status = status
}
}
function authHeaders(token: string): Record<string, string> {
return {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
}
}
export type Api = ReturnType<typeof createApi>
export function createApi(config: ApiConfig) {
const base = config.url.replace(/\/+$/, '')
return {
async getMeasurements(since?: string): Promise<Measurement[]> {
let url = `${base}/api/v1/measurements`
if (since) {
url += `?since=${encodeURIComponent(since)}`
}
const response = await fetch(url, {
headers: { Authorization: `Bearer ${config.token}` },
})
if (response.status === 401) {
throw new ApiError('Authenticatie mislukt. Controleer je token in Instellingen.', 401)
}
if (!response.ok) {
throw new ApiError(`Serverfout bij ophalen metingen: ${response.status}`, response.status)
}
return response.json()
},
async createMeasurement(m: Measurement): Promise<void> {
const response = await fetch(`${base}/api/v1/measurements`, {
method: 'POST',
headers: authHeaders(config.token),
body: JSON.stringify(m),
})
if (response.status === 401) {
throw new ApiError('Authenticatie mislukt. Controleer je token in Instellingen.', 401)
}
if (!response.ok) {
throw new ApiError(`Serverfout bij opslaan meting: ${response.status}`, response.status)
}
},
async deleteMeasurement(id: string): Promise<void> {
const response = await fetch(`${base}/api/v1/measurements/${id}`, {
method: 'DELETE',
headers: { Authorization: `Bearer ${config.token}` },
})
if (response.status === 401) {
throw new ApiError('Authenticatie mislukt. Controleer je token in Instellingen.', 401)
}
if (!response.ok) {
throw new ApiError(`Serverfout bij verwijderen meting: ${response.status}`, response.status)
}
},
}
}

View file

@ -1,139 +1,146 @@
import { Measurement } from '../types' import { Measurement } from '../types'
import { initDatabase, getAllMeasurementsIncludingDeleted, upsertMeasurement } from '../database/db' import {
import { getWebDAVConfig } from '../screens/SettingsScreen' initDatabase,
import { encodeBase64 } from '../utils/base64' getAllMeasurementsForSync,
upsertMeasurement,
getPendingRecords,
updateSyncStatus,
removeMeasurement,
} from '../database/db'
import { createApi, ApiError } from './api'
import * as SecureStore from 'expo-secure-store'
const WEBDAV_PATH = 'weegschaal/measurements.json' const LAST_SYNC_KEY = 'weegschaal_last_sync'
const SYNC_TIMEOUT_MS = 15_000
type SyncResult = { local: number; remote: number; merged: number } type SyncResult = {
pushed: number
deleted: number
pulled: number
errors: string[]
}
export function mergeMeasurements(local: Measurement[], remote: Measurement[]): Measurement[] { export function mergeMeasurements(local: Measurement[], remote: any[]): Measurement[] {
const map = new Map<string, Measurement>() const localMap = new Map(local.map(m => [m.id, m]))
const resultMap = new Map(local.map(m => [m.id, m]))
for (const m of local) { for (const r of remote) {
map.set(m.id, m) const localRec = localMap.get(r.id)
const isDeleted = !!(r as any).deleted
const localPending =
localRec?.syncStatus === 'pending' ||
localRec?.syncStatus === 'pending-delete'
if (isDeleted) {
if (!localRec || localPending) continue
if (new Date(r.updatedAt) > new Date(localRec.updatedAt)) {
resultMap.set(r.id, {
...r,
syncStatus: 'synced',
deletedAt: (r as any).deletedAt || r.updatedAt,
} as Measurement)
}
continue
}
if (localPending) continue
if (localRec) {
if (new Date(r.updatedAt) > new Date(localRec.updatedAt)) {
resultMap.set(r.id, { ...r, syncStatus: 'synced' } as Measurement)
}
} else {
resultMap.set(r.id, { ...r, syncStatus: 'synced' } as Measurement)
}
} }
for (const m of remote) { return Array.from(resultMap.values())
const existing = map.get(m.id) }
if (!existing) {
map.set(m.id, { ...m, syncStatus: 'synced' }) export async function uploadMeasurement(m: Measurement): Promise<void> {
const { getApiConfig } = await import('../screens/SettingsScreen')
const config = await getApiConfig()
if (!config) throw new Error('Geen API configuratie. Stel deze eerst in in Instellingen.')
const api = createApi(config)
await api.createMeasurement(m)
await updateSyncStatus(m.id, 'synced')
}
export async function syncAll(): Promise<SyncResult> {
const { getApiConfig } = await import('../screens/SettingsScreen')
const config = await getApiConfig()
if (!config) throw new Error('Geen API configuratie. Stel deze eerst in in Instellingen.')
const api = createApi(config)
await initDatabase()
const errors: string[] = []
let pushed = 0
let deleted = 0
let pulled = 0
// === PUSH phase — outbox first ===
const pending = await getPendingRecords()
for (const m of pending) {
if (m.deletedAt) {
try {
await api.deleteMeasurement(m.id)
await removeMeasurement(m.id)
deleted++
} catch (e: any) {
if (e instanceof ApiError && e.status === 404) {
await removeMeasurement(m.id)
deleted++
} else {
errors.push(e?.message ?? 'Delete failed')
}
}
} else { } else {
const remoteNewer = new Date(m.updatedAt) > new Date(existing.updatedAt) try {
if (remoteNewer) { await api.createMeasurement(m)
map.set(m.id, { ...m, syncStatus: 'synced' }) await updateSyncStatus(m.id, 'synced')
pushed++
} catch (e: any) {
errors.push(e?.message ?? 'Create failed')
} }
} }
} }
return Array.from(map.values()).filter(m => !m.deleted) // === PULL phase — fetch remote changes ===
} const lastSyncAt = await SecureStore.getItemAsync(LAST_SYNC_KEY)
const remote = await api.getMeasurements(lastSyncAt || undefined)
async function fetchRemote(config: { url: string; username: string; password: string }): Promise<Measurement[]> { if (remote.length > 0) {
const url = `${config.url.replace(/\/+$/, '')}/${WEBDAV_PATH}` const local = await getAllMeasurementsForSync()
const auth = encodeBase64(`${config.username}:${config.password}`) const merged = mergeMeasurements(local, remote)
const controller = new AbortController() const localMap = new Map(local.map(m => [m.id, m]))
const timer = setTimeout(() => controller.abort(), SYNC_TIMEOUT_MS)
try { for (const m of merged) {
const response = await fetch(url, { if (m.deletedAt) {
headers: { Authorization: `Basic ${auth}` }, await removeMeasurement(m.id)
signal: controller.signal, const localRec = localMap.get(m.id)
}) if (localRec && !localRec.deletedAt) {
deleted++
}
} else {
const localRec = localMap.get(m.id)
const isNew = !localRec
const isUpdated =
localRec &&
new Date(m.updatedAt).getTime() !==
new Date(localRec.updatedAt).getTime()
if (response.status === 404) { if (isNew || isUpdated) {
return [] await upsertMeasurement(m)
await updateSyncStatus(m.id, 'synced')
pulled++
}
}
} }
if (!response.ok) {
throw new Error(`WebDAV fetch failed: ${response.status}`)
}
return response.json()
} finally {
clearTimeout(timer)
}
}
async function createDir(config: { url: string; username: string; password: string }): Promise<void> {
const base = config.url.replace(/\/+$/, '')
const dirUrl = `${base}/weegschaal`
const auth = encodeBase64(`${config.username}:${config.password}`)
const controller = new AbortController()
const timer = setTimeout(() => controller.abort(), SYNC_TIMEOUT_MS)
try {
const response = await fetch(dirUrl, {
method: 'MKCOL',
headers: { Authorization: `Basic ${auth}` },
signal: controller.signal,
})
if (response.status !== 201 && response.status !== 405) {
throw new Error(`WebDAV directory create failed: ${response.status}`)
}
} finally {
clearTimeout(timer)
}
}
async function uploadRemote(
config: { url: string; username: string; password: string },
measurements: Measurement[]
): Promise<void> {
const base = config.url.replace(/\/+$/, '')
const url = `${base}/${WEBDAV_PATH}`
const body = JSON.stringify(measurements)
const auth = encodeBase64(`${config.username}:${config.password}`)
await createDir(config)
const controller = new AbortController()
const timer = setTimeout(() => controller.abort(), SYNC_TIMEOUT_MS)
try {
const response = await fetch(url, {
method: 'PUT',
headers: {
Authorization: `Basic ${auth}`,
'Content-Type': 'application/json',
},
signal: controller.signal,
})
if (!response.ok) {
throw new Error(`WebDAV upload failed: ${response.status}`)
}
} finally {
clearTimeout(timer)
}
}
export async function syncAll(): Promise<SyncResult> {
const config = await getWebDAVConfig()
if (!config) {
throw new Error('Geen WebDAV configuratie. Stel deze eerst in in Instellingen.')
}
await initDatabase()
const local = await getAllMeasurementsIncludingDeleted()
const remote = await fetchRemote(config)
const merged = mergeMeasurements(local, remote)
for (const m of merged) {
await upsertMeasurement(m)
}
await uploadRemote(config, merged)
return {
local: local.filter(m => !m.deleted).length,
remote: remote.filter(m => !m.deleted).length,
merged: merged.length,
} }
await SecureStore.setItemAsync(LAST_SYNC_KEY, new Date().toISOString())
return { pushed, deleted, pulled, errors }
} }

View file

@ -1,5 +1,3 @@
export type SyncStatus = 'local' | 'synced'
export type Measurement = { export type Measurement = {
id: string id: string
weight: number weight: number
@ -13,12 +11,11 @@ export type Measurement = {
measuredAt: string measuredAt: string
createdAt: string createdAt: string
updatedAt: string updatedAt: string
syncStatus: SyncStatus syncStatus?: string
deleted: number deletedAt?: string
} }
export type WebDAVConfig = { export type ApiConfig = {
url: string url: string
username: string token: string
password: string
} }

View file

@ -1,17 +0,0 @@
const CHARS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/='
export function encodeBase64(str: string): string {
const bytes = encodeURIComponent(str).replace(/%([0-9A-F]{2})/g, (_match, p1) => String.fromCharCode(parseInt(p1, 16)))
let result = ''
let i = 0
while (i < bytes.length) {
const a = bytes.charCodeAt(i++) || 0
const b = bytes.charCodeAt(i++) || 0
const c = bytes.charCodeAt(i++) || 0
result += CHARS.charAt(a >> 2)
result += CHARS.charAt(((a & 3) << 4) | (b >> 4))
result += CHARS.charAt(isNaN(b) ? CHARS.length - 1 : ((b & 15) << 2) | (c >> 6))
result += CHARS.charAt(isNaN(c) ? CHARS.length - 1 : c & 63)
}
return result
}

26
src/utils/toast.ts Normal file
View file

@ -0,0 +1,26 @@
import { Platform, ToastAndroid } from 'react-native'
let _toastQueue: Array<{message: string; duration: number}> = []
let _showFn: ((message: string, duration: number) => void) | null = null
export function setToastShowFn(fn: (message: string, duration: number) => void) {
_showFn = fn
}
export function showToast(message: string, duration = 3000) {
if (_showFn) {
_showFn(message, duration)
return
}
if (Platform.OS === 'android') {
ToastAndroid.show(message, duration > 2000 ? ToastAndroid.LONG : ToastAndroid.SHORT)
}
}
export function showSuccess(message: string) {
showToast(message, 2500)
}
export function showError(message: string) {
showToast(message, 4000)
}

View file

@ -18,8 +18,6 @@ const mockMeasurement: Measurement = {
measuredAt: '2024-01-01T08:00:00.000Z', measuredAt: '2024-01-01T08:00:00.000Z',
createdAt: '2024-01-01T08:00:00.000Z', createdAt: '2024-01-01T08:00:00.000Z',
updatedAt: '2024-01-01T08:00:00.000Z', updatedAt: '2024-01-01T08:00:00.000Z',
syncStatus: 'local',
deleted: 0,
} }
describe('database', () => { describe('database', () => {
@ -28,9 +26,7 @@ describe('database', () => {
let insertMeasurement: any let insertMeasurement: any
let getAllMeasurements: any let getAllMeasurements: any
let getLatestMeasurement: any let getLatestMeasurement: any
let updateMeasurement: any let setDeletedAt: any
let deleteMeasurement: any
let getAllMeasurementsIncludingDeleted: any
beforeEach(async () => { beforeEach(async () => {
jest.resetModules() jest.resetModules()
@ -38,8 +34,8 @@ describe('database', () => {
openDatabaseAsync: jest.fn(), openDatabaseAsync: jest.fn(),
})) }))
db = { db = {
execAsync: jest.fn(() => Promise.resolve()), execAsync: jest.fn(),
runAsync: jest.fn(() => Promise.resolve()), runAsync: jest.fn(),
getAllAsync: jest.fn(), getAllAsync: jest.fn(),
} }
const sqlite = require('expo-sqlite') const sqlite = require('expo-sqlite')
@ -49,9 +45,7 @@ describe('database', () => {
insertMeasurement = mod.insertMeasurement insertMeasurement = mod.insertMeasurement
getAllMeasurements = mod.getAllMeasurements getAllMeasurements = mod.getAllMeasurements
getLatestMeasurement = mod.getLatestMeasurement getLatestMeasurement = mod.getLatestMeasurement
updateMeasurement = mod.updateMeasurement setDeletedAt = mod.setDeletedAt
deleteMeasurement = mod.deleteMeasurement
getAllMeasurementsIncludingDeleted = mod.getAllMeasurementsIncludingDeleted
}) })
it('initDatabase creates the measurements table', async () => { it('initDatabase creates the measurements table', async () => {
@ -87,42 +81,15 @@ describe('database', () => {
expect(result!.id).toBe(mockMeasurement.id) expect(result!.id).toBe(mockMeasurement.id)
}) })
it('updateMeasurement updates syncStatus', async () => { it('setDeletedAt sets both deletedAt and updatedAt timestamps', async () => {
db.runAsync.mockResolvedValue(undefined) db.runAsync.mockResolvedValue(undefined)
await initDatabase() await initDatabase()
await updateMeasurement(mockMeasurement.id, 'synced') await setDeletedAt(mockMeasurement.id)
expect(db.runAsync).toHaveBeenCalledWith( expect(db.runAsync).toHaveBeenCalledWith(
expect.stringContaining('UPDATE measurements'), expect.stringContaining('UPDATE measurements SET deletedAt'),
expect.arrayContaining(['synced', mockMeasurement.id])
)
})
it('deleteMeasurement marks measurement as deleted', async () => {
db.runAsync.mockResolvedValue(undefined)
await initDatabase()
await deleteMeasurement(mockMeasurement.id)
expect(db.runAsync).toHaveBeenCalledWith(
expect.stringContaining('UPDATE measurements SET deleted = 1'),
expect.arrayContaining([mockMeasurement.id]) expect.arrayContaining([mockMeasurement.id])
) )
}) const args = (db.runAsync as jest.Mock).mock.calls.slice(-1)[0]
expect(args[1][0]).toBe(args[1][1])
it('getAllMeasurements filters out deleted records', async () => {
await initDatabase()
await getAllMeasurements()
expect(db.getAllAsync).toHaveBeenCalledWith(
expect.stringContaining('deleted = 0')
)
})
it('getAllMeasurementsIncludingDeleted includes deleted records', async () => {
await initDatabase()
await getAllMeasurementsIncludingDeleted()
expect(db.getAllAsync).toHaveBeenCalledWith(
expect.stringContaining('SELECT * FROM measurements')
)
expect(db.getAllAsync).not.toHaveBeenCalledWith(
expect.stringContaining('deleted = 0')
)
}) })
}) })

View file

@ -1,102 +1,86 @@
import { mergeMeasurements } from '../src/sync/syncService' import { mergeMeasurements } from '../src/sync/syncService'
import { Measurement } from '../src/types' import { Measurement } from '../src/types'
jest.mock('expo-sqlite', () => ({ jest.mock('expo-sqlite', () => ({ openDatabaseAsync: jest.fn() }))
openDatabaseAsync: jest.fn(),
}))
const base: Measurement = { const make = (overrides: Partial<Measurement> = {}): Measurement => ({
id: 'id-1', id: 'id-1', weight: 75, bmi: 22, bodyFatPercentage: 15, fatMass: 10,
weight: 75, musclePercentage: 40, muscleMass: 30, visceralFat: 8, bmr: 1600,
bmi: 22, measuredAt: '2024-06-01T08:00:00.000Z', createdAt: '2024-06-01T08:00:00.000Z',
bodyFatPercentage: 15, updatedAt: '2024-06-01T08:00:00.000Z', syncStatus: 'synced', ...overrides,
fatMass: 10, })
musclePercentage: 40,
muscleMass: 30,
visceralFat: 8,
bmr: 1600,
measuredAt: '2024-06-01T08:00:00.000Z',
createdAt: '2024-06-01T08:00:00.000Z',
updatedAt: '2024-06-01T08:00:00.000Z',
syncStatus: 'local',
deleted: 0,
}
describe('mergeMeasurements', () => { describe('mergeMeasurements', () => {
it('returns empty array when both are empty', () => { it('both empty returns empty', () => {
expect(mergeMeasurements([], [])).toEqual([]) expect(mergeMeasurements([], [])).toEqual([])
}) })
it('keeps local items not on remote', () => { it('local only kept', () => {
const result = mergeMeasurements([base], []) const r = mergeMeasurements([make()], [])
expect(result).toHaveLength(1) expect(r).toHaveLength(1)
expect(result[0].id).toBe('id-1')
}) })
it('keeps remote items not on local', () => { it('remote only kept', () => {
const remote = { ...base, syncStatus: 'synced' as const } const r = mergeMeasurements([], [make({ id: 'r1' })])
const result = mergeMeasurements([], [remote]) expect(r).toHaveLength(1)
expect(result).toHaveLength(1) expect(r[0].id).toBe('r1')
expect(result[0].id).toBe('id-1')
}) })
it('picks the newer version when both have same id', () => { it('last-writer-wins: remote newer', () => {
const local = { ...base, weight: 75, updatedAt: '2024-06-02T08:00:00.000Z' } const local = make({ weight: 75, updatedAt: '2024-06-02T08:00:00.000Z' })
const remote = { ...base, weight: 76, updatedAt: '2024-06-03T08:00:00.000Z' } const remote = make({ weight: 76, updatedAt: '2024-06-03T08:00:00.000Z' })
const result = mergeMeasurements([local], [remote]) const r = mergeMeasurements([local], [remote])
expect(result).toHaveLength(1) expect(r[0].weight).toBe(76)
expect(result[0].weight).toBe(76)
}) })
it('picks local when local is newer', () => { it('last-writer-wins: local newer', () => {
const local = { ...base, weight: 77, updatedAt: '2024-06-04T08:00:00.000Z' } const local = make({ weight: 77, updatedAt: '2024-06-04T08:00:00.000Z' })
const remote = { ...base, weight: 76, updatedAt: '2024-06-03T08:00:00.000Z' } const remote = make({ weight: 76, updatedAt: '2024-06-03T08:00:00.000Z' })
const result = mergeMeasurements([local], [remote]) const r = mergeMeasurements([local], [remote])
expect(result).toHaveLength(1) expect(r[0].weight).toBe(77)
expect(result[0].weight).toBe(77)
}) })
it('filters out locally deleted items even if present on remote', () => { it('remote deleted wins over synced local', () => {
const local = { ...base, deleted: 1 as const, updatedAt: '2024-06-05T08:00:00.000Z' } const local = make({ updatedAt: '2024-06-01T08:00:00.000Z' })
const remote = { ...base, deleted: 0 as const, updatedAt: '2024-06-03T08:00:00.000Z' } const remote: any = { ...make({ updatedAt: '2024-06-03T08:00:00.000Z' }), deleted: true }
const result = mergeMeasurements([local], [remote]) const r = mergeMeasurements([local], [remote])
expect(result).toHaveLength(0) expect(r[0].deletedAt).toBe('2024-06-03T08:00:00.000Z')
}) })
it('filters out remotely deleted items when remote is newer', () => { it('local synced wins over older remote deleted', () => {
const local = { ...base, deleted: 0 as const, updatedAt: '2024-06-04T08:00:00.000Z' } const local = make({ updatedAt: '2024-06-03T08:00:00.000Z' })
const remote = { ...base, deleted: 1 as const, updatedAt: '2024-06-05T08:00:00.000Z' } const remote: any = { ...make({ updatedAt: '2024-06-01T08:00:00.000Z' }), deleted: true }
const result = mergeMeasurements([local], [remote]) const r = mergeMeasurements([local], [remote])
expect(result).toHaveLength(0) expect(r[0].deletedAt).toBeUndefined()
}) })
it('keeps local active item when local is newer than remote deletion', () => { it('local pending-delete wins over remote deleted', () => {
const local = { ...base, deleted: 0 as const, weight: 78, updatedAt: '2024-06-06T08:00:00.000Z' } const local = make({ updatedAt: '2024-06-01T08:00:00.000Z', syncStatus: 'pending-delete', deletedAt: '2024-06-05T08:00:00.000Z' })
const remote = { ...base, deleted: 1 as const, updatedAt: '2024-06-05T08:00:00.000Z' } const remote: any = { ...make({ updatedAt: '2024-06-03T08:00:00.000Z' }), deleted: true }
const result = mergeMeasurements([local], [remote]) const r = mergeMeasurements([local], [remote])
expect(result).toHaveLength(1) expect(r[0].syncStatus).toBe('pending-delete')
expect(result[0].weight).toBe(78) expect(r[0].deletedAt).toBe('2024-06-05T08:00:00.000Z')
}) })
it('keeps active item when local deleted is older than remote active', () => { it('local pending wins over remote newer', () => {
const local = { ...base, deleted: 1 as const, updatedAt: '2024-06-01T08:00:00.000Z' } const local = make({ weight: 75, updatedAt: '2024-06-01T08:00:00.000Z', syncStatus: 'pending' })
const remote = { ...base, deleted: 0 as const, updatedAt: '2024-06-05T08:00:00.000Z', syncStatus: 'synced' as const } const remote: any = { ...make({ weight: 76, updatedAt: '2024-06-03T08:00:00.000Z' }) }
const result = mergeMeasurements([local], [remote]) const r = mergeMeasurements([local], [remote])
expect(result).toHaveLength(1) expect(r[0].weight).toBe(75)
expect(result[0].weight).toBe(75) expect(r[0].syncStatus).toBe('pending')
}) })
it('merges multiple active and deleted items correctly', () => { it('remote deleted with no local record is skipped', () => {
const localActive = { ...base, id: 'a', weight: 70, updatedAt: '2024-06-05T08:00:00.000Z' } const remote: any = { ...make({ id: 'r1', updatedAt: '2024-06-03T08:00:00.000Z' }), deleted: true }
const localDeleted = { ...base, id: 'b', weight: 80, deleted: 1 as const, updatedAt: '2024-06-05T08:00:00.000Z' } const r = mergeMeasurements([], [remote])
const remoteActive = { ...base, id: 'c', weight: 90, updatedAt: '2024-06-03T08:00:00.000Z', syncStatus: 'synced' as const } expect(r).toHaveLength(0)
const remoteDeleted = { ...base, id: 'd', weight: 100, deleted: 1 as const, updatedAt: '2024-06-05T08:00:00.000Z' } })
const result = mergeMeasurements( it('local without syncStatus treated as synced', () => {
[localActive, localDeleted], const local = make({ weight: 75, updatedAt: '2024-06-01T08:00:00.000Z' })
[remoteActive, remoteDeleted] delete local.syncStatus
) const remote = make({ weight: 76, updatedAt: '2024-06-03T08:00:00.000Z' })
expect(result).toHaveLength(2) const r = mergeMeasurements([local], [remote])
expect(result.map(m => m.id).sort()).toEqual(['a', 'c']) expect(r[0].weight).toBe(76)
}) })
}) })

140
upgrade.md Normal file
View file

@ -0,0 +1,140 @@
# Upgrade: REST API sync i.p.v. WebDAV
Vervang WebDAV-sync door een eigen REST API op `weegschaal.eddydevink.nl`,
met MariaDB-backend en Bearer token-authenticatie. Incrementele sync i.p.v. volledig bestand.
## Fase 1 — Server-side: PHP API
### Subdomein & nginx
- DNS: `weegschaal.eddydevink.nl` → zelfde server als `git.eddydevink.nl`
- nginx server block in `/etc/nginx/conf.d/weegschaal-api.conf`
- SSL via Let's Encrypt (`sudo certbot`)
- Document root: `/usr/share/nginx/html/weegschaal-api/`
### MariaDB
```sql
CREATE DATABASE weegschaal;
CREATE USER 'weegschaal'@'localhost' IDENTIFIED BY '<random>';
GRANT ALL ON weegschaal.* TO 'weegschaal'@'localhost';
CREATE TABLE users (
id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(255) NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE auth_tokens (
id INT AUTO_INCREMENT PRIMARY KEY,
user_id INT NOT NULL REFERENCES users(id),
token VARCHAR(64) NOT NULL UNIQUE,
description VARCHAR(255),
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE measurements (
id VARCHAR(36) NOT NULL,
user_id INT NOT NULL,
weight DECIMAL(5,2) NOT NULL,
bmi DECIMAL(4,1) NOT NULL,
body_fat_percentage DECIMAL(4,1) NOT NULL,
fat_mass DECIMAL(5,2) NOT NULL,
muscle_percentage DECIMAL(4,1) NOT NULL,
muscle_mass DECIMAL(5,2) NOT NULL,
visceral_fat TINYINT NOT NULL,
bmr DECIMAL(6,1) NOT NULL,
measured_at DATE NOT NULL,
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL,
PRIMARY KEY (user_id, id),
INDEX idx_updated_at (user_id, updated_at)
);
```
### PHP API endpoints (Slim Framework via Composer)
| Methode | Pad | Beschrijving |
|---------|-----|-------------|
| `POST` | `/api/v1/auth/register` | (later) Nieuwe gebruiker |
| `POST` | `/api/v1/auth/tokens` | (later) Nieuwe token aanmaken |
| `GET` | `/api/v1/measurements` | Alle metingen (of `?since=ISO` voor delta) |
| `POST` | `/api/v1/measurements` | Nieuwe meting aanmaken |
| `PUT` | `/api/v1/measurements/:id` | Meting bijwerken |
| `DELETE` | `/api/v1/measurements/:id` | Meting verwijderen |
Authenticatie: `Authorization: Bearer <token>`, gevalideerd tegen `auth_tokens`-tabel.
Sync-logica server:
- `GET /api/v1/measurements?since=2025-01-01T12:00:00Z` → alleen metingen met `updated_at > since`
- Bij PUT/DELETE: controleer `updated_at` client, bij conflict → HTTP 409 met server-versie
### Bestandsstructuur API
```
/usr/share/nginx/html/weegschaal-api/
├── composer.json
├── public/
│ └── index.php ← Entry point, routing
├── src/
│ ├── Database.php ← PDO singleton
│ ├── Auth.php ← Token validation middleware
│ ├── Measurements.php ← CRUD + sync queries
│ └── helpers.php
└── config.php ← DB credentials (uit .env of env vars)
```
## Fase 2 — Client-side: App aanpassen
### Types (`src/types.ts`)
- Vervang `WebDAVConfig` door `ApiConfig { url: string; token: string }`
- Verwijder `SyncStatus` (niet meer nodig — sync status volgt uit `updatedAt`)
### SettingsScreen (`src/screens/SettingsScreen.tsx`)
- Vervang URL/username/password door API-URL + token (met oogje)
- Opslaan → SecureStore
- Wissen → SecureStore verwijderen
- 'Nu synchroniseren' knop blijft
### Nieuwe module `src/sync/api.ts`
```typescript
const api = (config: ApiConfig) => ({
getMeasurements: (since?: string) => GET /api/v1/measurements?since=...
createMeasurement: (m: Measurement) => POST /api/v1/measurements
updateMeasurement: (m: Measurement) => PUT /api/v1/measurements/:id
deleteMeasurement: (id: string) => DELETE /api/v1/measurements/:id
})
```
### syncService.ts — volledig herschreven
Nieuwe sync-flow:
| Stap | Actie |
|------|-------|
| 1 | Lees lokale `lastSyncAt` (uit SecureStore) |
| 2 | `GET ?since=lastSyncAt` → alleen wijzigingen ophalen |
| 3 | Upsert remote wijzigingen lokaal (last-writer-wins) |
| 4 | Voor elke lokale meting met `updatedAt > lastSyncAt`: push naar API |
| 5 | Update `lastSyncAt` naar server-tijd |
Bij opslaan in `NewMeasurementScreen`:
1. Direct naar lokale SQLite (offline-first)
2. Direct erna: push naar API (async, niet-blokkerend via `Promise.allSettled`)
## Fase 3 — Migratie
Eerste keer na API-setup:
1. App stuurt alle lokale metingen naar de API (1-voor-1 via POST)
2. Alle metingen nu in MariaDB
3. Vanaf dan: alleen delta-sync
## Benodigde server-toegang
- SSH naar nextcloud (`aiaiaiaiagent@nextcloud`) — key werkte niet
- `sudo -A mysql` voor DB + user aanmaken
- nginx config aanpassen (`/etc/nginx/conf.d/`)
- DNS controleren voor subdomein

BIN
weegschaal-app.jpeg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB