Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0513db46ac |
32 changed files with 542 additions and 3404 deletions
6
.gitignore
vendored
6
.gitignore
vendored
|
|
@ -111,12 +111,6 @@ 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
17
App.tsx
|
|
@ -5,18 +5,8 @@ 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'
|
|
||||||
|
|
||||||
type RootStackParamList = {
|
const Stack = createNativeStackNavigator()
|
||||||
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 (
|
||||||
|
|
@ -37,11 +27,6 @@ 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
145
README.md
|
|
@ -1,117 +1,24 @@
|
||||||
# Weegschaal App
|
# Weegschaal App
|
||||||
|
|
||||||
_*Gemaakt met vibecoding — DeepSeek V4 Pro*_
|
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.
|
||||||
|
|
||||||
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.
|
## Meetwaarden
|
||||||
|
|
||||||
## Inhoud
|
- Gewicht (kg)
|
||||||
|
- BMI
|
||||||
- [Functionaliteit](#functionaliteit)
|
- Vetpercentage (%)
|
||||||
- [Schermen](#schermen)
|
- Vetmassa (kg) — automatisch berekend
|
||||||
- [Synchronisatie](#synchronisatie)
|
- Spierpercentage (%)
|
||||||
- [Server-side API](#server-side-api)
|
- Spiermassa (kg) — automatisch berekend
|
||||||
- [Beveiliging](#beveiliging)
|
- Buikvet (index)
|
||||||
- [Offline resilience](#offline-resilience)
|
- Stofwisseling in rust (kcal)
|
||||||
- [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
|
||||||
|
|
||||||
| Scherm | Beschrijving |
|
- **Dashboard** — laatste meting (groene kaart) + drie losse grafieken onder elkaar (gewicht, vetmassa, spiermassa), elk met eigen y-as-schaal en 30-dagen filter gerekend vanaf de nieuwste meting
|
||||||
|--------|-------------|
|
- **Nieuwe meting** — datumkiezer bovenaan (standaard vandaag), formulier vooraf ingevuld met waarden van de laatste meting. Opslaan-knop altijd in beeld (buiten ScrollView). Bij opslaan wordt automatisch gesynchroniseerd met Nextcloud
|
||||||
| Dashboard | Progressive loading: laatste 2 metingen direct zichtbaar, grafieken volgen daarna |
|
- **Historie** — FlatList gesorteerd op ingevoerde datum (nieuwste bovenaan), pull-to-refresh
|
||||||
| Nieuwe meting / Wijzigen | Formulier met datumkiezer, waarden vooraf ingevuld vanaf laatste meting |
|
- **Instellingen** — WebDAV configuratie (URL, gebruikersnaam, wachtwoord met oogje voor tonen/verbergen), handmatige sync, wissen met bevestigingsdialoog
|
||||||
| 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
|
||||||
|
|
||||||
|
|
@ -122,25 +29,15 @@ npx expo start
|
||||||
|
|
||||||
Scannen met Expo Go op Android of iOS.
|
Scannen met Expo Go op Android of iOS.
|
||||||
|
|
||||||
## Build & deploy
|
## Synchronisatie
|
||||||
|
|
||||||
```bash
|
Data wordt opgeslagen als `weegschaal/measurements.json` in je Nextcloud WebDAV. Vul in het Instellingen scherm het volgende in:
|
||||||
# APK bouwen (lokaal via EAS)
|
|
||||||
npx eas build --local --profile apk --platform android
|
|
||||||
|
|
||||||
# Draadloos sideloaden via ADB
|
- **URL**: `https://<jouw-nextcloud>/remote.php/dav/files/<gebruikersnaam>/`
|
||||||
adb-wireless-connect
|
- **Gebruikersnaam**: je Nextcloud gebruikersnaam
|
||||||
adb install -r build-*.apk
|
- **Wachtwoord**: je Nextcloud app-wachtwoord
|
||||||
```
|
|
||||||
|
|
||||||
Vereisten: Android SDK 36, OpenJDK 21, ADB 37+, Gradle 8.14.
|
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.
|
||||||
|
|
||||||
## 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
|
||||||
|
|
||||||
|
|
@ -148,8 +45,6 @@ Bestaande SQLite-databases worden automatisch gemigreerd bij `initDatabase()`:
|
||||||
npm test
|
npm test
|
||||||
```
|
```
|
||||||
|
|
||||||
Tests richten zich op de merge-logica (`mergeMeasurements`) en database-operaties.
|
|
||||||
|
|
||||||
## Licentie
|
## Licentie
|
||||||
|
|
||||||
Public domain (Unlicense)
|
Public domain (Unlicense)
|
||||||
|
|
|
||||||
17
app.json
17
app.json
|
|
@ -13,9 +13,9 @@
|
||||||
"package": "nl.eddydevink.weegschaal",
|
"package": "nl.eddydevink.weegschaal",
|
||||||
"adaptiveIcon": {
|
"adaptiveIcon": {
|
||||||
"backgroundColor": "#E6F4FE",
|
"backgroundColor": "#E6F4FE",
|
||||||
"foregroundImage": "./assets/icon.png",
|
"foregroundImage": "./assets/android-icon-foreground.png",
|
||||||
"backgroundImage": "./assets/android-icon-background.png",
|
"backgroundImage": "./assets/android-icon-background.png",
|
||||||
"monochromeImage": "./assets/icon.png"
|
"monochromeImage": "./assets/android-icon-monochrome.png"
|
||||||
},
|
},
|
||||||
"predictiveBackGestureEnabled": false
|
"predictiveBackGestureEnabled": false
|
||||||
},
|
},
|
||||||
|
|
@ -26,17 +26,6 @@
|
||||||
"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"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
BIN
assets/icon.png
BIN
assets/icon.png
Binary file not shown.
|
Before Width: | Height: | Size: 218 KiB After Width: | Height: | Size: 384 KiB |
|
|
@ -1,70 +0,0 @@
|
||||||
# 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.
|
|
||||||
|
|
@ -1,106 +0,0 @@
|
||||||
# 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
|
|
@ -1,139 +0,0 @@
|
||||||
# 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
|
|
||||||
|
|
@ -1,266 +0,0 @@
|
||||||
# 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
|
|
||||||
3
eas.json
3
eas.json
|
|
@ -1,7 +1,6 @@
|
||||||
{
|
{
|
||||||
"cli": {
|
"cli": {
|
||||||
"version": ">= 5.9.1",
|
"version": ">= 5.9.1"
|
||||||
"appVersionSource": "local"
|
|
||||||
},
|
},
|
||||||
"build": {
|
"build": {
|
||||||
"development": {
|
"development": {
|
||||||
|
|
|
||||||
385
package-lock.json
generated
385
package-lock.json
generated
|
|
@ -13,19 +13,14 @@
|
||||||
"@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",
|
||||||
|
|
@ -1739,6 +1734,25 @@
|
||||||
"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",
|
||||||
|
|
@ -2147,25 +2161,6 @@
|
||||||
"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",
|
||||||
|
|
@ -3730,15 +3725,6 @@
|
||||||
"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",
|
||||||
|
|
@ -4340,57 +4326,6 @@
|
||||||
"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",
|
||||||
|
|
@ -4405,24 +4340,6 @@
|
||||||
"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",
|
||||||
|
|
@ -5160,31 +5077,6 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"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",
|
||||||
|
|
@ -5232,15 +5124,6 @@
|
||||||
"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",
|
||||||
|
|
@ -5268,57 +5151,6 @@
|
||||||
"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",
|
||||||
|
|
@ -5748,36 +5580,6 @@
|
||||||
"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",
|
||||||
|
|
@ -6173,19 +5975,6 @@
|
||||||
"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",
|
||||||
|
|
@ -6267,12 +6056,6 @@
|
||||||
"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",
|
||||||
|
|
@ -6383,15 +6166,6 @@
|
||||||
"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",
|
||||||
|
|
@ -9277,12 +9051,6 @@
|
||||||
"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",
|
||||||
|
|
@ -9495,18 +9263,6 @@
|
||||||
"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",
|
||||||
|
|
@ -9653,51 +9409,6 @@
|
||||||
"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",
|
||||||
|
|
@ -10246,12 +9957,6 @@
|
||||||
"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",
|
||||||
|
|
@ -10599,12 +10304,6 @@
|
||||||
"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",
|
||||||
|
|
@ -10808,15 +10507,6 @@
|
||||||
"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",
|
||||||
|
|
@ -10983,32 +10673,6 @@
|
||||||
"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",
|
||||||
|
|
@ -11151,15 +10815,6 @@
|
||||||
"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",
|
||||||
|
|
|
||||||
11
package.json
11
package.json
|
|
@ -8,19 +8,14 @@
|
||||||
"@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",
|
||||||
|
|
@ -30,8 +25,8 @@
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "expo start",
|
"start": "expo start",
|
||||||
"android": "expo run:android",
|
"android": "expo start --android",
|
||||||
"ios": "expo run:ios",
|
"ios": "expo start --ios",
|
||||||
"web": "expo start --web",
|
"web": "expo start --web",
|
||||||
"test": "jest"
|
"test": "jest"
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,8 @@
|
||||||
import React, { useRef } from 'react'
|
import React from 'react'
|
||||||
import { View, Text, StyleSheet, TouchableOpacity } from 'react-native'
|
import { View, Text, StyleSheet } 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 = { measurements: Measurement[] }
|
type Props = { measurement: Measurement | null }
|
||||||
|
|
||||||
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' },
|
||||||
|
|
@ -18,10 +15,8 @@ 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({ measurements }: Props) {
|
export default function LatestMeasurementCard({ measurement }: Props) {
|
||||||
const viewShotRef = useRef<ViewShot>(null)
|
if (!measurement) {
|
||||||
|
|
||||||
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>
|
||||||
|
|
@ -29,89 +24,31 @@ export default function LatestMeasurementCard({ measurements }: 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}>
|
||||||
<View style={styles.header}>
|
|
||||||
<View style={{ flex: 1 }} />
|
|
||||||
<TouchableOpacity onPress={handleShare} hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}>
|
|
||||||
<MaterialIcons name="share" size={24} color="#2196F3" />
|
|
||||||
</TouchableOpacity>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<ViewShot ref={viewShotRef} options={{ format: 'png', quality: 1 }}>
|
|
||||||
<View style={styles.shotContent}>
|
|
||||||
{previous && (
|
|
||||||
<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.title}>Laatste meting</Text>
|
||||||
<Text style={styles.date}>{new Date(latest.measuredAt).toLocaleDateString('nl-NL')}</Text>
|
<Text style={styles.date}>{new Date(measurement.measuredAt).toLocaleDateString('nl-NL')}</Text>
|
||||||
{rows.map(r => (
|
{rows.map(r => (
|
||||||
<View key={r.key} style={styles.row}>
|
<View key={r.key} style={styles.row}>
|
||||||
<Text style={styles.rowLabel}>{r.label}</Text>
|
<Text style={styles.rowLabel}>{r.label}</Text>
|
||||||
<Text style={styles.rowValue}>
|
<Text style={styles.rowValue}>
|
||||||
{typeof latest[r.key] === 'number'
|
{typeof measurement[r.key] === 'number'
|
||||||
? (latest[r.key] as number).toFixed(1)
|
? (measurement[r.key] as number).toFixed(1)
|
||||||
: latest[r.key]}{' '}
|
: measurement[r.key]}{' '}
|
||||||
{r.suffix}
|
{r.suffix}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
))}
|
))}
|
||||||
</View>
|
</View>
|
||||||
</ViewShot>
|
|
||||||
</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', paddingVertical: 4 },
|
row: { flexDirection: 'row', justifyContent: 'space-between', paddingVertical: 4 },
|
||||||
rowLabel: { fontSize: 14, color: '#555', flex: 1 },
|
rowLabel: { fontSize: 14, color: '#555' },
|
||||||
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 },
|
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,6 @@ 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 = {
|
||||||
|
|
@ -30,10 +29,13 @@ 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, isEdit }: Props) {
|
export default function MeasurementForm({ onSubmit, initialValues }: Props) {
|
||||||
const insets = useSafeAreaInsets()
|
const insets = useSafeAreaInsets()
|
||||||
const [pickerOpen, setPickerOpen] = useState(false)
|
const [pickerOpen, setPickerOpen] = useState(false)
|
||||||
const [measurementDate, setMeasurementDate] = useState<Date>(new Date())
|
const [measurementDate, setMeasurementDate] = useState<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) {
|
||||||
|
|
@ -45,18 +47,9 @@ export default function MeasurementForm({ onSubmit, initialValues, isEdit }: Pro
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
function toFloat(s: string): number {
|
const weight = parseFloat(values.weight) || 0
|
||||||
return parseFloat(s.replace(',', '.')) || 0
|
const bodyFatPct = parseFloat(values.bodyFatPercentage) || 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) : '',
|
||||||
|
|
@ -66,18 +59,20 @@ function toInt(s: string): number {
|
||||||
const handleSubmit = () => {
|
const handleSubmit = () => {
|
||||||
const now = new Date().toISOString()
|
const now = new Date().toISOString()
|
||||||
const m: Measurement = {
|
const m: Measurement = {
|
||||||
id: isEdit && initialValues?.id ? initialValues.id : generateId(),
|
id: generateId(),
|
||||||
weight,
|
weight,
|
||||||
bmi: toFloat(values.bmi),
|
bmi: parseFloat(values.bmi) || 0,
|
||||||
bodyFatPercentage: bodyFatPct,
|
bodyFatPercentage: bodyFatPct,
|
||||||
fatMass: toFloat(computed.fatMass),
|
fatMass: parseFloat(computed.fatMass) || 0,
|
||||||
musclePercentage: musclePct,
|
musclePercentage: musclePct,
|
||||||
muscleMass: toFloat(computed.muscleMass),
|
muscleMass: parseFloat(computed.muscleMass) || 0,
|
||||||
visceralFat: toInt(values.visceralFat),
|
visceralFat: parseInt(values.visceralFat, 10) || 0,
|
||||||
bmr: toFloat(values.bmr),
|
bmr: parseFloat(values.bmr) || 0,
|
||||||
measuredAt: measurementDate.toISOString(),
|
measuredAt: measurementDate.toISOString(),
|
||||||
createdAt: isEdit && initialValues?.createdAt ? initialValues.createdAt : now,
|
createdAt: now,
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
|
syncStatus: 'local',
|
||||||
|
deleted: 0,
|
||||||
}
|
}
|
||||||
onSubmit(m)
|
onSubmit(m)
|
||||||
}
|
}
|
||||||
|
|
@ -146,7 +141,7 @@ function toInt(s: string): number {
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
<TouchableOpacity style={[styles.button, { marginBottom: insets.bottom + 48 }]} onPress={handleSubmit}>
|
<TouchableOpacity style={[styles.button, { marginBottom: insets.bottom + 16 }]} onPress={handleSubmit}>
|
||||||
<Text style={styles.buttonText}>Opslaan</Text>
|
<Text style={styles.buttonText}>Opslaan</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</KeyboardAvoidingView>
|
</KeyboardAvoidingView>
|
||||||
|
|
@ -155,7 +150,7 @@ function toInt(s: string): number {
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
container: { flex: 1, backgroundColor: '#f5f5f5' },
|
container: { flex: 1, backgroundColor: '#f5f5f5' },
|
||||||
scroll: { padding: 16 },
|
scroll: { padding: 16, paddingBottom: 0 },
|
||||||
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' },
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import React, { useRef, useState } from 'react'
|
import React from 'react'
|
||||||
import { View, Text, StyleSheet, Dimensions, ScrollView, NativeSyntheticEvent, NativeScrollEvent } from 'react-native'
|
import { View, Text, StyleSheet, useWindowDimensions } 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,9 +11,6 @@ const COLORS = {
|
||||||
muscleMass: '#2196F3',
|
muscleMass: '#2196F3',
|
||||||
}
|
}
|
||||||
|
|
||||||
const screenWidth = Dimensions.get('window').width
|
|
||||||
const chartWidth = screenWidth - 32
|
|
||||||
|
|
||||||
function SingleChart({
|
function SingleChart({
|
||||||
title,
|
title,
|
||||||
data,
|
data,
|
||||||
|
|
@ -27,12 +24,13 @@ 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={chartWidth}
|
width={width - 32}
|
||||||
height={180}
|
height={180}
|
||||||
yAxisSuffix={unit}
|
yAxisSuffix={unit}
|
||||||
chartConfig={{
|
chartConfig={{
|
||||||
|
|
@ -60,63 +58,7 @@ 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
|
||||||
}
|
}
|
||||||
|
|
@ -125,38 +67,52 @@ 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 lastDate = new Date(sorted[sorted.length - 1].measuredAt)
|
const cutoff = new Date(sorted[sorted.length - 1].measuredAt)
|
||||||
|
cutoff.setDate(cutoff.getDate() - 30)
|
||||||
|
const filtered = sorted.filter(m => new Date(m.measuredAt) >= cutoff)
|
||||||
|
const dataPoints = filtered.length >= 2 ? filtered : sorted
|
||||||
|
|
||||||
const data30 = filterData(sorted, 30, lastDate)
|
const labels = dataPoints.map(m => {
|
||||||
const data365 = filterData(sorted, 365, lastDate)
|
const d = new Date(m.measuredAt)
|
||||||
|
return `${d.getDate()}/${d.getMonth() + 1}`
|
||||||
|
})
|
||||||
|
|
||||||
const onScroll = (e: NativeSyntheticEvent<NativeScrollEvent>) => {
|
const labelStep = labels.length > 8 ? Math.ceil(labels.length / 8) : 1
|
||||||
const offset = e.nativeEvent.contentOffset.x
|
const sparseLabels = labels.map((l, i) => (i % labelStep === 0 ? l : ''))
|
||||||
const newPage = Math.round(offset / screenWidth)
|
|
||||||
if (newPage !== page) {
|
|
||||||
setPage(newPage)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={styles.container}>
|
<View style={styles.container}>
|
||||||
<ScrollView
|
<Text style={styles.title}>Voortgang (30 dagen)</Text>
|
||||||
ref={scrollRef}
|
|
||||||
horizontal
|
<SingleChart
|
||||||
pagingEnabled
|
title="Gewicht"
|
||||||
showsHorizontalScrollIndicator={false}
|
data={dataPoints.map(m => m.weight)}
|
||||||
onMomentumScrollEnd={onScroll}
|
labels={sparseLabels}
|
||||||
>
|
color={COLORS.weight}
|
||||||
<ChartSet dataPoints={data30} title="Voortgang (30 dagen)" />
|
unit=" kg"
|
||||||
<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: { marginBottom: 16 },
|
container: { marginHorizontal: 16, 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 },
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,7 @@
|
||||||
import * as SQLite from 'expo-sqlite'
|
import * as SQLite from 'expo-sqlite'
|
||||||
import { Measurement } from '../types'
|
import { Measurement, SyncStatus } 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
|
||||||
|
|
@ -21,112 +20,81 @@ 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 'pending',
|
syncStatus TEXT NOT NULL DEFAULT 'local',
|
||||||
deletedAt TEXT
|
deleted INTEGER NOT NULL DEFAULT 0
|
||||||
);
|
);
|
||||||
`)
|
`)
|
||||||
try {
|
await db.execAsync(`ALTER TABLE measurements ADD COLUMN deleted INTEGER NOT NULL DEFAULT 0`)
|
||||||
await db.execAsync('ALTER TABLE measurements ADD COLUMN deletedAt TEXT')
|
.catch(() => {})
|
||||||
} 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, deletedAt)
|
`INSERT INTO measurements (id, weight, bmi, bodyFatPercentage, fatMass, musclePercentage, muscleMass, visceralFat, bmr, measuredAt, createdAt, updatedAt, syncStatus, deleted)
|
||||||
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.deletedAt ?? null]
|
[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]
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 deletedAt IS NULL ORDER BY measuredAt DESC'
|
`SELECT * FROM measurements WHERE ${activeOnly()} 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 deletedAt IS NULL ORDER BY measuredAt DESC LIMIT 1'
|
`SELECT * FROM measurements WHERE ${activeOnly()} 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 measuredAt LIKE ? AND deletedAt IS NULL LIMIT 1",
|
`SELECT * FROM measurements WHERE ${activeOnly()} AND measuredAt LIKE ? 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, deletedAt)
|
`INSERT OR REPLACE INTO measurements (id, weight, bmi, bodyFatPercentage, fatMass, musclePercentage, muscleMass, visceralFat, bmr, measuredAt, createdAt, updatedAt, syncStatus, deleted)
|
||||||
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.deletedAt ?? null]
|
[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]
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getMeasurementsUpdatedSince(since: string): Promise<Measurement[]> {
|
export async function getUnsyncedMeasurements(): Promise<Measurement[]> {
|
||||||
const rows = await db!.getAllAsync<any>(
|
const rows = await db!.getAllAsync<any>(
|
||||||
'SELECT * FROM measurements WHERE updatedAt > ? ORDER BY updatedAt ASC',
|
"SELECT * FROM measurements WHERE syncStatus = 'local' ORDER BY updatedAt ASC"
|
||||||
[since]
|
|
||||||
)
|
)
|
||||||
return rows as Measurement[]
|
return rows as Measurement[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function setDeletedAt(id: string): Promise<void> {
|
export async function deleteMeasurement(id: string): Promise<void> {
|
||||||
const now = new Date().toISOString()
|
|
||||||
await db!.runAsync(
|
await db!.runAsync(
|
||||||
'UPDATE measurements SET deletedAt = ?, updatedAt = ? WHERE id = ?',
|
"UPDATE measurements SET deleted = 1, syncStatus = 'local', updatedAt = ? WHERE id = ?",
|
||||||
[now, now, id]
|
[new Date().toISOString(), id]
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function removeMeasurement(id: string): Promise<void> {
|
export async function getAllMeasurementsIncludingDeleted(): Promise<Measurement[]> {
|
||||||
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 WHERE syncStatus IN ('pending', 'pending-delete') ORDER BY updatedAt ASC"
|
'SELECT * FROM measurements ORDER BY measuredAt DESC'
|
||||||
)
|
)
|
||||||
return rows as Measurement[]
|
return rows as Measurement[]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,8 @@
|
||||||
import React, { useCallback, useRef, useState } from 'react'
|
import React, { useCallback, 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 { getCached, getVersion, refreshCache } from '../store/measurementStore'
|
import { initDatabase, getAllMeasurements, getLatestMeasurement } from '../database/db'
|
||||||
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'
|
||||||
|
|
@ -12,76 +11,22 @@ type Props = { navigation: any }
|
||||||
|
|
||||||
export default function DashboardScreen({ navigation }: Props) {
|
export default function DashboardScreen({ navigation }: Props) {
|
||||||
const insets = useSafeAreaInsets()
|
const insets = useSafeAreaInsets()
|
||||||
const [latestTwo, setLatestTwo] = useState<Measurement[]>([])
|
const [latest, setLatest] = useState<Measurement | null>(null)
|
||||||
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()
|
||||||
const two = await getLatestMeasurements(2)
|
setAll(allM)
|
||||||
if (!active) return
|
const latestM = await getLatestMeasurement()
|
||||||
setLatestTwo(two)
|
setLatest(latestM)
|
||||||
|
|
||||||
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}>
|
||||||
|
|
@ -93,8 +38,8 @@ export default function DashboardScreen({ navigation }: Props) {
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
<ScrollView style={styles.container}>
|
<ScrollView style={styles.container}>
|
||||||
<LatestMeasurementCard measurements={display} />
|
<LatestMeasurementCard measurement={latest} />
|
||||||
{all.length >= 2 && <WeightChart key={chartKey} measurements={all} />}
|
<WeightChart measurements={all} />
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
|
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
|
|
@ -114,6 +59,4 @@ 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' },
|
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,7 @@
|
||||||
import React, { useCallback, useState } from 'react'
|
import React, { useCallback, useState } from 'react'
|
||||||
import { View, FlatList, StyleSheet, Text, RefreshControl, TouchableOpacity, Alert } from 'react-native'
|
import { View, FlatList, StyleSheet, Text, RefreshControl, Alert } from 'react-native'
|
||||||
import { useFocusEffect } from '@react-navigation/native'
|
import { useFocusEffect } from '@react-navigation/native'
|
||||||
import { initDatabase, getAllMeasurements, setDeletedAt, updateSyncStatus } from '../database/db'
|
import { initDatabase, getAllMeasurements, deleteMeasurement } 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'
|
||||||
|
|
||||||
|
|
@ -62,9 +61,13 @@ export default function HistoryScreen({ navigation }: Props) {
|
||||||
style: 'destructive',
|
style: 'destructive',
|
||||||
onPress: async () => {
|
onPress: async () => {
|
||||||
await initDatabase()
|
await initDatabase()
|
||||||
await setDeletedAt(m.id)
|
await deleteMeasurement(m.id)
|
||||||
await updateSyncStatus(m.id, 'pending-delete')
|
try {
|
||||||
await refreshCache()
|
const { syncAll } = await import('../sync/syncService')
|
||||||
|
await syncAll()
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Sync failed after delete:', e)
|
||||||
|
}
|
||||||
load()
|
load()
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
@ -79,10 +82,7 @@ export default function HistoryScreen({ navigation }: Props) {
|
||||||
data={measurements}
|
data={measurements}
|
||||||
keyExtractor={item => item.id}
|
keyExtractor={item => item.id}
|
||||||
renderItem={({ item }) => (
|
renderItem={({ item }) => (
|
||||||
<MeasurementRow
|
<MeasurementRow measurement={item} onPress={() => handlePress(item)} />
|
||||||
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>}
|
||||||
|
|
|
||||||
|
|
@ -1,134 +0,0 @@
|
||||||
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' },
|
|
||||||
})
|
|
||||||
|
|
@ -1,57 +1,64 @@
|
||||||
import React, { useCallback, useState } from 'react'
|
import React, { useCallback, useEffect, 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 { initDatabase, insertMeasurement, upsertMeasurement, getLatestMeasurement, getMeasurementByDate } from '../database/db'
|
import {
|
||||||
import { refreshCache } from '../store/measurementStore'
|
initDatabase,
|
||||||
import { showSuccess, showError } from '../utils/toast'
|
insertMeasurement,
|
||||||
|
getLatestMeasurement,
|
||||||
|
getMeasurementByDate,
|
||||||
|
upsertMeasurement,
|
||||||
|
} from '../database/db'
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
navigation: any
|
navigation: any
|
||||||
route: {
|
route: any
|
||||||
params: {
|
|
||||||
measurement?: Measurement
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function NewMeasurementScreen({ navigation, route }: Props) {
|
export default function NewMeasurementScreen({ navigation, route }: Props) {
|
||||||
const editMeasurement = route.params?.measurement
|
const editing = route.params?.measurement as Measurement | undefined
|
||||||
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) {
|
||||||
|
if (!cancelled) {
|
||||||
|
setLatest(editing)
|
||||||
|
setLoaded(true)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
const m = await getLatestMeasurement()
|
const m = await getLatestMeasurement()
|
||||||
if (!cancelled) {
|
if (!cancelled) {
|
||||||
setLatest(m || {})
|
setLatest(m || {})
|
||||||
setLoaded(true)
|
setLoaded(true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
load()
|
load()
|
||||||
return () => { cancelled = true }
|
return () => { cancelled = true }
|
||||||
}, [isEdit, editMeasurement])
|
}, [editing])
|
||||||
)
|
)
|
||||||
|
|
||||||
|
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 (isEdit) {
|
if (editing) {
|
||||||
|
m.id = editing.id
|
||||||
|
m.createdAt = editing.createdAt
|
||||||
await upsertMeasurement(m)
|
await upsertMeasurement(m)
|
||||||
} else {
|
doSync()
|
||||||
|
return
|
||||||
|
}
|
||||||
const existing = await getMeasurementByDate(m.measuredAt)
|
const existing = await getMeasurementByDate(m.measuredAt)
|
||||||
if (existing) {
|
if (existing) {
|
||||||
Alert.alert(
|
Alert.alert(
|
||||||
|
|
@ -59,32 +66,36 @@ export default function NewMeasurementScreen({ navigation, route }: Props) {
|
||||||
`Er bestaat al een meting van ${new Date(m.measuredAt).toLocaleDateString('nl-NL')}. Overschrijven?`,
|
`Er bestaat al een meting van ${new Date(m.measuredAt).toLocaleDateString('nl-NL')}. Overschrijven?`,
|
||||||
[
|
[
|
||||||
{ text: 'Annuleren', style: 'cancel' },
|
{ text: 'Annuleren', style: 'cancel' },
|
||||||
{ text: 'Overschrijven', style: 'destructive', onPress: () => {
|
{ text: 'Overschrijven', style: 'destructive', onPress: () => doSave(m, existing) },
|
||||||
m.id = existing.id
|
|
||||||
upsertMeasurement(m).then(() => doSave(m))
|
|
||||||
}},
|
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
doSave(m)
|
||||||
|
}
|
||||||
|
|
||||||
|
const doSave = async (m: Measurement, existing?: Measurement) => {
|
||||||
|
if (existing) {
|
||||||
|
m.id = existing.id
|
||||||
|
m.createdAt = existing.createdAt
|
||||||
|
await upsertMeasurement(m)
|
||||||
|
} else {
|
||||||
await insertMeasurement(m)
|
await insertMeasurement(m)
|
||||||
}
|
}
|
||||||
await doSave(m)
|
doSync()
|
||||||
}
|
}
|
||||||
|
|
||||||
const doSave = async (m: Measurement) => {
|
const doSync = async () => {
|
||||||
await refreshCache()
|
|
||||||
navigation.goBack()
|
|
||||||
uploadInBackground(m)
|
|
||||||
}
|
|
||||||
|
|
||||||
const uploadInBackground = async (m: Measurement) => {
|
|
||||||
try {
|
try {
|
||||||
const { uploadMeasurement } = await import('../sync/syncService')
|
const { syncAll } = await import('../sync/syncService')
|
||||||
await uploadMeasurement(m)
|
await syncAll()
|
||||||
showSuccess('Opgeslagen & gesynct')
|
Alert.alert('Opgeslagen & gesynct', 'Meting is opgeslagen en gesynchroniseerd', [
|
||||||
|
{ text: 'OK', onPress: () => navigation.goBack() },
|
||||||
|
])
|
||||||
} catch {
|
} catch {
|
||||||
showError('Opgeslagen (sync mislukt)')
|
Alert.alert('Opgeslagen', 'Meting is opgeslagen (sync mislukt)', [
|
||||||
|
{ text: 'OK', onPress: () => navigation.goBack() },
|
||||||
|
])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -100,7 +111,6 @@ 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}
|
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 { ApiConfig } from '../types'
|
import { WebDAVConfig } from '../types'
|
||||||
|
|
||||||
const CONFIG_KEY = 'weegschaal_api_config'
|
const CONFIG_KEY = 'weegschaal_webdav_config'
|
||||||
|
|
||||||
async function loadConfig(): Promise<ApiConfig | null> {
|
async function loadConfig(): Promise<WebDAVConfig | 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<ApiConfig | null> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function saveConfig(config: ApiConfig): Promise<void> {
|
async function saveConfig(config: WebDAVConfig): Promise<void> {
|
||||||
await SecureStore.setItemAsync(CONFIG_KEY, JSON.stringify(config))
|
await SecureStore.setItemAsync(CONFIG_KEY, JSON.stringify(config))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -22,32 +22,37 @@ async function deleteConfig(): Promise<void> {
|
||||||
await SecureStore.deleteItemAsync(CONFIG_KEY)
|
await SecureStore.deleteItemAsync(CONFIG_KEY)
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getApiConfig(): Promise<ApiConfig | null> {
|
export async function getWebDAVConfig(): Promise<WebDAVConfig | null> {
|
||||||
return loadConfig()
|
return loadConfig()
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function SettingsScreen() {
|
export default function SettingsScreen() {
|
||||||
const [url, setUrl] = useState('https://weegschaal.eddydevink.nl')
|
const [url, setUrl] = useState('https://nextcloud.eddydevink.nl/remote.php/dav/files/eddy')
|
||||||
const [token, setToken] = useState('')
|
const [username, setUsername] = useState('')
|
||||||
const [showToken, setShowToken] = useState(false)
|
const [password, setPassword] = useState('')
|
||||||
|
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)
|
||||||
setToken(c.token)
|
setUsername(c.username)
|
||||||
|
setPassword(c.password)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
await saveConfig({ url, token })
|
await saveConfig({ url, username, password })
|
||||||
Alert.alert('Opgeslagen', 'API instellingen zijn opgeslagen')
|
Alert.alert('Opgeslagen', 'WebDAV instellingen zijn opgeslagen')
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleClear = () => {
|
const handleClear = () => {
|
||||||
Alert.alert('Instellingen wissen?', 'API configuratie definitief verwijderen?', [
|
Alert.alert(
|
||||||
|
'Instellingen wissen?',
|
||||||
|
'WebDAV configuratie definitief verwijderen? Je kunt deze later opnieuw invoeren.',
|
||||||
|
[
|
||||||
{ text: 'Annuleren', style: 'cancel' },
|
{ text: 'Annuleren', style: 'cancel' },
|
||||||
{
|
{
|
||||||
text: 'Ja, wissen',
|
text: 'Ja, wissen',
|
||||||
|
|
@ -55,10 +60,12 @@ export default function SettingsScreen() {
|
||||||
onPress: async () => {
|
onPress: async () => {
|
||||||
await deleteConfig()
|
await deleteConfig()
|
||||||
setUrl('')
|
setUrl('')
|
||||||
setToken('')
|
setUsername('')
|
||||||
|
setPassword('')
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
])
|
]
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleSync = async () => {
|
const handleSync = async () => {
|
||||||
|
|
@ -66,11 +73,7 @@ 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()
|
||||||
let msg = `Opgehaald: ${result.pulled}, gepusht: ${result.pushed}, verwijderd: ${result.deleted}`
|
Alert.alert('Sync voltooid', `Lokaal: ${result.local}, WebDAV: ${result.remote}, samengevoegd: ${result.merged}`)
|
||||||
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 {
|
||||||
|
|
@ -80,30 +83,38 @@ export default function SettingsScreen() {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ScrollView style={styles.container} contentContainerStyle={styles.content}>
|
<ScrollView style={styles.container} contentContainerStyle={styles.content}>
|
||||||
<Text style={styles.sectionTitle}>REST API</Text>
|
<Text style={styles.sectionTitle}>WebDAV (Nextcloud)</Text>
|
||||||
|
|
||||||
<Text style={styles.label}>API URL</Text>
|
<Text style={styles.label}>URL</Text>
|
||||||
<TextInput
|
<TextInput
|
||||||
style={styles.input}
|
style={styles.input}
|
||||||
value={url}
|
value={url}
|
||||||
onChangeText={setUrl}
|
onChangeText={setUrl}
|
||||||
placeholder="https://weegschaal.eddydevink.nl"
|
placeholder="https://nextcloud.eddydevink.nl/remote.php/dav/files/gebruiker/"
|
||||||
autoCapitalize="none"
|
autoCapitalize="none"
|
||||||
autoCorrect={false}
|
autoCorrect={false}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Text style={styles.label}>Token</Text>
|
<Text style={styles.label}>Gebruikersnaam</Text>
|
||||||
<View style={styles.passwordRow}>
|
|
||||||
<TextInput
|
<TextInput
|
||||||
style={styles.passwordInput}
|
style={styles.input}
|
||||||
value={token}
|
value={username}
|
||||||
onChangeText={setToken}
|
onChangeText={setUsername}
|
||||||
secureTextEntry={!showToken}
|
|
||||||
autoCapitalize="none"
|
autoCapitalize="none"
|
||||||
autoCorrect={false}
|
autoCorrect={false}
|
||||||
/>
|
/>
|
||||||
<TouchableOpacity style={styles.eyeButton} onPress={() => setShowToken(!showToken)}>
|
|
||||||
<Text style={styles.eyeIcon}>{showToken ? '👁' : '🔒'}</Text>
|
<Text style={styles.label}>Wachtwoord</Text>
|
||||||
|
<View style={styles.passwordRow}>
|
||||||
|
<TextInput
|
||||||
|
style={styles.passwordInput}
|
||||||
|
value={password}
|
||||||
|
onChangeText={setPassword}
|
||||||
|
secureTextEntry={!showPassword}
|
||||||
|
autoCapitalize="none"
|
||||||
|
/>
|
||||||
|
<TouchableOpacity style={styles.eyeButton} onPress={() => setShowPassword(!showPassword)}>
|
||||||
|
<Text style={styles.eyeIcon}>{showPassword ? '👁' : '👁🗨'}</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,20 +0,0 @@
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
@ -1,70 +0,0 @@
|
||||||
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)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,146 +1,139 @@
|
||||||
import { Measurement } from '../types'
|
import { Measurement } from '../types'
|
||||||
import {
|
import { initDatabase, getAllMeasurementsIncludingDeleted, upsertMeasurement } from '../database/db'
|
||||||
initDatabase,
|
import { getWebDAVConfig } from '../screens/SettingsScreen'
|
||||||
getAllMeasurementsForSync,
|
import { encodeBase64 } from '../utils/base64'
|
||||||
upsertMeasurement,
|
|
||||||
getPendingRecords,
|
|
||||||
updateSyncStatus,
|
|
||||||
removeMeasurement,
|
|
||||||
} from '../database/db'
|
|
||||||
import { createApi, ApiError } from './api'
|
|
||||||
import * as SecureStore from 'expo-secure-store'
|
|
||||||
|
|
||||||
const LAST_SYNC_KEY = 'weegschaal_last_sync'
|
const WEBDAV_PATH = 'weegschaal/measurements.json'
|
||||||
|
const SYNC_TIMEOUT_MS = 15_000
|
||||||
|
|
||||||
type SyncResult = {
|
type SyncResult = { local: number; remote: number; merged: number }
|
||||||
pushed: number
|
|
||||||
deleted: number
|
export function mergeMeasurements(local: Measurement[], remote: Measurement[]): Measurement[] {
|
||||||
pulled: number
|
const map = new Map<string, Measurement>()
|
||||||
errors: string[]
|
|
||||||
|
for (const m of local) {
|
||||||
|
map.set(m.id, m)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function mergeMeasurements(local: Measurement[], remote: any[]): Measurement[] {
|
for (const m of remote) {
|
||||||
const localMap = new Map(local.map(m => [m.id, m]))
|
const existing = map.get(m.id)
|
||||||
const resultMap = new Map(local.map(m => [m.id, m]))
|
if (!existing) {
|
||||||
|
map.set(m.id, { ...m, syncStatus: 'synced' })
|
||||||
for (const r of remote) {
|
|
||||||
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 {
|
} else {
|
||||||
resultMap.set(r.id, { ...r, syncStatus: 'synced' } as Measurement)
|
const remoteNewer = new Date(m.updatedAt) > new Date(existing.updatedAt)
|
||||||
|
if (remoteNewer) {
|
||||||
|
map.set(m.id, { ...m, syncStatus: 'synced' })
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return Array.from(resultMap.values())
|
return Array.from(map.values()).filter(m => !m.deleted)
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function uploadMeasurement(m: Measurement): Promise<void> {
|
async function fetchRemote(config: { url: string; username: string; password: string }): Promise<Measurement[]> {
|
||||||
const { getApiConfig } = await import('../screens/SettingsScreen')
|
const url = `${config.url.replace(/\/+$/, '')}/${WEBDAV_PATH}`
|
||||||
const config = await getApiConfig()
|
const auth = encodeBase64(`${config.username}:${config.password}`)
|
||||||
if (!config) throw new Error('Geen API configuratie. Stel deze eerst in in Instellingen.')
|
|
||||||
const api = createApi(config)
|
const controller = new AbortController()
|
||||||
await api.createMeasurement(m)
|
const timer = setTimeout(() => controller.abort(), SYNC_TIMEOUT_MS)
|
||||||
await updateSyncStatus(m.id, 'synced')
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(url, {
|
||||||
|
headers: { Authorization: `Basic ${auth}` },
|
||||||
|
signal: controller.signal,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (response.status === 404) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
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> {
|
export async function syncAll(): Promise<SyncResult> {
|
||||||
const { getApiConfig } = await import('../screens/SettingsScreen')
|
const config = await getWebDAVConfig()
|
||||||
const config = await getApiConfig()
|
if (!config) {
|
||||||
if (!config) throw new Error('Geen API configuratie. Stel deze eerst in in Instellingen.')
|
throw new Error('Geen WebDAV configuratie. Stel deze eerst in in Instellingen.')
|
||||||
|
}
|
||||||
|
|
||||||
const api = createApi(config)
|
|
||||||
await initDatabase()
|
await initDatabase()
|
||||||
|
|
||||||
const errors: string[] = []
|
const local = await getAllMeasurementsIncludingDeleted()
|
||||||
let pushed = 0
|
const remote = await fetchRemote(config)
|
||||||
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 {
|
|
||||||
try {
|
|
||||||
await api.createMeasurement(m)
|
|
||||||
await updateSyncStatus(m.id, 'synced')
|
|
||||||
pushed++
|
|
||||||
} catch (e: any) {
|
|
||||||
errors.push(e?.message ?? 'Create failed')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// === PULL phase — fetch remote changes ===
|
|
||||||
const lastSyncAt = await SecureStore.getItemAsync(LAST_SYNC_KEY)
|
|
||||||
const remote = await api.getMeasurements(lastSyncAt || undefined)
|
|
||||||
|
|
||||||
if (remote.length > 0) {
|
|
||||||
const local = await getAllMeasurementsForSync()
|
|
||||||
const merged = mergeMeasurements(local, remote)
|
const merged = mergeMeasurements(local, remote)
|
||||||
|
|
||||||
const localMap = new Map(local.map(m => [m.id, m]))
|
|
||||||
|
|
||||||
for (const m of merged) {
|
for (const m of merged) {
|
||||||
if (m.deletedAt) {
|
|
||||||
await removeMeasurement(m.id)
|
|
||||||
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 (isNew || isUpdated) {
|
|
||||||
await upsertMeasurement(m)
|
await upsertMeasurement(m)
|
||||||
await updateSyncStatus(m.id, 'synced')
|
|
||||||
pulled++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
await SecureStore.setItemAsync(LAST_SYNC_KEY, new Date().toISOString())
|
await uploadRemote(config, merged)
|
||||||
|
|
||||||
return { pushed, deleted, pulled, errors }
|
return {
|
||||||
|
local: local.filter(m => !m.deleted).length,
|
||||||
|
remote: remote.filter(m => !m.deleted).length,
|
||||||
|
merged: merged.length,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
11
src/types.ts
11
src/types.ts
|
|
@ -1,3 +1,5 @@
|
||||||
|
export type SyncStatus = 'local' | 'synced'
|
||||||
|
|
||||||
export type Measurement = {
|
export type Measurement = {
|
||||||
id: string
|
id: string
|
||||||
weight: number
|
weight: number
|
||||||
|
|
@ -11,11 +13,12 @@ export type Measurement = {
|
||||||
measuredAt: string
|
measuredAt: string
|
||||||
createdAt: string
|
createdAt: string
|
||||||
updatedAt: string
|
updatedAt: string
|
||||||
syncStatus?: string
|
syncStatus: SyncStatus
|
||||||
deletedAt?: string
|
deleted: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ApiConfig = {
|
export type WebDAVConfig = {
|
||||||
url: string
|
url: string
|
||||||
token: string
|
username: string
|
||||||
|
password: string
|
||||||
}
|
}
|
||||||
|
|
|
||||||
17
src/utils/base64.ts
Normal file
17
src/utils/base64.ts
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
@ -1,26 +0,0 @@
|
||||||
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)
|
|
||||||
}
|
|
||||||
|
|
@ -18,6 +18,8 @@ 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', () => {
|
||||||
|
|
@ -26,7 +28,9 @@ describe('database', () => {
|
||||||
let insertMeasurement: any
|
let insertMeasurement: any
|
||||||
let getAllMeasurements: any
|
let getAllMeasurements: any
|
||||||
let getLatestMeasurement: any
|
let getLatestMeasurement: any
|
||||||
let setDeletedAt: any
|
let updateMeasurement: any
|
||||||
|
let deleteMeasurement: any
|
||||||
|
let getAllMeasurementsIncludingDeleted: any
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
jest.resetModules()
|
jest.resetModules()
|
||||||
|
|
@ -34,8 +38,8 @@ describe('database', () => {
|
||||||
openDatabaseAsync: jest.fn(),
|
openDatabaseAsync: jest.fn(),
|
||||||
}))
|
}))
|
||||||
db = {
|
db = {
|
||||||
execAsync: jest.fn(),
|
execAsync: jest.fn(() => Promise.resolve()),
|
||||||
runAsync: jest.fn(),
|
runAsync: jest.fn(() => Promise.resolve()),
|
||||||
getAllAsync: jest.fn(),
|
getAllAsync: jest.fn(),
|
||||||
}
|
}
|
||||||
const sqlite = require('expo-sqlite')
|
const sqlite = require('expo-sqlite')
|
||||||
|
|
@ -45,7 +49,9 @@ describe('database', () => {
|
||||||
insertMeasurement = mod.insertMeasurement
|
insertMeasurement = mod.insertMeasurement
|
||||||
getAllMeasurements = mod.getAllMeasurements
|
getAllMeasurements = mod.getAllMeasurements
|
||||||
getLatestMeasurement = mod.getLatestMeasurement
|
getLatestMeasurement = mod.getLatestMeasurement
|
||||||
setDeletedAt = mod.setDeletedAt
|
updateMeasurement = mod.updateMeasurement
|
||||||
|
deleteMeasurement = mod.deleteMeasurement
|
||||||
|
getAllMeasurementsIncludingDeleted = mod.getAllMeasurementsIncludingDeleted
|
||||||
})
|
})
|
||||||
|
|
||||||
it('initDatabase creates the measurements table', async () => {
|
it('initDatabase creates the measurements table', async () => {
|
||||||
|
|
@ -81,15 +87,42 @@ describe('database', () => {
|
||||||
expect(result!.id).toBe(mockMeasurement.id)
|
expect(result!.id).toBe(mockMeasurement.id)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('setDeletedAt sets both deletedAt and updatedAt timestamps', async () => {
|
it('updateMeasurement updates syncStatus', async () => {
|
||||||
db.runAsync.mockResolvedValue(undefined)
|
db.runAsync.mockResolvedValue(undefined)
|
||||||
await initDatabase()
|
await initDatabase()
|
||||||
await setDeletedAt(mockMeasurement.id)
|
await updateMeasurement(mockMeasurement.id, 'synced')
|
||||||
expect(db.runAsync).toHaveBeenCalledWith(
|
expect(db.runAsync).toHaveBeenCalledWith(
|
||||||
expect.stringContaining('UPDATE measurements SET deletedAt'),
|
expect.stringContaining('UPDATE measurements'),
|
||||||
|
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')
|
||||||
|
)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -1,86 +1,102 @@
|
||||||
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', () => ({ openDatabaseAsync: jest.fn() }))
|
jest.mock('expo-sqlite', () => ({
|
||||||
|
openDatabaseAsync: jest.fn(),
|
||||||
|
}))
|
||||||
|
|
||||||
const make = (overrides: Partial<Measurement> = {}): Measurement => ({
|
const base: Measurement = {
|
||||||
id: 'id-1', weight: 75, bmi: 22, bodyFatPercentage: 15, fatMass: 10,
|
id: 'id-1',
|
||||||
musclePercentage: 40, muscleMass: 30, visceralFat: 8, bmr: 1600,
|
weight: 75,
|
||||||
measuredAt: '2024-06-01T08:00:00.000Z', createdAt: '2024-06-01T08:00:00.000Z',
|
bmi: 22,
|
||||||
updatedAt: '2024-06-01T08:00:00.000Z', syncStatus: 'synced', ...overrides,
|
bodyFatPercentage: 15,
|
||||||
})
|
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('both empty returns empty', () => {
|
it('returns empty array when both are empty', () => {
|
||||||
expect(mergeMeasurements([], [])).toEqual([])
|
expect(mergeMeasurements([], [])).toEqual([])
|
||||||
})
|
})
|
||||||
|
|
||||||
it('local only kept', () => {
|
it('keeps local items not on remote', () => {
|
||||||
const r = mergeMeasurements([make()], [])
|
const result = mergeMeasurements([base], [])
|
||||||
expect(r).toHaveLength(1)
|
expect(result).toHaveLength(1)
|
||||||
|
expect(result[0].id).toBe('id-1')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('remote only kept', () => {
|
it('keeps remote items not on local', () => {
|
||||||
const r = mergeMeasurements([], [make({ id: 'r1' })])
|
const remote = { ...base, syncStatus: 'synced' as const }
|
||||||
expect(r).toHaveLength(1)
|
const result = mergeMeasurements([], [remote])
|
||||||
expect(r[0].id).toBe('r1')
|
expect(result).toHaveLength(1)
|
||||||
|
expect(result[0].id).toBe('id-1')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('last-writer-wins: remote newer', () => {
|
it('picks the newer version when both have same id', () => {
|
||||||
const local = make({ weight: 75, updatedAt: '2024-06-02T08:00:00.000Z' })
|
const local = { ...base, weight: 75, updatedAt: '2024-06-02T08:00:00.000Z' }
|
||||||
const remote = make({ weight: 76, updatedAt: '2024-06-03T08:00:00.000Z' })
|
const remote = { ...base, weight: 76, updatedAt: '2024-06-03T08:00:00.000Z' }
|
||||||
const r = mergeMeasurements([local], [remote])
|
const result = mergeMeasurements([local], [remote])
|
||||||
expect(r[0].weight).toBe(76)
|
expect(result).toHaveLength(1)
|
||||||
|
expect(result[0].weight).toBe(76)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('last-writer-wins: local newer', () => {
|
it('picks local when local is newer', () => {
|
||||||
const local = make({ weight: 77, updatedAt: '2024-06-04T08:00:00.000Z' })
|
const local = { ...base, weight: 77, updatedAt: '2024-06-04T08:00:00.000Z' }
|
||||||
const remote = make({ weight: 76, updatedAt: '2024-06-03T08:00:00.000Z' })
|
const remote = { ...base, weight: 76, updatedAt: '2024-06-03T08:00:00.000Z' }
|
||||||
const r = mergeMeasurements([local], [remote])
|
const result = mergeMeasurements([local], [remote])
|
||||||
expect(r[0].weight).toBe(77)
|
expect(result).toHaveLength(1)
|
||||||
|
expect(result[0].weight).toBe(77)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('remote deleted wins over synced local', () => {
|
it('filters out locally deleted items even if present on remote', () => {
|
||||||
const local = make({ updatedAt: '2024-06-01T08:00:00.000Z' })
|
const local = { ...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 remote = { ...base, deleted: 0 as const, updatedAt: '2024-06-03T08:00:00.000Z' }
|
||||||
const r = mergeMeasurements([local], [remote])
|
const result = mergeMeasurements([local], [remote])
|
||||||
expect(r[0].deletedAt).toBe('2024-06-03T08:00:00.000Z')
|
expect(result).toHaveLength(0)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('local synced wins over older remote deleted', () => {
|
it('filters out remotely deleted items when remote is newer', () => {
|
||||||
const local = make({ updatedAt: '2024-06-03T08:00:00.000Z' })
|
const local = { ...base, deleted: 0 as const, updatedAt: '2024-06-04T08:00:00.000Z' }
|
||||||
const remote: any = { ...make({ updatedAt: '2024-06-01T08:00:00.000Z' }), deleted: true }
|
const remote = { ...base, deleted: 1 as const, updatedAt: '2024-06-05T08:00:00.000Z' }
|
||||||
const r = mergeMeasurements([local], [remote])
|
const result = mergeMeasurements([local], [remote])
|
||||||
expect(r[0].deletedAt).toBeUndefined()
|
expect(result).toHaveLength(0)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('local pending-delete wins over remote deleted', () => {
|
it('keeps local active item when local is newer than remote deletion', () => {
|
||||||
const local = make({ updatedAt: '2024-06-01T08:00:00.000Z', syncStatus: 'pending-delete', deletedAt: '2024-06-05T08:00:00.000Z' })
|
const local = { ...base, deleted: 0 as const, weight: 78, updatedAt: '2024-06-06T08:00:00.000Z' }
|
||||||
const remote: any = { ...make({ updatedAt: '2024-06-03T08:00:00.000Z' }), deleted: true }
|
const remote = { ...base, deleted: 1 as const, updatedAt: '2024-06-05T08:00:00.000Z' }
|
||||||
const r = mergeMeasurements([local], [remote])
|
const result = mergeMeasurements([local], [remote])
|
||||||
expect(r[0].syncStatus).toBe('pending-delete')
|
expect(result).toHaveLength(1)
|
||||||
expect(r[0].deletedAt).toBe('2024-06-05T08:00:00.000Z')
|
expect(result[0].weight).toBe(78)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('local pending wins over remote newer', () => {
|
it('keeps active item when local deleted is older than remote active', () => {
|
||||||
const local = make({ weight: 75, updatedAt: '2024-06-01T08:00:00.000Z', syncStatus: 'pending' })
|
const local = { ...base, deleted: 1 as const, updatedAt: '2024-06-01T08:00:00.000Z' }
|
||||||
const remote: any = { ...make({ weight: 76, updatedAt: '2024-06-03T08:00:00.000Z' }) }
|
const remote = { ...base, deleted: 0 as const, updatedAt: '2024-06-05T08:00:00.000Z', syncStatus: 'synced' as const }
|
||||||
const r = mergeMeasurements([local], [remote])
|
const result = mergeMeasurements([local], [remote])
|
||||||
expect(r[0].weight).toBe(75)
|
expect(result).toHaveLength(1)
|
||||||
expect(r[0].syncStatus).toBe('pending')
|
expect(result[0].weight).toBe(75)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('remote deleted with no local record is skipped', () => {
|
it('merges multiple active and deleted items correctly', () => {
|
||||||
const remote: any = { ...make({ id: 'r1', updatedAt: '2024-06-03T08:00:00.000Z' }), deleted: true }
|
const localActive = { ...base, id: 'a', weight: 70, updatedAt: '2024-06-05T08:00:00.000Z' }
|
||||||
const r = mergeMeasurements([], [remote])
|
const localDeleted = { ...base, id: 'b', weight: 80, deleted: 1 as const, updatedAt: '2024-06-05T08:00:00.000Z' }
|
||||||
expect(r).toHaveLength(0)
|
const remoteActive = { ...base, id: 'c', weight: 90, updatedAt: '2024-06-03T08:00:00.000Z', syncStatus: 'synced' as const }
|
||||||
})
|
const remoteDeleted = { ...base, id: 'd', weight: 100, deleted: 1 as const, updatedAt: '2024-06-05T08:00:00.000Z' }
|
||||||
|
|
||||||
it('local without syncStatus treated as synced', () => {
|
const result = mergeMeasurements(
|
||||||
const local = make({ weight: 75, updatedAt: '2024-06-01T08:00:00.000Z' })
|
[localActive, localDeleted],
|
||||||
delete local.syncStatus
|
[remoteActive, remoteDeleted]
|
||||||
const remote = make({ weight: 76, updatedAt: '2024-06-03T08:00:00.000Z' })
|
)
|
||||||
const r = mergeMeasurements([local], [remote])
|
expect(result).toHaveLength(2)
|
||||||
expect(r[0].weight).toBe(76)
|
expect(result.map(m => m.id).sort()).toEqual(['a', 'c'])
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
|
||||||
140
upgrade.md
140
upgrade.md
|
|
@ -1,140 +0,0 @@
|
||||||
# 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
|
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 50 KiB |
Loading…
Reference in a new issue