Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ebe0eafa23 | ||
| 38137fabb4 | |||
|
|
97c0e8ad67 | ||
|
|
e3960a389e | ||
|
|
edbf69d6d9 | ||
|
|
a1e8a7ed48 | ||
| aafa041840 | |||
|
|
d94628ce86 | ||
|
|
a77b31f95c | ||
|
|
a32b9e0a88 | ||
|
|
4c34dff629 | ||
|
|
55572a1455 | ||
|
|
491ea1ff16 | ||
|
|
4a7c89bdd8 | ||
|
|
b65c7a7043 | ||
|
|
2e6c3fd588 | ||
|
|
e9a17d32a2 | ||
|
|
dc85cd3ff0 | ||
|
|
86d6cde478 | ||
|
|
73d327f914 | ||
|
|
df91f4d46f | ||
|
|
e93d12565e | ||
|
|
d0dd2f4e39 | ||
|
|
8e1783306a | ||
|
|
b043a5c1d2 | ||
|
|
143572bc81 | ||
|
|
23db445366 | ||
|
|
4b7b7e3d5c | ||
|
|
79c91eb537 | ||
|
|
cec6c54e61 | ||
|
|
a13f2b821b | ||
|
|
ac681dbfaa | ||
|
|
ed714af8d1 |
31 changed files with 3421 additions and 410 deletions
6
.gitignore
vendored
6
.gitignore
vendored
|
|
@ -111,6 +111,12 @@ test/fixtures/
|
|||
test/sample-pdfs/
|
||||
test/output/
|
||||
|
||||
# Expo
|
||||
.expo/
|
||||
|
||||
# APK builds
|
||||
*.apk
|
||||
|
||||
# Temporary files
|
||||
temp/
|
||||
tmp/
|
||||
|
|
|
|||
17
App.tsx
17
App.tsx
|
|
@ -5,8 +5,18 @@ import DashboardScreen from './src/screens/DashboardScreen'
|
|||
import NewMeasurementScreen from './src/screens/NewMeasurementScreen'
|
||||
import HistoryScreen from './src/screens/HistoryScreen'
|
||||
import SettingsScreen from './src/screens/SettingsScreen'
|
||||
import MeasurementDetailScreen from './src/screens/MeasurementDetailScreen'
|
||||
import { Measurement } from './src/types'
|
||||
|
||||
const Stack = createNativeStackNavigator()
|
||||
type RootStackParamList = {
|
||||
Dashboard: undefined
|
||||
NewMeasurement: { measurement?: Measurement }
|
||||
History: undefined
|
||||
MeasurementDetail: { measurementId: string }
|
||||
Settings: undefined
|
||||
}
|
||||
|
||||
const Stack = createNativeStackNavigator<RootStackParamList>()
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
|
|
@ -27,6 +37,11 @@ export default function App() {
|
|||
component={HistoryScreen}
|
||||
options={{ title: 'Historie' }}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="MeasurementDetail"
|
||||
component={MeasurementDetailScreen}
|
||||
options={{ title: 'Meting' }}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="Settings"
|
||||
component={SettingsScreen}
|
||||
|
|
|
|||
145
README.md
145
README.md
|
|
@ -1,24 +1,117 @@
|
|||
# Weegschaal App
|
||||
|
||||
React Native (Expo) app voor het handmatig invoeren van meetgegevens van een slimme weegschaal. Data wordt lokaal opgeslagen in SQLite en gesynchroniseerd met Nextcloud via WebDAV.
|
||||
_*Gemaakt met vibecoding — DeepSeek V4 Pro*_
|
||||
|
||||
## Meetwaarden
|
||||
React Native (Expo) app voor het invoeren en synchroniseren van lichaamsmetingen (weegschaal). Data wordt lokaal opgeslagen in SQLite en gesynchroniseerd via een eigen REST API met outbox-pattern sync.
|
||||
|
||||
- Gewicht (kg)
|
||||
- BMI
|
||||
- Vetpercentage (%)
|
||||
- Vetmassa (kg) — automatisch berekend
|
||||
- Spierpercentage (%)
|
||||
- Spiermassa (kg) — automatisch berekend
|
||||
- Buikvet (index)
|
||||
- Stofwisseling in rust (kcal)
|
||||
## Inhoud
|
||||
|
||||
- [Functionaliteit](#functionaliteit)
|
||||
- [Schermen](#schermen)
|
||||
- [Synchronisatie](#synchronisatie)
|
||||
- [Server-side API](#server-side-api)
|
||||
- [Beveiliging](#beveiliging)
|
||||
- [Offline resilience](#offline-resilience)
|
||||
- [Ontwikkeling](#ontwikkeling)
|
||||
- [Build & deploy](#build--deploy)
|
||||
- [Testen](#testen)
|
||||
- [Licentie](#licentie)
|
||||
|
||||
## Functionaliteit
|
||||
|
||||
- **Metingen invoeren** van een slimme weegschaal: gewicht, BMI, vetpercentage, vetmassa, spierpercentage, spiermassa, buikvet en stofwisseling
|
||||
- **Dashboard** met laatste en voorlaatste meting (direct zichtbaar via progressieve loading), gewichtsverschil, en grafieken (gewicht, vetmassa, spiermassa)
|
||||
- **Delen** van meetresultaten als PNG-afbeelding via WhatsApp, e-mail, etc.
|
||||
- **Historie** van alle metingen, gesorteerd op datum
|
||||
- **Offline-first**: metingen worden lokaal opgeslagen en later automatisch gesynchroniseerd
|
||||
- **Instellingen** voor API-configuratie en handmatige sync
|
||||
|
||||
## Schermen
|
||||
|
||||
- **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
|
||||
- **Historie** — FlatList gesorteerd op ingevoerde datum (nieuwste bovenaan), pull-to-refresh
|
||||
- **Instellingen** — WebDAV configuratie (URL, gebruikersnaam, wachtwoord met oogje voor tonen/verbergen), handmatige sync, wissen met bevestigingsdialoog
|
||||
| Scherm | Beschrijving |
|
||||
|--------|-------------|
|
||||
| Dashboard | Progressive loading: laatste 2 metingen direct zichtbaar, grafieken volgen daarna |
|
||||
| Nieuwe meting / Wijzigen | Formulier met datumkiezer, waarden vooraf ingevuld vanaf laatste meting |
|
||||
| Meting detail | Alle meetwaarden met knoppen voor wijzigen en verwijderen |
|
||||
| Historie | FlatList van alle metingen, pull-to-refresh, tik voor detail |
|
||||
| Instellingen | API URL + token, opslaan/wissen, handmatige sync |
|
||||
|
||||
## Synchronisatie
|
||||
|
||||
De app synchroniseert via een zelfgebouwde REST API. Geen WebDAV, geen bestandssync — pure delta-sync.
|
||||
|
||||
### Hoe het werkt
|
||||
|
||||
1. **Outbox pattern**: elke meting heeft een `syncStatus` — `pending` (nieuw/gewijzigd), `pending-delete` (verwijderd), of `synced` (in sync met server)
|
||||
2. **Push vóór pull**: eerst worden lokale wijzigingen (`pending` + `pending-delete`) naar de server gepusht, daarna pas remote changes opgehaald
|
||||
3. **Local-first merge**: remote data overschrijft nooit lokale `pending` of `pending-delete` records — de gebruiker wint altijd
|
||||
4. **Verwijderen**: `setDeletedAt(id)` lokaal + `updateSyncStatus(id, 'pending-delete')` → push in outbox-fase → server DELETE → hard delete in pull-fase
|
||||
5. **Cursor tracking**: `lastSyncAt` (SecureStore) voor delta-pull, alleen records sinds laatste sync
|
||||
|
||||
### Waarom outbox pattern?
|
||||
|
||||
Betrouwbaarder dan timestamp-only push: `syncStatus` markeert expliciet wat gepusht moet worden, onafhankelijk van klokverschillen. Push vóór pull voorkomt dat eigen wijzigingen overschreven worden door oudere remote data. Local-first merge garandeert dat de gebruiker nooit data verliest.
|
||||
|
||||
### Offline resilience
|
||||
|
||||
Bij netwerkfout tijdens opslaan blijft de meting lokaal staan met `syncStatus='pending'`. De outbox (`getPendingRecords()`) pikt hem automatisch op bij de volgende sync of dashboard-focus.
|
||||
|
||||
## Server-side API
|
||||
|
||||
**Stack**: nginx, PHP 8.5 (Slim Framework 4), MariaDB, Let's Encrypt SSL, SELinux
|
||||
|
||||
**Endpoints**:
|
||||
|
||||
| Methode | Pad | Auth | Beschrijving |
|
||||
|---------|-----|------|-------------|
|
||||
| `GET` | `/api/v1/measurements?since=ISO` | Bearer | Delta-sync |
|
||||
| `POST` | `/api/v1/measurements` | Bearer | Upsert (INSERT ON DUPLICATE KEY) |
|
||||
| `DELETE` | `/api/v1/measurements/:id` | Bearer | Soft delete (`deleted=1`, update `updated_at`) |
|
||||
|
||||
**Database**: MariaDB met drie tabellen — `users`, `auth_tokens`, `measurements`. De `measurements` tabel gebruikt `ON DUPLICATE KEY UPDATE` voor conflictvrije upserts.
|
||||
|
||||
**Bestandsstructuur**:
|
||||
```
|
||||
/usr/share/nginx/html/weegschaal-api/
|
||||
├── composer.json
|
||||
├── config.php
|
||||
├── public/
|
||||
│ └── index.php
|
||||
└── src/
|
||||
├── Auth.php ← Bearer token validatie middleware
|
||||
├── Database.php ← PDO singleton
|
||||
├── Measurements.php ← CRUD + sync queries
|
||||
└── helpers.php ← JSON response, datetime conversie, logging
|
||||
```
|
||||
|
||||
## Beveiliging
|
||||
|
||||
### Authenticatie
|
||||
|
||||
Bearer token-validatie via `auth_tokens` tabel. Tokens worden handmatig aangemaakt via SQL.
|
||||
|
||||
### Logging
|
||||
|
||||
Elke mislukte authenticatie en 404 wordt gelogd naar `/var/log/weegschaal-api/auth.log`:
|
||||
|
||||
```
|
||||
[2026-07-20 18:06:07] 401 94.157.121.179 GET /api/v1/measurements - invalid Bearer token
|
||||
[2026-07-20 18:06:07] 404 94.157.121.179 GET /.env - unknown route
|
||||
```
|
||||
|
||||
### Fail2ban
|
||||
|
||||
Jail `weegschaal-api`: 5 foutieve pogingen binnen 5 minuten = 1 uur IP-ban. Filter regex:
|
||||
|
||||
```
|
||||
^\[.*\] 401 <HOST> .* - .*$
|
||||
```
|
||||
|
||||
Logrotering wekelijks, 12 weken retentie.
|
||||
|
||||
### SELinux
|
||||
|
||||
Logmap heeft context `httpd_log_t` gezet via `semanage fcontext`.
|
||||
|
||||
## Ontwikkeling
|
||||
|
||||
|
|
@ -29,15 +122,25 @@ npx expo start
|
|||
|
||||
Scannen met Expo Go op Android of iOS.
|
||||
|
||||
## Synchronisatie
|
||||
## Build & deploy
|
||||
|
||||
Data wordt opgeslagen als `weegschaal/measurements.json` in je Nextcloud WebDAV. Vul in het Instellingen scherm het volgende in:
|
||||
```bash
|
||||
# APK bouwen (lokaal via EAS)
|
||||
npx eas build --local --profile apk --platform android
|
||||
|
||||
- **URL**: `https://<jouw-nextcloud>/remote.php/dav/files/<gebruikersnaam>/`
|
||||
- **Gebruikersnaam**: je Nextcloud gebruikersnaam
|
||||
- **Wachtwoord**: je Nextcloud app-wachtwoord
|
||||
# Draadloos sideloaden via ADB
|
||||
adb-wireless-connect
|
||||
adb install -r build-*.apk
|
||||
```
|
||||
|
||||
Bij sync worden lokale en externe metingen samengevoegd (last-write-wins op basis van `updatedAt`). De map `weegschaal/` wordt automatisch aangemaakt via MKCOL indien deze nog niet bestaat.
|
||||
Vereisten: Android SDK 36, OpenJDK 21, ADB 37+, Gradle 8.14.
|
||||
|
||||
## Database migratie
|
||||
|
||||
Bestaande SQLite-databases worden automatisch gemigreerd bij `initDatabase()`:
|
||||
|
||||
- `deletedAt` kolom: toegevoegd via `ALTER TABLE` als deze nog niet bestaat
|
||||
- `syncStatus` migratie: bestaande records met `syncStatus='local'` worden omgezet naar `'synced'`, nieuwe records krijgen DEFAULT `'pending'`
|
||||
|
||||
## Testen
|
||||
|
||||
|
|
@ -45,6 +148,8 @@ Bij sync worden lokale en externe metingen samengevoegd (last-write-wins op basi
|
|||
npm test
|
||||
```
|
||||
|
||||
Tests richten zich op de merge-logica (`mergeMeasurements`) en database-operaties.
|
||||
|
||||
## Licentie
|
||||
|
||||
Public domain (Unlicense)
|
||||
|
|
|
|||
17
app.json
17
app.json
|
|
@ -13,9 +13,9 @@
|
|||
"package": "nl.eddydevink.weegschaal",
|
||||
"adaptiveIcon": {
|
||||
"backgroundColor": "#E6F4FE",
|
||||
"foregroundImage": "./assets/android-icon-foreground.png",
|
||||
"foregroundImage": "./assets/icon.png",
|
||||
"backgroundImage": "./assets/android-icon-background.png",
|
||||
"monochromeImage": "./assets/android-icon-monochrome.png"
|
||||
"monochromeImage": "./assets/icon.png"
|
||||
},
|
||||
"predictiveBackGestureEnabled": false
|
||||
},
|
||||
|
|
@ -26,6 +26,17 @@
|
|||
"expo-sqlite",
|
||||
"expo-secure-store",
|
||||
"@react-native-community/datetimepicker"
|
||||
]
|
||||
],
|
||||
"extra": {
|
||||
"eas": {
|
||||
"projectId": "9707c719-d5aa-45a4-a6cd-b95ac3d32996"
|
||||
}
|
||||
},
|
||||
"runtimeVersion": {
|
||||
"policy": "appVersion"
|
||||
},
|
||||
"updates": {
|
||||
"url": "https://u.expo.dev/9707c719-d5aa-45a4-a6cd-b95ac3d32996"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
BIN
assets/icon.png
BIN
assets/icon.png
Binary file not shown.
|
Before Width: | Height: | Size: 384 KiB After Width: | Height: | Size: 218 KiB |
70
docs/blog-sync-upgrade.md
Normal file
70
docs/blog-sync-upgrade.md
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
# Van WebDAV naar REST API: een volwassen sync-architectuur voor de weegschaal-app
|
||||
|
||||
*20 juli 2026*
|
||||
|
||||
De afgelopen dagen heb ik de synchronisatie van mijn weegschaal-app volledig herzien. De app slaat metingen lokaal op in SQLite en synchroniseert ze met een centrale server. Tot nu toe gebeurde dat via WebDAV naar Nextcloud — een simpel JSON-bestand dat telkens in zijn geheel werd gedownload, samengevoegd en teruggeüpload. Functioneel, maar niet ideaal.
|
||||
|
||||
## Waarom WebDAV niet voldeed
|
||||
|
||||
Het oude sync-mechanisme was primitief: bij elke sync werd het volledige `measurements.json` bestand van de WebDAV-server gehaald, lokaal gemerged op basis van `updatedAt`, en daarna in zijn geheel teruggeschreven. Met 1300+ metingen werd dat bestand steeds groter, en elke sync — ook als er maar één meting was veranderd — verstuurde het hele bestand over de lijn. Bovendien leverde Basic Auth via WebDAV geen fijnmazige controle over wie toegang had, en was er geen logging van foutieve inlogpogingen.
|
||||
|
||||
## De nieuwe architectuur
|
||||
|
||||
De oplossing is een eigen REST API, gehost op een subdomein van mijn domein, met een MariaDB-backend en Bearer token-authenticatie. De stack:
|
||||
|
||||
- **Server**: Fedora Linux, nginx, PHP 8.5 (Slim Framework 4), MariaDB
|
||||
- **Client**: React Native (Expo), expo-sqlite, expo-secure-store
|
||||
- **Authenticatie**: Bearer token, handmatig aangemaakt via SQL
|
||||
- **Sync**: incrementeel via `?since=` timestamps — last-writer-wins
|
||||
|
||||
### Endpoints
|
||||
|
||||
De API kent drie endpoints, allemaal beschermd met Bearer token-validatie:
|
||||
|
||||
| Methode | Pad | Werking |
|
||||
|---------|-----|---------|
|
||||
| `GET` | `/api/v1/measurements?since=ISO` | Delta-sync: alleen metingen met `updated_at > since` |
|
||||
| `POST` | `/api/v1/measurements` | Upsert op client-ID — de server accepteert het client-gegenereerde UUID |
|
||||
| `DELETE` | `/api/v1/measurements/:id` | Soft delete: zet `deleted=1` en update `updated_at` |
|
||||
|
||||
Geen PUT, geen register/token-endpoints. Minimaal en doelgericht.
|
||||
|
||||
### Incrementele sync
|
||||
|
||||
De sync-flow is nu gebaseerd op timestamps, zonder `syncStatus` enum. Het mechanisme is hetzelfde als wat CouchDB, PouchDB en tal van productie-apps gebruiken:
|
||||
|
||||
1. **Delta ophalen**: `GET ?since=lastSyncAt` → alleen gewijzigde records van de server
|
||||
2. **Mergen**: last-writer-wins op `updatedAt`. Remote records met `deleted=true` overschrijven lokaal
|
||||
3. **Pushen**: alle lokale records met `updatedAt > lastSyncAt` worden naar de server gestuurd
|
||||
4. **Verwijderde records**: lokaal een `deletedAt` timestamp gezet, bij volgende sync via `DELETE` naar de server
|
||||
5. **High-water mark**: `lastSyncAt` wordt bijgewerkt naar de huidige tijd
|
||||
|
||||
Bij een netwerkfout blijft een meting gewoon lokaal staan — de volgende sync pakt hem automatisch op omdat `updatedAt > lastSyncAt`.
|
||||
|
||||
## Fail2ban-vriendelijke logging
|
||||
|
||||
Een API die op internet staat trekt vroeg of laat ongenode gasten aan. Om hierop voorbereid te zijn logt de API elke mislukte authenticatiepoging naar `/var/log/weegschaal-api/auth.log` in een formaat dat fail2ban direct kan verwerken:
|
||||
|
||||
```
|
||||
[2026-07-20 18:06:07] 401 94.157.121.179 GET /api/v1/measurements - invalid Bearer token
|
||||
[2026-07-20 18:06:07] 404 94.157.121.179 GET /.env - unknown route
|
||||
```
|
||||
|
||||
De fail2ban jail grijpt in bij 5 foutieve pogingen binnen 5 minuten en bant het IP een uur lang. Ook 404's op niet-bestaande routes worden gelogd — een klassiek signaal van fuzzing-tools.
|
||||
|
||||
De logrotering (12 weken, wekelijks) en SELinux-context (`httpd_log_t`) zijn uiteraard geregeld.
|
||||
|
||||
## Resultaten delen
|
||||
|
||||
Een onverwacht leuke toevoeging: de share-knop op het dashboard. Rechtsboven in de kaart met je laatste en voorlaatste meting zit een deel-icoon. Tik erop, en de hele kaart wordt als PNG-afbeelding geëxporteerd. Je kunt hem direct delen via WhatsApp, Signal, e-mail — wat je maar wilt. De techniek erachter: `react-native-view-shot` vangt de view, `expo-sharing` opent het native deelmenu.
|
||||
|
||||
## Wat nog meer is veranderd
|
||||
|
||||
- **App-icoon**: vernieuwd met een eigen afbeelding
|
||||
- **Dashboard**: toont naast de laatste meting nu ook de voorlaatste en het gewichtsverschil
|
||||
- **Laad-indicator**: geen verwarrende "Nog geen metingen" meer tijdens het laden — het dashboard toont nu "Laden..."
|
||||
- **Database-migratie**: een `ALTER TABLE` zorgt dat bestaande SQLite-databases de nieuwe `deletedAt`-kolom krijgen zonder dataverlies
|
||||
|
||||
## Conclusie
|
||||
|
||||
Wat begon als een simpel sync-scriptje via WebDAV is uitgegroeid tot een volwaardige REST API met productie-grade logging, fail2ban-integratie, en een UX die het delen van meetresultaten net zo makkelijk maakt als een screenshot. De app doet nu wat hij moet doen: metingen opslaan, synchroniseren, en — sinds kort — met één tik delen met wie je maar wilt.
|
||||
106
docs/build-toolchain.md
Normal file
106
docs/build-toolchain.md
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
# Lokale APK build toolchain
|
||||
|
||||
## Overzicht
|
||||
|
||||
De Weegschaal app kan lokaal gebouwd worden tot een zelfstandige APK
|
||||
zonder EAS cloud. Dit is handig voor snelle iteraties of als je geen
|
||||
internetverbinding hebt voor de cloud build.
|
||||
|
||||
## Benodigdheden
|
||||
|
||||
| Component | Versie | Installatie |
|
||||
|---|---|---|
|
||||
| Java | OpenJDK 21+ | `brew install --cask temurin` |
|
||||
| Android Command Line Tools | laatste | `brew install --cask android-commandlinetools` |
|
||||
| Android Platform Tools | laatste | `brew install --cask android-platform-tools` |
|
||||
| Node.js | 22+ | `brew install node` |
|
||||
| EAS CLI | laatste | via npm (`npx eas`) |
|
||||
|
||||
## Android SDK instellen
|
||||
|
||||
`ANDROID_HOME` moet gezet zijn in je shell config (`~/.zshrc`):
|
||||
|
||||
```bash
|
||||
export ANDROID_HOME="/home/linuxbrew/.linuxbrew/share/android-commandlinetools"
|
||||
export PATH="$ANDROID_HOME/cmdline-tools/latest/bin:$ANDROID_HOME/platform-tools:$PATH"
|
||||
```
|
||||
|
||||
Licenties accepteren:
|
||||
|
||||
```bash
|
||||
yes | $ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager --sdk_root=$ANDROID_HOME --licenses
|
||||
```
|
||||
|
||||
Best practices:
|
||||
- Gebruik `--sdk_root=$ANDROID_HOME` bij sdkmanager commando's, anders
|
||||
installeert hij naar de verkeerde plek
|
||||
- De eerste build downloadt automatisch de juiste platform-, build-tools-,
|
||||
NDK- en CMake-versies. Je hoeft ze niet handmatig voor te installeren.
|
||||
- Wil je ze toch vastzetten: `sdkmanager "platforms;android-36" "build-tools;36.0.0"`
|
||||
|
||||
## APK bouwen
|
||||
|
||||
```bash
|
||||
cd /home/eddy/bin/weegschaal-app
|
||||
npx eas build --local --profile apk --platform android
|
||||
```
|
||||
|
||||
Het build profiel staat in `eas.json`:
|
||||
|
||||
```json
|
||||
"apk": {
|
||||
"android": {
|
||||
"buildType": "apk"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
De build:
|
||||
1. Draait `npx expo prebuild` (maakt `android/` directory aan)
|
||||
2. Bundelt de JS bundle via Metro
|
||||
3. Voert `./gradlew :app:assembleRelease` uit
|
||||
4. Produceert `android/app/build/outputs/apk/release/app-release.apk`
|
||||
5. Kopieert het resultaat naar `build-<timestamp>.apk` in de projectroot
|
||||
|
||||
## Duur
|
||||
|
||||
- **Eerste build**: ~13 minuten (Gradle download, NDK/CMake compilatie)
|
||||
- **Latere builds**: ~2-3 minuten (Gradle daemon werkt door)
|
||||
|
||||
## Veelvoorkomende problemen
|
||||
|
||||
**`ANDROID_HOME` niet gezet**
|
||||
```
|
||||
Error: ANDROID_HOME is not set
|
||||
```
|
||||
→ Controleer of de export in `~/.zshrc` staat en herlaad met `source ~/.zshrc`
|
||||
|
||||
**Gradle fault / OutOfMemoryError**
|
||||
```
|
||||
The daemon will expire after running out of JVM Metaspace
|
||||
```
|
||||
→ Zet in `android/gradle.properties`:
|
||||
```
|
||||
org.gradle.jvmargs=-Xmx4g -XX:MaxMetaspaceSize=1g
|
||||
```
|
||||
|
||||
**Licenties niet geaccepteerd**
|
||||
→ Voer `yes | sdkmanager --licenses` uit
|
||||
|
||||
## Testen
|
||||
|
||||
Voor het bouwen kun je de tests draaien:
|
||||
|
||||
```bash
|
||||
cd /home/eddy/bin/weegschaal-app
|
||||
npm test
|
||||
```
|
||||
|
||||
## Installatie op Android
|
||||
|
||||
```bash
|
||||
# Via ADB (als je toestel aan de USB hangt met debugging aan)
|
||||
adb install -r weegschaal.apk
|
||||
|
||||
# Of handmatig: kopieer APK naar toestel, open bestand, kies 'installeren'
|
||||
```
|
||||
1285
docs/superpowers/plans/2026-07-20-rest-api-sync-plan.md
Normal file
1285
docs/superpowers/plans/2026-07-20-rest-api-sync-plan.md
Normal file
File diff suppressed because it is too large
Load diff
139
docs/superpowers/plans/2026-07-25-sync-refactor-plan.md
Normal file
139
docs/superpowers/plans/2026-07-25-sync-refactor-plan.md
Normal file
|
|
@ -0,0 +1,139 @@
|
|||
# Sync refactor — Implementation Plan ✅ (completed 2026-07-25)
|
||||
|
||||
> Fix 6 sync bugs + herstel architectuur naar best-practice bidirectional timestamp sync.
|
||||
>
|
||||
> **Status:** Alle 6 bugs gefixt. 15/15 tests passen. APK build + geinstalleerd.
|
||||
|
||||
## Bug Overzicht
|
||||
|
||||
| # | Bug | Oorzaak | Fix |
|
||||
|---|-----|---------|-----|
|
||||
| 1 | Server `deleted:true` wordt genegeerd | `mergeMeasurements` mapt `deleted` niet naar `deletedAt` | Detecteer `(r as any).deleted` in merge, zet `deletedAt = r.updatedAt` |
|
||||
| 2 | `setDeletedAt` update `updatedAt` niet | Alleen `deletedAt` wordt gezet, `updatedAt` blijft oud | Voeg `updatedAt = ?` toe aan de UPDATE |
|
||||
| 3 | Pull-then-push double-push | Na upsert van remote records hebben die `updatedAt > lastSyncAt` → worden teruggestuurd | Skip in push-fase records die uit remote pull komen en waar remote.updatedAt ≥ local.updatedAt |
|
||||
| 4 | Hard delete ondanks falende API | `removeMeasurement()` wordt ALTIJD aangeroepen, ook na catch | Alleen hard delete bij API-succes |
|
||||
| 5 | `syncStatus` dead column | In schema, nooit gebruikt | Laat staan, geen impact |
|
||||
| 6 | `getAllMeasurements` filtert `deletedAt` niet | UI-schermen moeten zelf filteren (`.filter(m => !m.deletedAt)`) | Rename naar `getAllMeasurementsForSync` (ongefilterd), vervang UI-calls door `getAllMeasurements` met `WHERE deletedAt IS NULL` |
|
||||
|
||||
## Sync Protocol (vastgelegd)
|
||||
|
||||
```
|
||||
lastSyncAt = SecureStore.getItem(LAST_SYNC_KEY)
|
||||
|
||||
── PULL ──
|
||||
remote[] = GET /api/v1/measurements?since=lastSyncAt
|
||||
local[] = getAllMeasurementsForSync() // ALLE records (incl. deleted)
|
||||
remoteUpdatedMap: Map<id, updatedAt> // track welke IDs + timestamps we binnenhaalden
|
||||
|
||||
for each r in remote:
|
||||
remoteUpdatedMap.set(r.id, r.updatedAt)
|
||||
if (r as any).deleted:
|
||||
r.deletedAt = r.updatedAt // Bug 1 fix
|
||||
|
||||
── MERGE ──
|
||||
remoteDeletedIds: Set<id> // track welke remote records 'deleted' zijn
|
||||
|
||||
for each r in remote:
|
||||
if (r as any).deleted:
|
||||
remoteDeletedIds.add(r.id)
|
||||
|
||||
merged = mergeMeasurements(local, remote)
|
||||
|
||||
for each m in merged:
|
||||
remoteUA = remoteUpdatedMap.get(m.id)
|
||||
if remoteUA && new Date(remoteUA) >= new Date(m.updatedAt):
|
||||
// remote won de merge
|
||||
if remoteDeletedIds.has(m.id):
|
||||
removeMeasurement(m.id) // hard-delete lokaal (server heeft 'm al)
|
||||
else:
|
||||
upsertMeasurement(m) // remote gewijzigd, niet verwijderd
|
||||
else:
|
||||
upsertMeasurement(m) // local won of nieuw → gewoon bewaren
|
||||
|
||||
── PUSH ──
|
||||
cutoff = lastSyncAt ?? '1970-01-01T00:00:00.000Z'
|
||||
localChanges[] = getMeasurementsUpdatedSince(cutoff)
|
||||
|
||||
for each m in localChanges:
|
||||
remoteUA = remoteUpdatedMap.get(m.id)
|
||||
if remoteUA && new Date(remoteUA) >= new Date(m.updatedAt):
|
||||
continue // remote won merge → niet terugpushen (Bug 3 fix)
|
||||
|
||||
if m.deletedAt:
|
||||
DELETE /api/v1/measurements/:id
|
||||
if success:
|
||||
removeMeasurement(m.id) // Bug 4 fix: alleen hard-delete bij API-succes
|
||||
else:
|
||||
POST /api/v1/measurements
|
||||
|
||||
SecureStore.setItem(LAST_SYNC_KEY, now())
|
||||
```
|
||||
|
||||
## Delete Protocol (vastgelegd)
|
||||
|
||||
```
|
||||
Lokaal: setDeletedAt(id) + updatedAt = now() (Bug 2 fix)
|
||||
Cache: removeFromCache(id)
|
||||
API: DELETE /api/v1/measurements/:id
|
||||
if success:
|
||||
Lokaal: removeMeasurement(id) (Bug 4 fix: alleen bij succes)
|
||||
// bij falen: blijft met deletedAt in DB → syncAll pakt op
|
||||
```
|
||||
|
||||
## Files om te wijzigen
|
||||
|
||||
### 1. `src/sync/syncService.ts` — REWRITE syncAll + mergeMeasurements
|
||||
|
||||
**Wijzigingen:**
|
||||
- `mergeMeasurements`: detecteer `(r as any).deleted` → zet `deletedAt = r.updatedAt`
|
||||
- `syncAll`:
|
||||
- Track `remoteUpdatedMap` in pull-fase
|
||||
- Merge via bestaande `mergeMeasurements`
|
||||
- Push: skip records waar remote.updatedAt ≥ local.updatedAt (remote won)
|
||||
- Alleen `removeMeasurement` na succesvolle API DELETE
|
||||
- Exporteer `cleanupDeletedRecord(id)` voor gebruik door screens (DRY)
|
||||
|
||||
### 2. `src/database/db.ts` — setDeletedAt fix + query filters
|
||||
|
||||
**Wijzigingen:**
|
||||
- `setDeletedAt(id)`: voeg `updatedAt = ?` toe aan SQL
|
||||
- `getAllMeasurements()`: voeg `WHERE deletedAt IS NULL` toe
|
||||
- `getLatestMeasurement()`: voeg `WHERE deletedAt IS NULL` toe
|
||||
- Nieuw: `getAllMeasurementsForSync()` — ongefilterd (voor syncService)
|
||||
- Verwijder: `deleteMeasurement()` (dode code, duplicate van `removeMeasurement`)
|
||||
|
||||
### 3. `src/screens/HistoryScreen.tsx` — delete fix
|
||||
|
||||
**Wijzigingen:**
|
||||
- `confirmDelete`: alleen `removeMeasurement()` na API-succes
|
||||
- `load`: gebruik `getMeasurementsSince('1970-01-01')` i.p.v. `getAllMeasurements()` (of de nieuwe `getAllMeasurements` die filtert)
|
||||
|
||||
### 4. `src/screens/MeasurementDetailScreen.tsx` — delete fix
|
||||
|
||||
**Wijzigingen:**
|
||||
- `handleDelete`: alleen `removeMeasurement()` na API-succes
|
||||
|
||||
### 5. `src/store/measurementStore.ts` — geen wijzigingen nodig
|
||||
|
||||
De store gebruikt `getLatestMeasurements(count)` en `getMeasurementsSince(date)` die al filteren. `/loadFromDb` gebruikt `getAllMeasurements()` → die gaat nu ook filteren. ✓
|
||||
|
||||
### 6. `tests/syncService.test.ts` — nieuwe test cases
|
||||
|
||||
**Wijzigingen:**
|
||||
- Test: `deleted` van server wordt correct naar `deletedAt` gemapt
|
||||
- Test: syncAll push skips remote-won records
|
||||
- Test: syncAll push stuurt local-won records wel
|
||||
|
||||
### 7. `tests/database.test.ts` — update voor nieuwe functies
|
||||
|
||||
**Wijzigingen:**
|
||||
- Test `setDeletedAt` update ook `updatedAt`
|
||||
- Test `getAllMeasurementsForSync` returned deleted records
|
||||
|
||||
## Uitvoeringsvolgorde
|
||||
|
||||
1. `db.ts` — setDeletedAt fix + query filters
|
||||
2. `syncService.ts` — merge fix + syncAll refactor
|
||||
3. `HistoryScreen.tsx` + `MeasurementDetailScreen.tsx` — delete fix
|
||||
4. Tests updaten + TypeScript check + `npm test`
|
||||
5. Build APK, testen
|
||||
266
docs/superpowers/specs/2026-07-20-rest-api-sync-design.md
Normal file
266
docs/superpowers/specs/2026-07-20-rest-api-sync-design.md
Normal file
|
|
@ -0,0 +1,266 @@
|
|||
# REST API Sync — Design Doc
|
||||
|
||||
Vervang WebDAV-sync door een eigen REST API op `weegschaal.eddydevink.nl`,
|
||||
met MariaDB-backend en Bearer token-authenticatie. Incrementele sync i.p.v. volledig bestand.
|
||||
|
||||
## Fase 1 — Server-side: PHP API
|
||||
|
||||
### Infrastructuur
|
||||
|
||||
- **DNS**: `weegschaal.eddydevink.nl` → zelfde server als `git.eddydevink.nl` (nextcloud)
|
||||
- **nginx**: server block in `/etc/nginx/conf.d/weegschaal-api.conf`, proxy naar PHP-FPM socket
|
||||
- **SSL**: `certbot --nginx -d weegschaal.eddydevink.nl`
|
||||
- **Document root**: `/usr/share/nginx/html/weegschaal-api/`
|
||||
|
||||
### MariaDB
|
||||
|
||||
Database `weegschaal` met gebruiker `weegschaal@localhost` (random wachtwoord).
|
||||
|
||||
```sql
|
||||
CREATE TABLE users (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE auth_tokens (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
user_id INT NOT NULL REFERENCES users(id),
|
||||
token VARCHAR(64) NOT NULL UNIQUE,
|
||||
description VARCHAR(255),
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE measurements (
|
||||
id VARCHAR(36) NOT NULL,
|
||||
user_id INT NOT NULL,
|
||||
weight DECIMAL(5,2) NOT NULL,
|
||||
bmi DECIMAL(4,1) NOT NULL,
|
||||
body_fat_percentage DECIMAL(4,1) NOT NULL,
|
||||
fat_mass DECIMAL(5,2) NOT NULL,
|
||||
muscle_percentage DECIMAL(4,1) NOT NULL,
|
||||
muscle_mass DECIMAL(5,2) NOT NULL,
|
||||
visceral_fat TINYINT NOT NULL,
|
||||
bmr DECIMAL(6,1) NOT NULL,
|
||||
measured_at DATE NOT NULL,
|
||||
created_at DATETIME NOT NULL,
|
||||
updated_at DATETIME NOT NULL,
|
||||
deleted TINYINT NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (user_id, id),
|
||||
INDEX idx_updated_at (user_id, updated_at)
|
||||
);
|
||||
```
|
||||
|
||||
`deleted` flag voor soft deletes op server. De client stuurt harde DELETEs;
|
||||
de server zet `deleted=1` en update `updated_at` zodat de volgende `?since=`
|
||||
query deze doorgeeft zodat andere clients de delete kunnen verwerken.
|
||||
|
||||
### Endpoints (Slim Framework)
|
||||
|
||||
| Methode | Pad | Beschrijving |
|
||||
|---------|-----|-------------|
|
||||
| `GET` | `/api/v1/measurements` | Alle metingen (`?since=ISO` voor delta) |
|
||||
| `POST` | `/api/v1/measurements` | Nieuwe meting of upsert op ID |
|
||||
| `DELETE` | `/api/v1/measurements/:id` | Meting verwijderen (soft delete) |
|
||||
|
||||
**Geen** auth endpoints — eerste token wordt handmatig via SQL INSERT aangemaakt.
|
||||
|
||||
### Auth middleware
|
||||
|
||||
- `Authorization: Bearer <token>` → valideert tegen `auth_tokens` tabel
|
||||
- Zet `user_id` in request context
|
||||
- 401 bij ongeldige/missing token
|
||||
|
||||
### Bestandsstructuur
|
||||
|
||||
```
|
||||
/usr/share/nginx/html/weegschaal-api/
|
||||
├── composer.json
|
||||
├── public/
|
||||
│ └── index.php ← Entry point, routing
|
||||
├── src/
|
||||
│ ├── Database.php ← PDO singleton
|
||||
│ ├── Auth.php ← Token validation middleware
|
||||
│ ├── Measurements.php ← CRUD + sync queries
|
||||
│ └── helpers.php
|
||||
└── config.php ← DB credentials
|
||||
```
|
||||
|
||||
### Sync-logica server
|
||||
|
||||
- `GET ?since=ISO` → `WHERE user_id = ? AND updated_at > ? ORDER BY updated_at ASC`
|
||||
- `POST` → `INSERT ... ON DUPLICATE KEY UPDATE` — upsert op client-ID
|
||||
- `DELETE` → set `deleted=1`, update `updated_at=NOW()` (soft delete, zodat delta-sync werkt voor andere clients)
|
||||
- Geretourneerde records bevatten `deleted: true` veld zodat de client weet dat dit een soft-deleted record is
|
||||
|
||||
---
|
||||
|
||||
## Fase 2 — Client-side: App aanpassen
|
||||
|
||||
### Types (`src/types.ts`)
|
||||
|
||||
`WebDAVConfig` en `SyncStatus` vervallen. Toegevoegd: `ApiConfig`, `deletedAt`:
|
||||
|
||||
```typescript
|
||||
export type Measurement = {
|
||||
id: string
|
||||
weight: number
|
||||
bmi: number
|
||||
bodyFatPercentage: number
|
||||
fatMass: number
|
||||
musclePercentage: number
|
||||
muscleMass: number
|
||||
visceralFat: number
|
||||
bmr: number
|
||||
measuredAt: string
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
deletedAt?: string
|
||||
}
|
||||
|
||||
export type ApiConfig = {
|
||||
url: string
|
||||
token: string
|
||||
}
|
||||
```
|
||||
|
||||
### SettingsScreen (`src/screens/SettingsScreen.tsx`)
|
||||
|
||||
- Vervang URL/username/password door API-URL + token (met oogje)
|
||||
- SecureStore key: `weegschaal_api_config`
|
||||
- `getApiConfig()` (exported) i.p.v. `getWebDAVConfig()`
|
||||
- Sync knop blijft
|
||||
|
||||
### Nieuwe module `src/sync/api.ts`
|
||||
|
||||
```typescript
|
||||
const createApi = (config: ApiConfig) => ({
|
||||
getMeasurements: (since?: string) => GET /api/v1/measurements?since=...
|
||||
createMeasurement: (m: Measurement) => POST /api/v1/measurements
|
||||
deleteMeasurement: (id: string) => DELETE /api/v1/measurements/:id
|
||||
})
|
||||
```
|
||||
|
||||
Alle calls met `Authorization: Bearer <token>` header.
|
||||
|
||||
### syncService.ts — volledig herschreven
|
||||
|
||||
#### `mergeMeasurements(local, remote)`
|
||||
|
||||
Simpele last-writer-wins op `updatedAt`:
|
||||
|
||||
1. Bouw map van alle IDs (lokaal + remote)
|
||||
2. Voor elke ID: als beide bestaan → behoud de met de hoogste `updatedAt`
|
||||
3. Remote metingen die winnen krijgen `deletedAt` overgenomen uit server indien de server record `deleted: true` had
|
||||
|
||||
#### `syncAll()`
|
||||
|
||||
1. Lees `lastSyncAt` uit SecureStore (`weegschaal_last_sync`)
|
||||
2. `GET /api/v1/measurements?since=lastSyncAt` → remote delta
|
||||
3. `getAllMeasurements()` → lokale metingen
|
||||
4. Merge remote delta in lokale DB (last-writer-wins)
|
||||
5. Verwijder lokale records waar remote `deleted: true` en remote `updatedAt > local updatedAt`
|
||||
6. Verzamel lokale metingen met `updatedAt > lastSyncAt` (alleen niet-verwijderde)
|
||||
7. Voor `deletedAt` metingen met `deletedAt > lastSyncAt` → DELETE naar API → bij succes lokaal verwijderen
|
||||
8. Voor overige metingen met `updatedAt > lastSyncAt` → POST naar API
|
||||
9. `lastSyncAt = now()`
|
||||
|
||||
#### `uploadMeasurement(m)`
|
||||
|
||||
Losstaande functie voor directe push na opslaan:
|
||||
|
||||
1. POST naar API
|
||||
2. Bij succes → return
|
||||
3. Bij netwerkfout → throw; meting blijft lokaal, `syncAll` pakt hem later op (want `updatedAt > lastSyncAt`)
|
||||
|
||||
### NewMeasurementScreen
|
||||
|
||||
1. Lokaal opslaan (insert of upsert, met `updatedAt = now()`)
|
||||
2. Direct `uploadMeasurement(m)` proberen
|
||||
3. Bij succes → navigeer terug met "Opgeslagen & gesynct"
|
||||
4. Bij falen → navigeer terug met "Opgeslagen (sync mislukt)"
|
||||
|
||||
### MeasurementDetailScreen
|
||||
|
||||
Bij delete:
|
||||
1. Zet `deletedAt = now()` lokaal
|
||||
2. Fire-and-forget: DELETE API call
|
||||
3. Bij succes → record lokaal verwijderen
|
||||
4. Bij falen → record blijft met `deletedAt`, syncAll pakt het op
|
||||
|
||||
### HistoryScreen
|
||||
|
||||
Bij delete (vanuit alert in HistoryScreen):
|
||||
1. Zet `deletedAt = now()` lokaal
|
||||
2. Fire-and-forget: DELETE API call
|
||||
3. Bij succes → record lokaal verwijderen
|
||||
4. Bij falen → record blijft met `deletedAt`
|
||||
|
||||
Filter bij laden: `!m.deletedAt`.
|
||||
|
||||
### DashboardScreen
|
||||
|
||||
Laatste meting en grafiek: alleen metingen zonder `deletedAt`.
|
||||
Laatste meting = meest recente `measuredAt` zonder `deletedAt`.
|
||||
|
||||
### Database (`db.ts`)
|
||||
|
||||
Nieuw kolom in schema: `deletedAt TEXT`.
|
||||
|
||||
Nieuwe functies:
|
||||
- `getMeasurementsUpdatedSince(since: string)` — metingen met `updatedAt > since`
|
||||
- `setDeletedAt(id: string)` — zet `deletedAt = now()`
|
||||
- `removeMeasurement(id: string)` — harde delete
|
||||
|
||||
Verwijderd/onbruikbaar:
|
||||
- `getUnsyncedMeasurements()` — niet meer nodig
|
||||
- `getPendingMeasurements()` — niet meer nodig
|
||||
- `markAsDeleted()` — vervangen door `setDeletedAt`
|
||||
- `syncStatus` kolom — blijft in DB maar wordt niet meer gebruikt in code
|
||||
|
||||
---
|
||||
|
||||
## Fase 3 — Migratie (eerste sync)
|
||||
|
||||
Eerste keer dat de app de nieuwe API gebruikt (`lastSyncAt` is `null`):
|
||||
- `GET /api/v1/measurements` (zonder `?since`) → alle remote metingen
|
||||
- Merge met lokale DB: last-writer-wins, remote deleted overschrijft lokaal
|
||||
- Alle lokale metingen (die niet deleted zijn) worden gepusht (`updatedAt > null` = alles)
|
||||
- `lastSyncAt = now()`
|
||||
- Vanaf dan: delta-sync
|
||||
|
||||
---
|
||||
|
||||
## Foutafhandeling
|
||||
|
||||
| Situatie | Gedrag |
|
||||
|----------|--------|
|
||||
| Geen netwerk bij opslaan | Lokaal opgeslagen, `updatedAt > lastSyncAt` dus next sync pakt op |
|
||||
| Geen netwerk bij sync | Throw error, scherm toont melding |
|
||||
| Geen API-config | Throw `'Geen API configuratie. Stel deze eerst in in Instellingen.'` |
|
||||
| HTTP 401 (ongeldige token) | Throw `'Authenticatie mislukt. Controleer je token in Instellingen.'` |
|
||||
| HTTP 5xx | Throw met statuscode, scherm toont melding |
|
||||
|
||||
---
|
||||
|
||||
## Testen
|
||||
|
||||
### Client-side (Jest)
|
||||
|
||||
- `mergeMeasurements()` unit tests: local wins, remote wins, deleted remote vs local
|
||||
- `api.ts` calls mocken met fetch-mock
|
||||
- `syncAll()` integratietest met gemockte API
|
||||
|
||||
### Server-side
|
||||
|
||||
- Handmatig testen met curl na deployment
|
||||
|
||||
---
|
||||
|
||||
## Implementatievolgorde
|
||||
|
||||
1. Server-side: nginx, MariaDB, PHP API → testen met curl
|
||||
2. Client-side: types, api.ts, db.ts, syncService.ts, SettingsScreen → testen
|
||||
3. Client-side: DashboardScreen, HistoryScreen, MeasurementDetailScreen, NewMeasurementScreen
|
||||
4. Client-side: unit tests
|
||||
5. Build APK, sideloaden, testen op telefoon
|
||||
3
eas.json
3
eas.json
|
|
@ -1,6 +1,7 @@
|
|||
{
|
||||
"cli": {
|
||||
"version": ">= 5.9.1"
|
||||
"version": ">= 5.9.1",
|
||||
"appVersionSource": "local"
|
||||
},
|
||||
"build": {
|
||||
"development": {
|
||||
|
|
|
|||
385
package-lock.json
generated
385
package-lock.json
generated
|
|
@ -13,14 +13,19 @@
|
|||
"@react-navigation/native-stack": "^7.17.6",
|
||||
"expo": "~54.0.0",
|
||||
"expo-secure-store": "~15.0.8",
|
||||
"expo-sharing": "~14.0.8",
|
||||
"expo-sqlite": "~16.0.10",
|
||||
"expo-status-bar": "~3.0.9",
|
||||
"expo-updates": "~29.0.18",
|
||||
"react": "19.1.0",
|
||||
"react-dom": "19.1.0",
|
||||
"react-native": "0.81.5",
|
||||
"react-native-chart-kit": "^7.0.1",
|
||||
"react-native-safe-area-context": "~5.6.0",
|
||||
"react-native-screens": "~4.16.0",
|
||||
"react-native-svg": "15.12.1"
|
||||
"react-native-svg": "15.12.1",
|
||||
"react-native-view-shot": "4.0.3",
|
||||
"react-native-web": "^0.21.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/jest": "29.5.14",
|
||||
|
|
@ -1734,25 +1739,6 @@
|
|||
"semver": "^7.6.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@expo/image-utils/node_modules/@expo/require-utils": {
|
||||
"version": "55.0.5",
|
||||
"resolved": "https://registry.npmjs.org/@expo/require-utils/-/require-utils-55.0.5.tgz",
|
||||
"integrity": "sha512-U4K/CQ2VpXuwfNGsN+daKmYOt15hCP8v/pXaYH6eut7kdYZo6SfJ1yr67BIcJ+1Gzzs+QzTxswAZChKpXmceyw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.20.0",
|
||||
"@babel/core": "^7.25.2",
|
||||
"@babel/plugin-transform-modules-commonjs": "^7.24.8"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": "^5.0.0 || ^5.0.0-0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"typescript": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@expo/json-file": {
|
||||
"version": "10.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@expo/json-file/-/json-file-10.2.0.tgz",
|
||||
|
|
@ -2161,6 +2147,25 @@
|
|||
"xmlbuilder": "^15.1.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@expo/require-utils": {
|
||||
"version": "55.0.5",
|
||||
"resolved": "https://registry.npmjs.org/@expo/require-utils/-/require-utils-55.0.5.tgz",
|
||||
"integrity": "sha512-U4K/CQ2VpXuwfNGsN+daKmYOt15hCP8v/pXaYH6eut7kdYZo6SfJ1yr67BIcJ+1Gzzs+QzTxswAZChKpXmceyw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.20.0",
|
||||
"@babel/core": "^7.25.2",
|
||||
"@babel/plugin-transform-modules-commonjs": "^7.24.8"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": "^5.0.0 || ^5.0.0-0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"typescript": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@expo/schema-utils": {
|
||||
"version": "0.1.8",
|
||||
"resolved": "https://registry.npmjs.org/@expo/schema-utils/-/schema-utils-0.1.8.tgz",
|
||||
|
|
@ -3725,6 +3730,15 @@
|
|||
"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": {
|
||||
"version": "1.5.1",
|
||||
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
|
||||
|
|
@ -4326,6 +4340,57 @@
|
|||
"node": "^14.15.0 || ^16.10.0 || >=18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/cross-fetch": {
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-3.2.0.tgz",
|
||||
"integrity": "sha512-Q+xVJLoGOeIMXZmbUK4HYk+69cQH6LudR0Vu/pRm2YlU/hDV9CiS0gKUMaWY5f2NeUH9C1nV3bsTlCo0FsTV1Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"node-fetch": "^2.7.0"
|
||||
}
|
||||
},
|
||||
"node_modules/cross-fetch/node_modules/node-fetch": {
|
||||
"version": "2.7.0",
|
||||
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz",
|
||||
"integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"whatwg-url": "^5.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "4.x || >=6.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"encoding": "^0.1.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"encoding": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/cross-fetch/node_modules/tr46": {
|
||||
"version": "0.0.3",
|
||||
"resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
|
||||
"integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/cross-fetch/node_modules/webidl-conversions": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
|
||||
"integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==",
|
||||
"license": "BSD-2-Clause"
|
||||
},
|
||||
"node_modules/cross-fetch/node_modules/whatwg-url": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz",
|
||||
"integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tr46": "~0.0.3",
|
||||
"webidl-conversions": "^3.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/cross-spawn": {
|
||||
"version": "7.0.6",
|
||||
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
||||
|
|
@ -4340,6 +4405,24 @@
|
|||
"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": {
|
||||
"version": "5.2.2",
|
||||
"resolved": "https://registry.npmjs.org/css-select/-/css-select-5.2.2.tgz",
|
||||
|
|
@ -5077,6 +5160,31 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"node_modules/expo-eas-client": {
|
||||
"version": "1.0.8",
|
||||
"resolved": "https://registry.npmjs.org/expo-eas-client/-/expo-eas-client-1.0.8.tgz",
|
||||
"integrity": "sha512-5or11NJhSeDoHHI6zyvQDW2cz/yFyE+1Cz8NTs5NK8JzC7J0JrkUgptWtxyfB6Xs/21YRNifd3qgbBN3hfKVgA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/expo-json-utils": {
|
||||
"version": "0.15.0",
|
||||
"resolved": "https://registry.npmjs.org/expo-json-utils/-/expo-json-utils-0.15.0.tgz",
|
||||
"integrity": "sha512-duRT6oGl80IDzH2LD2yEFWNwGIC2WkozsB6HF3cDYNoNNdUvFk6uN3YiwsTsqVM/D0z6LEAQ01/SlYvN+Fw0JQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/expo-manifests": {
|
||||
"version": "1.0.11",
|
||||
"resolved": "https://registry.npmjs.org/expo-manifests/-/expo-manifests-1.0.11.tgz",
|
||||
"integrity": "sha512-6zItytTewN37Cjhp3glUg0ozrgW2GwB8x9wtfzUNoJIMmxO38nnGdTLMaotYhRqdf5PP2Dzdmej1HDHXVNUpRw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@expo/config": "~12.0.13",
|
||||
"expo-json-utils": "~0.15.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"expo": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/expo-modules-autolinking": {
|
||||
"version": "3.0.26",
|
||||
"resolved": "https://registry.npmjs.org/expo-modules-autolinking/-/expo-modules-autolinking-3.0.26.tgz",
|
||||
|
|
@ -5124,6 +5232,15 @@
|
|||
"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": {
|
||||
"version": "16.0.10",
|
||||
"resolved": "https://registry.npmjs.org/expo-sqlite/-/expo-sqlite-16.0.10.tgz",
|
||||
|
|
@ -5151,6 +5268,57 @@
|
|||
"react-native": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/expo-structured-headers": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/expo-structured-headers/-/expo-structured-headers-5.0.0.tgz",
|
||||
"integrity": "sha512-RmrBtnSphk5REmZGV+lcdgdpxyzio5rJw8CXviHE6qH5pKQQ83fhMEcigvrkBdsn2Efw2EODp4Yxl1/fqMvOZw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/expo-updates": {
|
||||
"version": "29.0.18",
|
||||
"resolved": "https://registry.npmjs.org/expo-updates/-/expo-updates-29.0.18.tgz",
|
||||
"integrity": "sha512-qn0YDM7wVwghQAaxb9NYzzwrmj+v8Djz5H+Zft4/myG9SPg4ICAfDy52IVF0K+/GH/oNgsFfzmmLl6hIkDu42g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@expo/code-signing-certificates": "^0.0.6",
|
||||
"@expo/plist": "^0.4.9",
|
||||
"@expo/spawn-async": "^1.7.2",
|
||||
"arg": "4.1.0",
|
||||
"chalk": "^4.1.2",
|
||||
"debug": "^4.3.4",
|
||||
"expo-eas-client": "~1.0.8",
|
||||
"expo-manifests": "~1.0.11",
|
||||
"expo-structured-headers": "~5.0.0",
|
||||
"expo-updates-interface": "~2.0.0",
|
||||
"getenv": "^2.0.0",
|
||||
"glob": "^13.0.0",
|
||||
"ignore": "^5.3.1",
|
||||
"resolve-from": "^5.0.0"
|
||||
},
|
||||
"bin": {
|
||||
"expo-updates": "bin/cli.js"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"expo": "*",
|
||||
"react": "*",
|
||||
"react-native": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/expo-updates-interface": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/expo-updates-interface/-/expo-updates-interface-2.0.0.tgz",
|
||||
"integrity": "sha512-pTzAIufEZdVPKql6iMi5ylVSPqV1qbEopz9G6TSECQmnNde2nwq42PxdFBaUEd8IZJ/fdJLQnOT3m6+XJ5s7jg==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"expo": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/expo-updates/node_modules/arg": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/arg/-/arg-4.1.0.tgz",
|
||||
"integrity": "sha512-ZWc51jO3qegGkVh8Hwpv636EkbesNV5ZNQPCtRa+0qytRYPEs9IYT9qITY9buezqUH5uqyzlWLcufrzU2rffdg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/expo/node_modules/@expo/cli": {
|
||||
"version": "54.0.25",
|
||||
"resolved": "https://registry.npmjs.org/@expo/cli/-/cli-54.0.25.tgz",
|
||||
|
|
@ -5580,6 +5748,36 @@
|
|||
"bser": "2.1.1"
|
||||
}
|
||||
},
|
||||
"node_modules/fbjs": {
|
||||
"version": "3.0.5",
|
||||
"resolved": "https://registry.npmjs.org/fbjs/-/fbjs-3.0.5.tgz",
|
||||
"integrity": "sha512-ztsSx77JBtkuMrEypfhgc3cI0+0h+svqeie7xHbh1k/IKdcydnvadp/mUaGgjAOXQmQSxsqgaRhS3q9fy+1kxg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cross-fetch": "^3.1.5",
|
||||
"fbjs-css-vars": "^1.0.0",
|
||||
"loose-envify": "^1.0.0",
|
||||
"object-assign": "^4.1.0",
|
||||
"promise": "^7.1.1",
|
||||
"setimmediate": "^1.0.5",
|
||||
"ua-parser-js": "^1.0.35"
|
||||
}
|
||||
},
|
||||
"node_modules/fbjs-css-vars": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/fbjs-css-vars/-/fbjs-css-vars-1.0.2.tgz",
|
||||
"integrity": "sha512-b2XGFAFdWZWg0phtAWLHCk836A1Xann+I+Dgd3Gk64MHKZO44FfoD1KxyvbSh0qZsIoXQGGlVztIY+oitJPpRQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fbjs/node_modules/promise": {
|
||||
"version": "7.3.1",
|
||||
"resolved": "https://registry.npmjs.org/promise/-/promise-7.3.1.tgz",
|
||||
"integrity": "sha512-nolQXZ/4L+bP/UGlkfaIujX9BKxGwmQ9OT4mOt5yvy8iK1h3wqTEJCijzGANTCCl9nWjY41juyAn2K3Q1hLLTg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"asap": "~2.0.3"
|
||||
}
|
||||
},
|
||||
"node_modules/fill-range": {
|
||||
"version": "7.1.1",
|
||||
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
|
||||
|
|
@ -5975,6 +6173,19 @@
|
|||
"license": "MIT",
|
||||
"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": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
|
||||
|
|
@ -6056,6 +6267,12 @@
|
|||
"node": ">=10.17.0"
|
||||
}
|
||||
},
|
||||
"node_modules/hyphenate-style-name": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/hyphenate-style-name/-/hyphenate-style-name-1.1.0.tgz",
|
||||
"integrity": "sha512-WDC/ui2VVRrz3jOVi+XtjqkDjiVjTtFaAGiW37k6b+ohyQ5wYDOGkvCZa8+H0nx3gyvv0+BST9xuOgIyGQ00gw==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/iconv-lite": {
|
||||
"version": "0.6.3",
|
||||
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
|
||||
|
|
@ -6166,6 +6383,15 @@
|
|||
"integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/inline-style-prefixer": {
|
||||
"version": "7.0.1",
|
||||
"resolved": "https://registry.npmjs.org/inline-style-prefixer/-/inline-style-prefixer-7.0.1.tgz",
|
||||
"integrity": "sha512-lhYo5qNTQp3EvSSp3sRvXMbVQTLrvGV6DycRMJ5dm2BLMiJ30wpXKdDdgX+GmJZ5uQMucwRKHamXSst3Sj/Giw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"css-in-js-utils": "^3.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/invariant": {
|
||||
"version": "2.2.4",
|
||||
"resolved": "https://registry.npmjs.org/invariant/-/invariant-2.2.4.tgz",
|
||||
|
|
@ -9051,6 +9277,12 @@
|
|||
"node": "^10 || ^12 || >=14"
|
||||
}
|
||||
},
|
||||
"node_modules/postcss-value-parser": {
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz",
|
||||
"integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/pretty-bytes": {
|
||||
"version": "5.6.0",
|
||||
"resolved": "https://registry.npmjs.org/pretty-bytes/-/pretty-bytes-5.6.0.tgz",
|
||||
|
|
@ -9263,6 +9495,18 @@
|
|||
"ws": "^7"
|
||||
}
|
||||
},
|
||||
"node_modules/react-dom": {
|
||||
"version": "19.1.0",
|
||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.0.tgz",
|
||||
"integrity": "sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"scheduler": "^0.26.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^19.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-freeze": {
|
||||
"version": "1.0.4",
|
||||
"resolved": "https://registry.npmjs.org/react-freeze/-/react-freeze-1.0.4.tgz",
|
||||
|
|
@ -9409,6 +9653,51 @@
|
|||
"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": {
|
||||
"version": "0.81.5",
|
||||
"resolved": "https://registry.npmjs.org/@react-native/js-polyfills/-/js-polyfills-0.81.5.tgz",
|
||||
|
|
@ -9957,6 +10246,12 @@
|
|||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/setimmediate": {
|
||||
"version": "1.0.5",
|
||||
"resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz",
|
||||
"integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/setprototypeof": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
|
||||
|
|
@ -10304,6 +10599,12 @@
|
|||
"integrity": "sha512-0MP/Cxx5SzeeZ10p/bZI0S6MpgD+yxAhi1BOQ34jgnMXsCq3j1t6tQnZu+KdlL7dvJTLT3g9xN8tl10TqgFMcg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/styleq": {
|
||||
"version": "0.1.3",
|
||||
"resolved": "https://registry.npmjs.org/styleq/-/styleq-0.1.3.tgz",
|
||||
"integrity": "sha512-3ZUifmCDCQanjeej1f6kyl/BeP/Vae5EYkQ9iJfUm/QwZvlgnZzyflqAsAWYURdtea8Vkvswu2GrC57h3qffcA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/sucrase": {
|
||||
"version": "3.35.1",
|
||||
"resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz",
|
||||
|
|
@ -10507,6 +10808,15 @@
|
|||
"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": {
|
||||
"version": "3.3.1",
|
||||
"resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz",
|
||||
|
|
@ -10673,6 +10983,32 @@
|
|||
"node": ">=14.17"
|
||||
}
|
||||
},
|
||||
"node_modules/ua-parser-js": {
|
||||
"version": "1.0.41",
|
||||
"resolved": "https://registry.npmjs.org/ua-parser-js/-/ua-parser-js-1.0.41.tgz",
|
||||
"integrity": "sha512-LbBDqdIC5s8iROCUjMbW1f5dJQTEFB1+KO9ogbvlb3nm9n4YHa5p4KTvFPWvh2Hs8gZMBuiB1/8+pdfe/tDPug==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/ua-parser-js"
|
||||
},
|
||||
{
|
||||
"type": "paypal",
|
||||
"url": "https://paypal.me/faisalman"
|
||||
},
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/faisalman"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"ua-parser-js": "script/cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/undici": {
|
||||
"version": "6.27.0",
|
||||
"resolved": "https://registry.npmjs.org/undici/-/undici-6.27.0.tgz",
|
||||
|
|
@ -10815,6 +11151,15 @@
|
|||
"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": {
|
||||
"version": "7.0.3",
|
||||
"resolved": "https://registry.npmjs.org/uuid/-/uuid-7.0.3.tgz",
|
||||
|
|
|
|||
11
package.json
11
package.json
|
|
@ -8,14 +8,19 @@
|
|||
"@react-navigation/native-stack": "^7.17.6",
|
||||
"expo": "~54.0.0",
|
||||
"expo-secure-store": "~15.0.8",
|
||||
"expo-sharing": "~14.0.8",
|
||||
"expo-sqlite": "~16.0.10",
|
||||
"expo-status-bar": "~3.0.9",
|
||||
"expo-updates": "~29.0.18",
|
||||
"react": "19.1.0",
|
||||
"react-dom": "19.1.0",
|
||||
"react-native": "0.81.5",
|
||||
"react-native-chart-kit": "^7.0.1",
|
||||
"react-native-safe-area-context": "~5.6.0",
|
||||
"react-native-screens": "~4.16.0",
|
||||
"react-native-svg": "15.12.1"
|
||||
"react-native-svg": "15.12.1",
|
||||
"react-native-view-shot": "4.0.3",
|
||||
"react-native-web": "^0.21.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/jest": "29.5.14",
|
||||
|
|
@ -25,8 +30,8 @@
|
|||
},
|
||||
"scripts": {
|
||||
"start": "expo start",
|
||||
"android": "expo start --android",
|
||||
"ios": "expo start --ios",
|
||||
"android": "expo run:android",
|
||||
"ios": "expo run:ios",
|
||||
"web": "expo start --web",
|
||||
"test": "jest"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1,8 +1,11 @@
|
|||
import React from 'react'
|
||||
import { View, Text, StyleSheet } from 'react-native'
|
||||
import React, { useRef } from 'react'
|
||||
import { View, Text, StyleSheet, TouchableOpacity } from 'react-native'
|
||||
import ViewShot from 'react-native-view-shot'
|
||||
import * as Sharing from 'expo-sharing'
|
||||
import { MaterialIcons } from '@expo/vector-icons'
|
||||
import { Measurement } from '../types'
|
||||
|
||||
type Props = { measurement: Measurement | null }
|
||||
type Props = { measurements: Measurement[] }
|
||||
|
||||
const rows: { label: string; key: keyof Measurement; suffix: string }[] = [
|
||||
{ label: 'Gewicht', key: 'weight', suffix: 'kg' },
|
||||
|
|
@ -15,8 +18,10 @@ const rows: { label: string; key: keyof Measurement; suffix: string }[] = [
|
|||
{ label: 'Stofwisseling', key: 'bmr', suffix: 'kcal' },
|
||||
]
|
||||
|
||||
export default function LatestMeasurementCard({ measurement }: Props) {
|
||||
if (!measurement) {
|
||||
export default function LatestMeasurementCard({ measurements }: Props) {
|
||||
const viewShotRef = useRef<ViewShot>(null)
|
||||
|
||||
if (measurements.length === 0) {
|
||||
return (
|
||||
<View style={styles.card}>
|
||||
<Text style={styles.emptyText}>Nog geen metingen</Text>
|
||||
|
|
@ -24,31 +29,89 @@ export default function LatestMeasurementCard({ measurement }: Props) {
|
|||
)
|
||||
}
|
||||
|
||||
const latest = measurements[0]
|
||||
const previous = measurements[1]
|
||||
const diff = previous ? (latest.weight - previous.weight) : null
|
||||
const fatMassDiff = previous ? (latest.fatMass - previous.fatMass) : null
|
||||
const muscleMassDiff = previous ? (latest.muscleMass - previous.muscleMass) : null
|
||||
|
||||
let positiveComment: string | null = null
|
||||
if (previous && diff !== null && diff >= 0) {
|
||||
const parts: string[] = []
|
||||
if (fatMassDiff !== null && fatMassDiff < 0) parts.push('Vetje naar beneden')
|
||||
if (muscleMassDiff !== null && muscleMassDiff > 0) parts.push('💪')
|
||||
if (parts.length > 0) positiveComment = parts.join(' ')
|
||||
}
|
||||
|
||||
const handleShare = async () => {
|
||||
try {
|
||||
const uri = await viewShotRef.current?.capture?.()
|
||||
if (uri) {
|
||||
await Sharing.shareAsync(uri, { mimeType: 'image/png' })
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
return (
|
||||
<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.date}>{new Date(measurement.measuredAt).toLocaleDateString('nl-NL')}</Text>
|
||||
<Text style={styles.date}>{new Date(latest.measuredAt).toLocaleDateString('nl-NL')}</Text>
|
||||
{rows.map(r => (
|
||||
<View key={r.key} style={styles.row}>
|
||||
<Text style={styles.rowLabel}>{r.label}</Text>
|
||||
<Text style={styles.rowValue}>
|
||||
{typeof measurement[r.key] === 'number'
|
||||
? (measurement[r.key] as number).toFixed(1)
|
||||
: measurement[r.key]}{' '}
|
||||
{typeof latest[r.key] === 'number'
|
||||
? (latest[r.key] as number).toFixed(1)
|
||||
: latest[r.key]}{' '}
|
||||
{r.suffix}
|
||||
</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
</ViewShot>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
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 },
|
||||
date: { fontSize: 14, color: '#666', marginBottom: 12 },
|
||||
row: { flexDirection: 'row', justifyContent: 'space-between', paddingVertical: 4 },
|
||||
rowLabel: { fontSize: 14, color: '#555' },
|
||||
row: { flexDirection: 'row', paddingVertical: 4 },
|
||||
rowLabel: { fontSize: 14, color: '#555', flex: 1 },
|
||||
rowValue: { fontSize: 14, fontWeight: '600' },
|
||||
emptyText: { fontSize: 16, color: '#999', textAlign: 'center', padding: 20 },
|
||||
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,6 +8,7 @@ import { generateId } from '../utils/uuid'
|
|||
type Props = {
|
||||
onSubmit: (m: Measurement) => void
|
||||
initialValues?: Partial<Measurement>
|
||||
isEdit?: boolean
|
||||
}
|
||||
|
||||
type FieldDef = {
|
||||
|
|
@ -29,13 +30,10 @@ const fields: FieldDef[] = [
|
|||
{ key: 'bmr', label: 'Stofwisseling (kcal)', placeholder: '1650', keyboardType: 'number-pad' },
|
||||
]
|
||||
|
||||
export default function MeasurementForm({ onSubmit, initialValues }: Props) {
|
||||
export default function MeasurementForm({ onSubmit, initialValues, isEdit }: Props) {
|
||||
const insets = useSafeAreaInsets()
|
||||
const [pickerOpen, setPickerOpen] = useState(false)
|
||||
const [measurementDate, setMeasurementDate] = useState<Date>(() => {
|
||||
if (initialValues?.measuredAt) return new Date(initialValues.measuredAt)
|
||||
return new Date()
|
||||
})
|
||||
const [measurementDate, setMeasurementDate] = useState<Date>(new Date())
|
||||
const [values, setValues] = useState<Record<string, string>>(() => {
|
||||
const init: Record<string, string> = {}
|
||||
for (const f of fields) {
|
||||
|
|
@ -47,9 +45,18 @@ export default function MeasurementForm({ onSubmit, initialValues }: Props) {
|
|||
|
||||
|
||||
|
||||
const weight = parseFloat(values.weight) || 0
|
||||
const bodyFatPct = parseFloat(values.bodyFatPercentage) || 0
|
||||
const musclePct = parseFloat(values.musclePercentage) || 0
|
||||
function toFloat(s: string): number {
|
||||
return parseFloat(s.replace(',', '.')) || 0
|
||||
}
|
||||
|
||||
function toInt(s: string): number {
|
||||
return parseInt(s.replace(',', '.'), 10) || 0
|
||||
}
|
||||
|
||||
|
||||
const weight = toFloat(values.weight)
|
||||
const bodyFatPct = toFloat(values.bodyFatPercentage)
|
||||
const musclePct = toFloat(values.musclePercentage)
|
||||
|
||||
const computed = useMemo(() => ({
|
||||
fatMass: weight > 0 && bodyFatPct > 0 ? ((weight * bodyFatPct) / 100).toFixed(1) : '',
|
||||
|
|
@ -59,19 +66,18 @@ export default function MeasurementForm({ onSubmit, initialValues }: Props) {
|
|||
const handleSubmit = () => {
|
||||
const now = new Date().toISOString()
|
||||
const m: Measurement = {
|
||||
id: generateId(),
|
||||
id: isEdit && initialValues?.id ? initialValues.id : generateId(),
|
||||
weight,
|
||||
bmi: parseFloat(values.bmi) || 0,
|
||||
bmi: toFloat(values.bmi),
|
||||
bodyFatPercentage: bodyFatPct,
|
||||
fatMass: parseFloat(computed.fatMass) || 0,
|
||||
fatMass: toFloat(computed.fatMass),
|
||||
musclePercentage: musclePct,
|
||||
muscleMass: parseFloat(computed.muscleMass) || 0,
|
||||
visceralFat: parseInt(values.visceralFat, 10) || 0,
|
||||
bmr: parseFloat(values.bmr) || 0,
|
||||
muscleMass: toFloat(computed.muscleMass),
|
||||
visceralFat: toInt(values.visceralFat),
|
||||
bmr: toFloat(values.bmr),
|
||||
measuredAt: measurementDate.toISOString(),
|
||||
createdAt: now,
|
||||
createdAt: isEdit && initialValues?.createdAt ? initialValues.createdAt : now,
|
||||
updatedAt: now,
|
||||
syncStatus: 'local',
|
||||
}
|
||||
onSubmit(m)
|
||||
}
|
||||
|
|
@ -140,7 +146,7 @@ export default function MeasurementForm({ onSubmit, initialValues }: Props) {
|
|||
)
|
||||
})}
|
||||
</ScrollView>
|
||||
<TouchableOpacity style={[styles.button, { marginBottom: insets.bottom + 16 }]} onPress={handleSubmit}>
|
||||
<TouchableOpacity style={[styles.button, { marginBottom: insets.bottom + 48 }]} onPress={handleSubmit}>
|
||||
<Text style={styles.buttonText}>Opslaan</Text>
|
||||
</TouchableOpacity>
|
||||
</KeyboardAvoidingView>
|
||||
|
|
@ -149,7 +155,7 @@ export default function MeasurementForm({ onSubmit, initialValues }: Props) {
|
|||
|
||||
const styles = StyleSheet.create({
|
||||
container: { flex: 1, backgroundColor: '#f5f5f5' },
|
||||
scroll: { padding: 16, paddingBottom: 0 },
|
||||
scroll: { padding: 16 },
|
||||
field: { marginBottom: 12 },
|
||||
label: { fontSize: 14, fontWeight: '600', color: '#333', marginBottom: 4 },
|
||||
input: { backgroundColor: '#fff', borderRadius: 8, padding: 12, fontSize: 16, borderWidth: 1, borderColor: '#ddd' },
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import React from 'react'
|
||||
import { View, Text, StyleSheet, Dimensions } from 'react-native'
|
||||
import React, { useRef, useState } from 'react'
|
||||
import { View, Text, StyleSheet, Dimensions, ScrollView, NativeSyntheticEvent, NativeScrollEvent } from 'react-native'
|
||||
import { LineChart } from 'react-native-chart-kit'
|
||||
import { Measurement } from '../types'
|
||||
|
||||
|
|
@ -11,6 +11,9 @@ const COLORS = {
|
|||
muscleMass: '#2196F3',
|
||||
}
|
||||
|
||||
const screenWidth = Dimensions.get('window').width
|
||||
const chartWidth = screenWidth - 32
|
||||
|
||||
function SingleChart({
|
||||
title,
|
||||
data,
|
||||
|
|
@ -29,7 +32,7 @@ function SingleChart({
|
|||
<Text style={styles.chartTitle}>{title}</Text>
|
||||
<LineChart
|
||||
data={{ labels, datasets: [{ data, color: () => color, strokeWidth: 2 }] }}
|
||||
width={Dimensions.get('window').width - 32}
|
||||
width={chartWidth}
|
||||
height={180}
|
||||
yAxisSuffix={unit}
|
||||
chartConfig={{
|
||||
|
|
@ -57,7 +60,63 @@ function hexToRgb(hex: string): string {
|
|||
return `${r}, ${g}, ${b}`
|
||||
}
|
||||
|
||||
function filterData(sorted: Measurement[], days: number, lastDate: Date) {
|
||||
const cutoff = new Date(lastDate)
|
||||
cutoff.setDate(cutoff.getDate() - days)
|
||||
const filtered = sorted.filter(m => new Date(m.measuredAt) >= cutoff)
|
||||
return filtered.length >= 2 ? filtered : sorted
|
||||
}
|
||||
|
||||
function prepareLabels(dataPoints: Measurement[]) {
|
||||
const labels = dataPoints.map(m => {
|
||||
const d = new Date(m.measuredAt)
|
||||
return `${d.getDate()}/${d.getMonth() + 1}`
|
||||
})
|
||||
const labelStep = labels.length > 8 ? Math.ceil(labels.length / 8) : 1
|
||||
return labels.map((l, i) => (i % labelStep === 0 ? l : ''))
|
||||
}
|
||||
|
||||
function ChartSet({ dataPoints, title }: { dataPoints: Measurement[]; title: string }) {
|
||||
const labels = prepareLabels(dataPoints)
|
||||
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) {
|
||||
const scrollRef = useRef<ScrollView>(null)
|
||||
const [page, setPage] = useState(0)
|
||||
|
||||
if (measurements.length < 2) {
|
||||
return null
|
||||
}
|
||||
|
|
@ -66,52 +125,38 @@ export default function WeightChart({ measurements }: Props) {
|
|||
(a, b) => new Date(a.measuredAt).getTime() - new Date(b.measuredAt).getTime()
|
||||
)
|
||||
|
||||
const cutoff = new Date(sorted[sorted.length - 1].measuredAt)
|
||||
cutoff.setDate(cutoff.getDate() - 30)
|
||||
const filtered = sorted.filter(m => new Date(m.measuredAt) >= cutoff)
|
||||
const dataPoints = filtered.length >= 2 ? filtered : sorted
|
||||
const lastDate = new Date(sorted[sorted.length - 1].measuredAt)
|
||||
|
||||
const labels = dataPoints.map(m => {
|
||||
const d = new Date(m.measuredAt)
|
||||
return `${d.getDate()}/${d.getMonth() + 1}`
|
||||
})
|
||||
const data30 = filterData(sorted, 30, lastDate)
|
||||
const data365 = filterData(sorted, 365, lastDate)
|
||||
|
||||
const labelStep = labels.length > 8 ? Math.ceil(labels.length / 8) : 1
|
||||
const sparseLabels = labels.map((l, i) => (i % labelStep === 0 ? l : ''))
|
||||
const onScroll = (e: NativeSyntheticEvent<NativeScrollEvent>) => {
|
||||
const offset = e.nativeEvent.contentOffset.x
|
||||
const newPage = Math.round(offset / screenWidth)
|
||||
if (newPage !== page) {
|
||||
setPage(newPage)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<Text style={styles.title}>Voortgang (30 dagen)</Text>
|
||||
|
||||
<SingleChart
|
||||
title="Gewicht"
|
||||
data={dataPoints.map(m => m.weight)}
|
||||
labels={sparseLabels}
|
||||
color={COLORS.weight}
|
||||
unit=" kg"
|
||||
/>
|
||||
|
||||
<SingleChart
|
||||
title="Vetmassa"
|
||||
data={dataPoints.map(m => m.fatMass)}
|
||||
labels={sparseLabels}
|
||||
color={COLORS.fatMass}
|
||||
unit=" kg"
|
||||
/>
|
||||
|
||||
<SingleChart
|
||||
title="Spiermassa"
|
||||
data={dataPoints.map(m => m.muscleMass)}
|
||||
labels={sparseLabels}
|
||||
color={COLORS.muscleMass}
|
||||
unit=" kg"
|
||||
/>
|
||||
<ScrollView
|
||||
ref={scrollRef}
|
||||
horizontal
|
||||
pagingEnabled
|
||||
showsHorizontalScrollIndicator={false}
|
||||
onMomentumScrollEnd={onScroll}
|
||||
>
|
||||
<ChartSet dataPoints={data30} title="Voortgang (30 dagen)" />
|
||||
<ChartSet dataPoints={data365} title="Voortgang (365 dagen)" />
|
||||
</ScrollView>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: { marginHorizontal: 16, marginBottom: 16 },
|
||||
container: { marginBottom: 16 },
|
||||
page: { width: screenWidth, paddingHorizontal: 16 },
|
||||
title: { fontSize: 18, fontWeight: '700', marginBottom: 8 },
|
||||
chartBlock: { marginBottom: 12 },
|
||||
chartTitle: { fontSize: 13, fontWeight: '600', color: '#555', marginBottom: 4, marginLeft: 4 },
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
import * as SQLite from 'expo-sqlite'
|
||||
import { Measurement, SyncStatus } from '../types'
|
||||
import { Measurement } from '../types'
|
||||
|
||||
let db: SQLite.SQLiteDatabase | null = null
|
||||
let dbReady = false
|
||||
|
||||
export async function initDatabase(): Promise<void> {
|
||||
if (db) return
|
||||
|
|
@ -20,20 +21,39 @@ export async function initDatabase(): Promise<void> {
|
|||
measuredAt TEXT NOT NULL,
|
||||
createdAt TEXT NOT NULL,
|
||||
updatedAt TEXT NOT NULL,
|
||||
syncStatus TEXT NOT NULL DEFAULT 'local'
|
||||
syncStatus TEXT NOT NULL DEFAULT 'pending',
|
||||
deletedAt TEXT
|
||||
);
|
||||
`)
|
||||
try {
|
||||
await db.execAsync('ALTER TABLE measurements ADD COLUMN deletedAt TEXT')
|
||||
} catch {}
|
||||
|
||||
// Migrate existing records to crossbreed syncStatus model
|
||||
if (!dbReady) {
|
||||
try {
|
||||
await db.execAsync("UPDATE measurements SET syncStatus = 'synced' WHERE syncStatus = 'local'")
|
||||
} catch {}
|
||||
dbReady = true
|
||||
}
|
||||
}
|
||||
|
||||
export async function insertMeasurement(m: Measurement): Promise<void> {
|
||||
await db!.runAsync(
|
||||
`INSERT INTO measurements (id, weight, bmi, bodyFatPercentage, fatMass, musclePercentage, muscleMass, visceralFat, bmr, measuredAt, createdAt, updatedAt, syncStatus)
|
||||
`INSERT INTO measurements (id, weight, bmi, bodyFatPercentage, fatMass, musclePercentage, muscleMass, visceralFat, bmr, measuredAt, createdAt, updatedAt, deletedAt)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
[m.id, m.weight, m.bmi, m.bodyFatPercentage, m.fatMass, m.musclePercentage, m.muscleMass, m.visceralFat, m.bmr, m.measuredAt, m.createdAt, m.updatedAt, m.syncStatus]
|
||||
[m.id, m.weight, m.bmi, m.bodyFatPercentage, m.fatMass, m.musclePercentage, m.muscleMass, m.visceralFat, m.bmr, m.measuredAt, m.createdAt, m.updatedAt, m.deletedAt ?? null]
|
||||
)
|
||||
}
|
||||
|
||||
export async function getAllMeasurements(): Promise<Measurement[]> {
|
||||
const rows = await db!.getAllAsync<any>(
|
||||
'SELECT * FROM measurements WHERE deletedAt IS NULL ORDER BY measuredAt DESC'
|
||||
)
|
||||
return rows as Measurement[]
|
||||
}
|
||||
|
||||
export async function getAllMeasurementsForSync(): Promise<Measurement[]> {
|
||||
const rows = await db!.getAllAsync<any>(
|
||||
'SELECT * FROM measurements ORDER BY measuredAt DESC'
|
||||
)
|
||||
|
|
@ -42,42 +62,71 @@ export async function getAllMeasurements(): Promise<Measurement[]> {
|
|||
|
||||
export async function getLatestMeasurement(): Promise<Measurement | null> {
|
||||
const rows = await db!.getAllAsync<any>(
|
||||
'SELECT * FROM measurements ORDER BY measuredAt DESC LIMIT 1'
|
||||
'SELECT * FROM measurements WHERE deletedAt IS NULL ORDER BY measuredAt DESC LIMIT 1'
|
||||
)
|
||||
return rows.length > 0 ? (rows[0] as Measurement) : null
|
||||
}
|
||||
|
||||
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> {
|
||||
const day = date.slice(0, 10)
|
||||
const rows = await db!.getAllAsync<any>(
|
||||
"SELECT * FROM measurements WHERE measuredAt LIKE ? LIMIT 1",
|
||||
"SELECT * FROM measurements WHERE measuredAt LIKE ? AND deletedAt IS NULL LIMIT 1",
|
||||
[`${day}%`]
|
||||
)
|
||||
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> {
|
||||
await db!.runAsync(
|
||||
`INSERT OR REPLACE INTO measurements (id, weight, bmi, bodyFatPercentage, fatMass, musclePercentage, muscleMass, visceralFat, bmr, measuredAt, createdAt, updatedAt, syncStatus)
|
||||
`INSERT OR REPLACE INTO measurements (id, weight, bmi, bodyFatPercentage, fatMass, musclePercentage, muscleMass, visceralFat, bmr, measuredAt, createdAt, updatedAt, deletedAt)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
[m.id, m.weight, m.bmi, m.bodyFatPercentage, m.fatMass, m.musclePercentage, m.muscleMass, m.visceralFat, m.bmr, m.measuredAt, m.createdAt, m.updatedAt, m.syncStatus]
|
||||
[m.id, m.weight, m.bmi, m.bodyFatPercentage, m.fatMass, m.musclePercentage, m.muscleMass, m.visceralFat, m.bmr, m.measuredAt, m.createdAt, m.updatedAt, m.deletedAt ?? null]
|
||||
)
|
||||
}
|
||||
|
||||
export async function getUnsyncedMeasurements(): Promise<Measurement[]> {
|
||||
export async function getMeasurementsUpdatedSince(since: string): Promise<Measurement[]> {
|
||||
const rows = await db!.getAllAsync<any>(
|
||||
"SELECT * FROM measurements WHERE syncStatus = 'local' ORDER BY updatedAt ASC"
|
||||
'SELECT * FROM measurements WHERE updatedAt > ? ORDER BY updatedAt ASC',
|
||||
[since]
|
||||
)
|
||||
return rows as Measurement[]
|
||||
}
|
||||
|
||||
export async function deleteMeasurement(id: string): Promise<void> {
|
||||
export async function setDeletedAt(id: string): Promise<void> {
|
||||
const now = new Date().toISOString()
|
||||
await db!.runAsync(
|
||||
'UPDATE measurements SET deletedAt = ?, updatedAt = ? WHERE id = ?',
|
||||
[now, now, id]
|
||||
)
|
||||
}
|
||||
|
||||
export async function removeMeasurement(id: string): Promise<void> {
|
||||
await db!.runAsync('DELETE FROM measurements WHERE id = ?', [id])
|
||||
}
|
||||
|
||||
export async function updateSyncStatus(id: string, status: string): Promise<void> {
|
||||
await db!.runAsync('UPDATE measurements SET syncStatus = ? WHERE id = ?', [status, id])
|
||||
}
|
||||
|
||||
export async function getPendingRecords(): Promise<Measurement[]> {
|
||||
const rows = await db!.getAllAsync<any>(
|
||||
"SELECT * FROM measurements WHERE syncStatus IN ('pending', 'pending-delete') ORDER BY updatedAt ASC"
|
||||
)
|
||||
return rows as Measurement[]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,9 @@
|
|||
import React, { useCallback, useState } from 'react'
|
||||
import React, { useCallback, useRef, useState } from 'react'
|
||||
import { View, ScrollView, TouchableOpacity, Text, StyleSheet } from 'react-native'
|
||||
import { useFocusEffect } from '@react-navigation/native'
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context'
|
||||
import { initDatabase, getAllMeasurements, getLatestMeasurement } from '../database/db'
|
||||
import { getCached, getVersion, refreshCache } from '../store/measurementStore'
|
||||
import { initDatabase, getLatestMeasurements } from '../database/db'
|
||||
import { Measurement } from '../types'
|
||||
import LatestMeasurementCard from '../components/LatestMeasurementCard'
|
||||
import WeightChart from '../components/WeightChart'
|
||||
|
|
@ -11,22 +12,76 @@ type Props = { navigation: any }
|
|||
|
||||
export default function DashboardScreen({ navigation }: Props) {
|
||||
const insets = useSafeAreaInsets()
|
||||
const [latest, setLatest] = useState<Measurement | null>(null)
|
||||
const [latestTwo, setLatestTwo] = useState<Measurement[]>([])
|
||||
const [all, setAll] = useState<Measurement[]>([])
|
||||
const [chartKey, setChartKey] = useState(0)
|
||||
const seenVersion = useRef(0)
|
||||
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
let active = true
|
||||
const load = async () => {
|
||||
await initDatabase()
|
||||
const allM = await getAllMeasurements()
|
||||
setAll(allM)
|
||||
const latestM = await getLatestMeasurement()
|
||||
setLatest(latestM)
|
||||
|
||||
const two = await getLatestMeasurements(2)
|
||||
if (!active) return
|
||||
setLatestTwo(two)
|
||||
|
||||
await refreshCache()
|
||||
if (!active) return
|
||||
const v = getVersion()
|
||||
if (v !== seenVersion.current) {
|
||||
setAll(getCached())
|
||||
setChartKey(v)
|
||||
seenVersion.current = v
|
||||
}
|
||||
|
||||
try {
|
||||
const { syncAll } = await import('../sync/syncService')
|
||||
const result = await syncAll()
|
||||
if (!active) return
|
||||
if (result.deleted > 0 || result.pulled > 0) {
|
||||
await refreshCache()
|
||||
const v2 = getVersion()
|
||||
setAll(getCached())
|
||||
setChartKey(v2)
|
||||
seenVersion.current = v2
|
||||
}
|
||||
} catch {
|
||||
console.warn('Sync failed')
|
||||
}
|
||||
}
|
||||
load()
|
||||
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 (
|
||||
<View style={styles.outer}>
|
||||
<View style={styles.navBar}>
|
||||
|
|
@ -38,8 +93,8 @@ export default function DashboardScreen({ navigation }: Props) {
|
|||
</TouchableOpacity>
|
||||
</View>
|
||||
<ScrollView style={styles.container}>
|
||||
<LatestMeasurementCard measurement={latest} />
|
||||
<WeightChart measurements={all} />
|
||||
<LatestMeasurementCard measurements={display} />
|
||||
{all.length >= 2 && <WeightChart key={chartKey} measurements={all} />}
|
||||
</ScrollView>
|
||||
|
||||
<TouchableOpacity
|
||||
|
|
@ -59,4 +114,6 @@ const styles = StyleSheet.create({
|
|||
navLink: { fontSize: 16, color: '#2196F3', fontWeight: '600' },
|
||||
fab: { backgroundColor: '#4CAF50', padding: 16, borderRadius: 8, margin: 16, alignItems: 'center' },
|
||||
fabText: { color: '#fff', fontSize: 16, fontWeight: '700' },
|
||||
loadingContainer: { flex: 1, justifyContent: 'center', alignItems: 'center' },
|
||||
loading: { fontSize: 16, color: '#999' },
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
import React, { useCallback, useState } from 'react'
|
||||
import { View, FlatList, StyleSheet, Text, RefreshControl, Alert } from 'react-native'
|
||||
import { View, FlatList, StyleSheet, Text, RefreshControl, TouchableOpacity, Alert } from 'react-native'
|
||||
import { useFocusEffect } from '@react-navigation/native'
|
||||
import { initDatabase, getAllMeasurements, deleteMeasurement } from '../database/db'
|
||||
import { initDatabase, getAllMeasurements, setDeletedAt, updateSyncStatus } from '../database/db'
|
||||
import { refreshCache } from '../store/measurementStore'
|
||||
import { Measurement } from '../types'
|
||||
import MeasurementRow from '../components/MeasurementRow'
|
||||
|
||||
|
|
@ -61,11 +62,9 @@ export default function HistoryScreen({ navigation }: Props) {
|
|||
style: 'destructive',
|
||||
onPress: async () => {
|
||||
await initDatabase()
|
||||
await deleteMeasurement(m.id)
|
||||
try {
|
||||
const { syncAll } = await import('../sync/syncService')
|
||||
await syncAll()
|
||||
} catch {}
|
||||
await setDeletedAt(m.id)
|
||||
await updateSyncStatus(m.id, 'pending-delete')
|
||||
await refreshCache()
|
||||
load()
|
||||
},
|
||||
},
|
||||
|
|
@ -80,7 +79,10 @@ export default function HistoryScreen({ navigation }: Props) {
|
|||
data={measurements}
|
||||
keyExtractor={item => item.id}
|
||||
renderItem={({ item }) => (
|
||||
<MeasurementRow measurement={item} onPress={() => handlePress(item)} />
|
||||
<MeasurementRow
|
||||
measurement={item}
|
||||
onPress={() => navigation.navigate('MeasurementDetail', { measurementId: item.id })}
|
||||
/>
|
||||
)}
|
||||
contentContainerStyle={measurements.length === 0 ? styles.empty : undefined}
|
||||
ListEmptyComponent={<Text style={styles.emptyText}>Nog geen metingen</Text>}
|
||||
|
|
|
|||
134
src/screens/MeasurementDetailScreen.tsx
Normal file
134
src/screens/MeasurementDetailScreen.tsx
Normal file
|
|
@ -0,0 +1,134 @@
|
|||
import React, { useCallback, useState } from 'react'
|
||||
import { View, Text, StyleSheet, TouchableOpacity, Alert, ScrollView } from 'react-native'
|
||||
import { useFocusEffect } from '@react-navigation/native'
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context'
|
||||
import { initDatabase, getAllMeasurements, setDeletedAt, updateSyncStatus } from '../database/db'
|
||||
import { refreshCache } from '../store/measurementStore'
|
||||
import { Measurement } from '../types'
|
||||
|
||||
type Props = {
|
||||
navigation: any
|
||||
route: {
|
||||
params: {
|
||||
measurementId: string
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const rows: { label: string; key: keyof Measurement; suffix: string }[] = [
|
||||
{ label: 'Gewicht', key: 'weight', suffix: 'kg' },
|
||||
{ label: 'BMI', key: 'bmi', suffix: '' },
|
||||
{ label: 'Vetpercentage', key: 'bodyFatPercentage', suffix: '%' },
|
||||
{ label: 'Vetmassa', key: 'fatMass', suffix: 'kg' },
|
||||
{ label: 'Spierpercentage', key: 'musclePercentage', suffix: '%' },
|
||||
{ label: 'Spiermassa', key: 'muscleMass', suffix: 'kg' },
|
||||
{ label: 'Buikvet', key: 'visceralFat', suffix: '' },
|
||||
{ label: 'Stofwisseling', key: 'bmr', suffix: 'kcal' },
|
||||
]
|
||||
|
||||
export default function MeasurementDetailScreen({ navigation, route }: Props) {
|
||||
const insets = useSafeAreaInsets()
|
||||
const { measurementId } = route.params
|
||||
const [measurement, setMeasurement] = useState<Measurement | null>(null)
|
||||
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
const load = async () => {
|
||||
await initDatabase()
|
||||
const all = await getAllMeasurements()
|
||||
const m = all.find(x => x.id === measurementId)
|
||||
setMeasurement(m ?? null)
|
||||
}
|
||||
load()
|
||||
}, [measurementId])
|
||||
)
|
||||
|
||||
const handleEdit = () => {
|
||||
navigation.navigate('NewMeasurement', { measurement })
|
||||
}
|
||||
|
||||
const handleDelete = () => {
|
||||
Alert.alert(
|
||||
'Meting verwijderen?',
|
||||
'Dit kan niet ongedaan worden gemaakt.',
|
||||
[
|
||||
{ text: 'Annuleren', style: 'cancel' },
|
||||
{
|
||||
text: 'Verwijderen',
|
||||
style: 'destructive',
|
||||
onPress: async () => {
|
||||
await initDatabase()
|
||||
await setDeletedAt(measurementId)
|
||||
await updateSyncStatus(measurementId, 'pending-delete')
|
||||
await refreshCache()
|
||||
navigation.goBack()
|
||||
},
|
||||
},
|
||||
]
|
||||
)
|
||||
}
|
||||
|
||||
if (!measurement) {
|
||||
return (
|
||||
<View style={styles.centered}>
|
||||
<Text style={{ color: '#999' }}>Laden...</Text>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<ScrollView contentContainerStyle={styles.content}>
|
||||
<Text style={styles.date}>
|
||||
{new Date(measurement.measuredAt).toLocaleDateString('nl-NL', {
|
||||
weekday: 'long',
|
||||
day: 'numeric',
|
||||
month: 'long',
|
||||
year: 'numeric',
|
||||
})}
|
||||
</Text>
|
||||
|
||||
{rows.map(r => (
|
||||
<View key={r.key} style={styles.row}>
|
||||
<Text style={styles.label}>{r.label}</Text>
|
||||
<Text style={styles.value}>
|
||||
{typeof measurement[r.key] === 'number'
|
||||
? (measurement[r.key] as number).toFixed(1)
|
||||
: measurement[r.key]}{' '}
|
||||
{r.suffix}
|
||||
</Text>
|
||||
</View>
|
||||
))}
|
||||
</ScrollView>
|
||||
|
||||
<View style={[styles.buttons, { paddingBottom: insets.bottom + 16 }]}>
|
||||
<TouchableOpacity style={styles.editButton} onPress={handleEdit}>
|
||||
<Text style={styles.buttonText}>Wijzigen</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity style={styles.deleteButton} onPress={handleDelete}>
|
||||
<Text style={styles.buttonText}>Verwijderen</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: { flex: 1, backgroundColor: '#f5f5f5' },
|
||||
content: { padding: 16 },
|
||||
centered: { flex: 1, justifyContent: 'center', alignItems: 'center', backgroundColor: '#f5f5f5' },
|
||||
date: { fontSize: 18, fontWeight: '700', marginBottom: 16, textAlign: 'center' },
|
||||
row: {
|
||||
flexDirection: 'row',
|
||||
backgroundColor: '#fff',
|
||||
padding: 14,
|
||||
marginBottom: 8,
|
||||
borderRadius: 8,
|
||||
},
|
||||
label: { fontSize: 15, color: '#555', flex: 1 },
|
||||
value: { fontSize: 15, fontWeight: '600' },
|
||||
buttons: { flexDirection: 'row', gap: 12, padding: 16 },
|
||||
editButton: { flex: 1, backgroundColor: '#2196F3', padding: 14, borderRadius: 8, alignItems: 'center' },
|
||||
deleteButton: { flex: 1, backgroundColor: '#e53935', padding: 14, borderRadius: 8, alignItems: 'center' },
|
||||
buttonText: { color: '#fff', fontSize: 16, fontWeight: '700' },
|
||||
})
|
||||
|
|
@ -1,64 +1,57 @@
|
|||
import React, { useCallback, useEffect, useState } from 'react'
|
||||
import React, { useCallback, useState } from 'react'
|
||||
import { View, Text, Alert } from 'react-native'
|
||||
import { useFocusEffect } from '@react-navigation/native'
|
||||
import MeasurementForm from '../components/MeasurementForm'
|
||||
import { Measurement } from '../types'
|
||||
import {
|
||||
initDatabase,
|
||||
insertMeasurement,
|
||||
getLatestMeasurement,
|
||||
getMeasurementByDate,
|
||||
upsertMeasurement,
|
||||
} from '../database/db'
|
||||
import { initDatabase, insertMeasurement, upsertMeasurement, getLatestMeasurement, getMeasurementByDate } from '../database/db'
|
||||
import { refreshCache } from '../store/measurementStore'
|
||||
import { showSuccess, showError } from '../utils/toast'
|
||||
|
||||
type Props = {
|
||||
navigation: any
|
||||
route: any
|
||||
route: {
|
||||
params: {
|
||||
measurement?: Measurement
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default function NewMeasurementScreen({ navigation, route }: Props) {
|
||||
const editing = route.params?.measurement as Measurement | undefined
|
||||
const editMeasurement = route.params?.measurement
|
||||
const isEdit = !!editMeasurement
|
||||
const [latest, setLatest] = useState<Partial<Measurement> | undefined>(undefined)
|
||||
const [loaded, setLoaded] = useState(false)
|
||||
|
||||
React.useLayoutEffect(() => {
|
||||
navigation.setOptions({ title: isEdit ? 'Meting wijzigen' : 'Nieuwe meting' })
|
||||
}, [navigation, isEdit])
|
||||
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
if (isEdit) {
|
||||
setLatest(editMeasurement)
|
||||
setLoaded(true)
|
||||
return
|
||||
}
|
||||
let cancelled = false
|
||||
const load = async () => {
|
||||
await initDatabase()
|
||||
if (editing) {
|
||||
if (!cancelled) {
|
||||
setLatest(editing)
|
||||
setLoaded(true)
|
||||
}
|
||||
} else {
|
||||
const m = await getLatestMeasurement()
|
||||
if (!cancelled) {
|
||||
setLatest(m || {})
|
||||
setLoaded(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
load()
|
||||
return () => { cancelled = true }
|
||||
}, [editing])
|
||||
}, [isEdit, editMeasurement])
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (editing) {
|
||||
navigation.setOptions({ title: 'Meting wijzigen' })
|
||||
}
|
||||
}, [editing, navigation])
|
||||
|
||||
const handleSubmit = async (m: Measurement) => {
|
||||
await initDatabase()
|
||||
if (editing) {
|
||||
m.id = editing.id
|
||||
m.createdAt = editing.createdAt
|
||||
if (isEdit) {
|
||||
await upsertMeasurement(m)
|
||||
doSync()
|
||||
return
|
||||
}
|
||||
} else {
|
||||
const existing = await getMeasurementByDate(m.measuredAt)
|
||||
if (existing) {
|
||||
Alert.alert(
|
||||
|
|
@ -66,35 +59,32 @@ export default function NewMeasurementScreen({ navigation, route }: Props) {
|
|||
`Er bestaat al een meting van ${new Date(m.measuredAt).toLocaleDateString('nl-NL')}. Overschrijven?`,
|
||||
[
|
||||
{ text: 'Annuleren', style: 'cancel' },
|
||||
{ text: 'Overschrijven', style: 'destructive', onPress: () => doSave(m, existing.id) },
|
||||
{ text: 'Overschrijven', style: 'destructive', onPress: () => {
|
||||
m.id = existing.id
|
||||
upsertMeasurement(m).then(() => doSave(m))
|
||||
}},
|
||||
]
|
||||
)
|
||||
return
|
||||
}
|
||||
doSave(m)
|
||||
}
|
||||
|
||||
const doSave = async (m: Measurement, existingId?: string) => {
|
||||
if (existingId) {
|
||||
m.id = existingId
|
||||
await upsertMeasurement(m)
|
||||
} else {
|
||||
await insertMeasurement(m)
|
||||
}
|
||||
doSync()
|
||||
await doSave(m)
|
||||
}
|
||||
|
||||
const doSync = async () => {
|
||||
const doSave = async (m: Measurement) => {
|
||||
await refreshCache()
|
||||
navigation.goBack()
|
||||
uploadInBackground(m)
|
||||
}
|
||||
|
||||
const uploadInBackground = async (m: Measurement) => {
|
||||
try {
|
||||
const { syncAll } = await import('../sync/syncService')
|
||||
await syncAll()
|
||||
Alert.alert('Opgeslagen & gesynct', 'Meting is opgeslagen en gesynchroniseerd', [
|
||||
{ text: 'OK', onPress: () => navigation.goBack() },
|
||||
])
|
||||
const { uploadMeasurement } = await import('../sync/syncService')
|
||||
await uploadMeasurement(m)
|
||||
showSuccess('Opgeslagen & gesynct')
|
||||
} catch {
|
||||
Alert.alert('Opgeslagen', 'Meting is opgeslagen (sync mislukt)', [
|
||||
{ text: 'OK', onPress: () => navigation.goBack() },
|
||||
])
|
||||
showError('Opgeslagen (sync mislukt)')
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -110,6 +100,7 @@ export default function NewMeasurementScreen({ navigation, route }: Props) {
|
|||
<MeasurementForm
|
||||
onSubmit={handleSubmit}
|
||||
initialValues={latest as Partial<Measurement> | undefined}
|
||||
isEdit={isEdit}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,11 +1,11 @@
|
|||
import React, { useEffect, useState } from 'react'
|
||||
import { View, Text, TextInput, TouchableOpacity, StyleSheet, Alert, ScrollView } from 'react-native'
|
||||
import * as SecureStore from 'expo-secure-store'
|
||||
import { WebDAVConfig } from '../types'
|
||||
import { ApiConfig } from '../types'
|
||||
|
||||
const CONFIG_KEY = 'weegschaal_webdav_config'
|
||||
const CONFIG_KEY = 'weegschaal_api_config'
|
||||
|
||||
async function loadConfig(): Promise<WebDAVConfig | null> {
|
||||
async function loadConfig(): Promise<ApiConfig | null> {
|
||||
try {
|
||||
const json = await SecureStore.getItemAsync(CONFIG_KEY)
|
||||
return json ? JSON.parse(json) : null
|
||||
|
|
@ -14,7 +14,7 @@ async function loadConfig(): Promise<WebDAVConfig | null> {
|
|||
}
|
||||
}
|
||||
|
||||
async function saveConfig(config: WebDAVConfig): Promise<void> {
|
||||
export async function saveConfig(config: ApiConfig): Promise<void> {
|
||||
await SecureStore.setItemAsync(CONFIG_KEY, JSON.stringify(config))
|
||||
}
|
||||
|
||||
|
|
@ -22,37 +22,32 @@ async function deleteConfig(): Promise<void> {
|
|||
await SecureStore.deleteItemAsync(CONFIG_KEY)
|
||||
}
|
||||
|
||||
export async function getWebDAVConfig(): Promise<WebDAVConfig | null> {
|
||||
export async function getApiConfig(): Promise<ApiConfig | null> {
|
||||
return loadConfig()
|
||||
}
|
||||
|
||||
export default function SettingsScreen() {
|
||||
const [url, setUrl] = useState('https://nextcloud.eddydevink.nl/remote.php/dav/files/eddy')
|
||||
const [username, setUsername] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [showPassword, setShowPassword] = useState(false)
|
||||
const [url, setUrl] = useState('https://weegschaal.eddydevink.nl')
|
||||
const [token, setToken] = useState('')
|
||||
const [showToken, setShowToken] = useState(false)
|
||||
const [syncing, setSyncing] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
loadConfig().then(c => {
|
||||
if (c) {
|
||||
setUrl(c.url)
|
||||
setUsername(c.username)
|
||||
setPassword(c.password)
|
||||
setToken(c.token)
|
||||
}
|
||||
})
|
||||
}, [])
|
||||
|
||||
const handleSave = async () => {
|
||||
await saveConfig({ url, username, password })
|
||||
Alert.alert('Opgeslagen', 'WebDAV instellingen zijn opgeslagen')
|
||||
await saveConfig({ url, token })
|
||||
Alert.alert('Opgeslagen', 'API instellingen zijn opgeslagen')
|
||||
}
|
||||
|
||||
const handleClear = () => {
|
||||
Alert.alert(
|
||||
'Instellingen wissen?',
|
||||
'WebDAV configuratie definitief verwijderen? Je kunt deze later opnieuw invoeren.',
|
||||
[
|
||||
Alert.alert('Instellingen wissen?', 'API configuratie definitief verwijderen?', [
|
||||
{ text: 'Annuleren', style: 'cancel' },
|
||||
{
|
||||
text: 'Ja, wissen',
|
||||
|
|
@ -60,12 +55,10 @@ export default function SettingsScreen() {
|
|||
onPress: async () => {
|
||||
await deleteConfig()
|
||||
setUrl('')
|
||||
setUsername('')
|
||||
setPassword('')
|
||||
setToken('')
|
||||
},
|
||||
},
|
||||
]
|
||||
)
|
||||
])
|
||||
}
|
||||
|
||||
const handleSync = async () => {
|
||||
|
|
@ -73,7 +66,11 @@ export default function SettingsScreen() {
|
|||
try {
|
||||
const { syncAll } = await import('../sync/syncService')
|
||||
const result = await syncAll()
|
||||
Alert.alert('Sync voltooid', `Lokaal: ${result.local}, WebDAV: ${result.remote}, samengevoegd: ${result.merged}`)
|
||||
let msg = `Opgehaald: ${result.pulled}, gepusht: ${result.pushed}, verwijderd: ${result.deleted}`
|
||||
if (result.errors.length > 0) {
|
||||
msg += `\n\nFouten: ${result.errors.join(', ')}`
|
||||
}
|
||||
Alert.alert('Sync voltooid', msg)
|
||||
} catch (e: any) {
|
||||
Alert.alert('Sync fout', e?.message ?? 'Onbekende fout')
|
||||
} finally {
|
||||
|
|
@ -83,38 +80,30 @@ export default function SettingsScreen() {
|
|||
|
||||
return (
|
||||
<ScrollView style={styles.container} contentContainerStyle={styles.content}>
|
||||
<Text style={styles.sectionTitle}>WebDAV (Nextcloud)</Text>
|
||||
<Text style={styles.sectionTitle}>REST API</Text>
|
||||
|
||||
<Text style={styles.label}>URL</Text>
|
||||
<Text style={styles.label}>API URL</Text>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
value={url}
|
||||
onChangeText={setUrl}
|
||||
placeholder="https://nextcloud.eddydevink.nl/remote.php/dav/files/gebruiker/"
|
||||
placeholder="https://weegschaal.eddydevink.nl"
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
/>
|
||||
|
||||
<Text style={styles.label}>Gebruikersnaam</Text>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
value={username}
|
||||
onChangeText={setUsername}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
/>
|
||||
|
||||
<Text style={styles.label}>Wachtwoord</Text>
|
||||
<Text style={styles.label}>Token</Text>
|
||||
<View style={styles.passwordRow}>
|
||||
<TextInput
|
||||
style={styles.passwordInput}
|
||||
value={password}
|
||||
onChangeText={setPassword}
|
||||
secureTextEntry={!showPassword}
|
||||
value={token}
|
||||
onChangeText={setToken}
|
||||
secureTextEntry={!showToken}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
/>
|
||||
<TouchableOpacity style={styles.eyeButton} onPress={() => setShowPassword(!showPassword)}>
|
||||
<Text style={styles.eyeIcon}>{showPassword ? '👁' : '👁🗨'}</Text>
|
||||
<TouchableOpacity style={styles.eyeButton} onPress={() => setShowToken(!showToken)}>
|
||||
<Text style={styles.eyeIcon}>{showToken ? '👁' : '🔒'}</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
|
|
|
|||
20
src/store/measurementStore.ts
Normal file
20
src/store/measurementStore.ts
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
import { Measurement } from '../types'
|
||||
import { initDatabase, getAllMeasurements } from '../database/db'
|
||||
|
||||
let cache: Measurement[] = []
|
||||
let version = 0
|
||||
|
||||
export async function refreshCache(): Promise<Measurement[]> {
|
||||
await initDatabase()
|
||||
cache = await getAllMeasurements()
|
||||
version++
|
||||
return cache
|
||||
}
|
||||
|
||||
export function getCached(): Measurement[] {
|
||||
return cache
|
||||
}
|
||||
|
||||
export function getVersion(): number {
|
||||
return version
|
||||
}
|
||||
70
src/sync/api.ts
Normal file
70
src/sync/api.ts
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
import { Measurement } from '../types'
|
||||
import { ApiConfig } from '../types'
|
||||
|
||||
export class ApiError extends Error {
|
||||
status: number
|
||||
constructor(message: string, status: number) {
|
||||
super(message)
|
||||
this.name = 'ApiError'
|
||||
this.status = status
|
||||
}
|
||||
}
|
||||
|
||||
function authHeaders(token: string): Record<string, string> {
|
||||
return {
|
||||
Authorization: `Bearer ${token}`,
|
||||
'Content-Type': 'application/json',
|
||||
}
|
||||
}
|
||||
|
||||
export type Api = ReturnType<typeof createApi>
|
||||
|
||||
export function createApi(config: ApiConfig) {
|
||||
const base = config.url.replace(/\/+$/, '')
|
||||
|
||||
return {
|
||||
async getMeasurements(since?: string): Promise<Measurement[]> {
|
||||
let url = `${base}/api/v1/measurements`
|
||||
if (since) {
|
||||
url += `?since=${encodeURIComponent(since)}`
|
||||
}
|
||||
const response = await fetch(url, {
|
||||
headers: { Authorization: `Bearer ${config.token}` },
|
||||
})
|
||||
if (response.status === 401) {
|
||||
throw new ApiError('Authenticatie mislukt. Controleer je token in Instellingen.', 401)
|
||||
}
|
||||
if (!response.ok) {
|
||||
throw new ApiError(`Serverfout bij ophalen metingen: ${response.status}`, response.status)
|
||||
}
|
||||
return response.json()
|
||||
},
|
||||
|
||||
async createMeasurement(m: Measurement): Promise<void> {
|
||||
const response = await fetch(`${base}/api/v1/measurements`, {
|
||||
method: 'POST',
|
||||
headers: authHeaders(config.token),
|
||||
body: JSON.stringify(m),
|
||||
})
|
||||
if (response.status === 401) {
|
||||
throw new ApiError('Authenticatie mislukt. Controleer je token in Instellingen.', 401)
|
||||
}
|
||||
if (!response.ok) {
|
||||
throw new ApiError(`Serverfout bij opslaan meting: ${response.status}`, response.status)
|
||||
}
|
||||
},
|
||||
|
||||
async deleteMeasurement(id: string): Promise<void> {
|
||||
const response = await fetch(`${base}/api/v1/measurements/${id}`, {
|
||||
method: 'DELETE',
|
||||
headers: { Authorization: `Bearer ${config.token}` },
|
||||
})
|
||||
if (response.status === 401) {
|
||||
throw new ApiError('Authenticatie mislukt. Controleer je token in Instellingen.', 401)
|
||||
}
|
||||
if (!response.ok) {
|
||||
throw new ApiError(`Serverfout bij verwijderen meting: ${response.status}`, response.status)
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
@ -1,109 +1,146 @@
|
|||
import { Measurement } from '../types'
|
||||
import { initDatabase, getAllMeasurements, upsertMeasurement } from '../database/db'
|
||||
import { getWebDAVConfig } from '../screens/SettingsScreen'
|
||||
import {
|
||||
initDatabase,
|
||||
getAllMeasurementsForSync,
|
||||
upsertMeasurement,
|
||||
getPendingRecords,
|
||||
updateSyncStatus,
|
||||
removeMeasurement,
|
||||
} from '../database/db'
|
||||
import { createApi, ApiError } from './api'
|
||||
import * as SecureStore from 'expo-secure-store'
|
||||
|
||||
const WEBDAV_PATH = 'weegschaal/measurements.json'
|
||||
const LAST_SYNC_KEY = 'weegschaal_last_sync'
|
||||
|
||||
type SyncResult = { local: number; remote: number; merged: number }
|
||||
|
||||
export function mergeMeasurements(local: Measurement[], remote: Measurement[]): Measurement[] {
|
||||
const map = new Map<string, Measurement>()
|
||||
|
||||
for (const m of local) {
|
||||
map.set(m.id, m)
|
||||
type SyncResult = {
|
||||
pushed: number
|
||||
deleted: number
|
||||
pulled: number
|
||||
errors: string[]
|
||||
}
|
||||
|
||||
for (const m of remote) {
|
||||
const existing = map.get(m.id)
|
||||
if (!existing || new Date(m.updatedAt) > new Date(existing.updatedAt)) {
|
||||
map.set(m.id, { ...m, syncStatus: 'synced' })
|
||||
export function mergeMeasurements(local: Measurement[], remote: any[]): Measurement[] {
|
||||
const localMap = new Map(local.map(m => [m.id, m]))
|
||||
const resultMap = new Map(local.map(m => [m.id, m]))
|
||||
|
||||
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 {
|
||||
resultMap.set(r.id, { ...r, syncStatus: 'synced' } as Measurement)
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(map.values())
|
||||
return Array.from(resultMap.values())
|
||||
}
|
||||
|
||||
async function fetchRemote(config: { url: string; username: string; password: string }): Promise<Measurement[]> {
|
||||
const url = `${config.url.replace(/\/+$/, '')}/${WEBDAV_PATH}`
|
||||
const response = await fetch(url, {
|
||||
headers: {
|
||||
Authorization: 'Basic ' + btoa(`${config.username}:${config.password}`),
|
||||
},
|
||||
})
|
||||
|
||||
if (response.status === 404) {
|
||||
return []
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`WebDAV fetch failed: ${response.status}`)
|
||||
}
|
||||
|
||||
return response.json()
|
||||
}
|
||||
|
||||
async function createDir(config: { url: string; username: string; password: string }): Promise<void> {
|
||||
const base = config.url.replace(/\/+$/, '')
|
||||
const dirUrl = `${base}/weegschaal`
|
||||
|
||||
const response = await fetch(dirUrl, {
|
||||
method: 'MKCOL',
|
||||
headers: {
|
||||
Authorization: 'Basic ' + btoa(`${config.username}:${config.password}`),
|
||||
},
|
||||
})
|
||||
|
||||
if (response.status !== 201 && response.status !== 405) {
|
||||
throw new Error(`WebDAV directory create failed: ${response.status}`)
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
await createDir(config)
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
Authorization: 'Basic ' + btoa(`${config.username}:${config.password}`),
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body,
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`WebDAV upload failed: ${response.status}`)
|
||||
}
|
||||
export async function uploadMeasurement(m: Measurement): Promise<void> {
|
||||
const { getApiConfig } = await import('../screens/SettingsScreen')
|
||||
const config = await getApiConfig()
|
||||
if (!config) throw new Error('Geen API configuratie. Stel deze eerst in in Instellingen.')
|
||||
const api = createApi(config)
|
||||
await api.createMeasurement(m)
|
||||
await updateSyncStatus(m.id, 'synced')
|
||||
}
|
||||
|
||||
export async function syncAll(): Promise<SyncResult> {
|
||||
const config = await getWebDAVConfig()
|
||||
if (!config) {
|
||||
throw new Error('Geen WebDAV configuratie. Stel deze eerst in in Instellingen.')
|
||||
}
|
||||
const { getApiConfig } = await import('../screens/SettingsScreen')
|
||||
const config = await getApiConfig()
|
||||
if (!config) throw new Error('Geen API configuratie. Stel deze eerst in in Instellingen.')
|
||||
|
||||
const api = createApi(config)
|
||||
await initDatabase()
|
||||
|
||||
const local = await getAllMeasurements()
|
||||
const remote = await fetchRemote(config)
|
||||
const errors: string[] = []
|
||||
let pushed = 0
|
||||
let deleted = 0
|
||||
let pulled = 0
|
||||
|
||||
// === PUSH phase — outbox first ===
|
||||
const pending = await getPendingRecords()
|
||||
|
||||
for (const m of pending) {
|
||||
if (m.deletedAt) {
|
||||
try {
|
||||
await api.deleteMeasurement(m.id)
|
||||
await removeMeasurement(m.id)
|
||||
deleted++
|
||||
} catch (e: any) {
|
||||
if (e instanceof ApiError && e.status === 404) {
|
||||
await removeMeasurement(m.id)
|
||||
deleted++
|
||||
} else {
|
||||
errors.push(e?.message ?? 'Delete failed')
|
||||
}
|
||||
}
|
||||
} else {
|
||||
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 localMap = new Map(local.map(m => [m.id, m]))
|
||||
|
||||
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 updateSyncStatus(m.id, 'synced')
|
||||
pulled++
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await uploadRemote(config, merged)
|
||||
await SecureStore.setItemAsync(LAST_SYNC_KEY, new Date().toISOString())
|
||||
|
||||
return {
|
||||
local: local.length,
|
||||
remote: remote.length,
|
||||
merged: merged.length,
|
||||
}
|
||||
return { pushed, deleted, pulled, errors }
|
||||
}
|
||||
|
|
|
|||
10
src/types.ts
10
src/types.ts
|
|
@ -1,5 +1,3 @@
|
|||
export type SyncStatus = 'local' | 'synced'
|
||||
|
||||
export type Measurement = {
|
||||
id: string
|
||||
weight: number
|
||||
|
|
@ -13,11 +11,11 @@ export type Measurement = {
|
|||
measuredAt: string
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
syncStatus: SyncStatus
|
||||
syncStatus?: string
|
||||
deletedAt?: string
|
||||
}
|
||||
|
||||
export type WebDAVConfig = {
|
||||
export type ApiConfig = {
|
||||
url: string
|
||||
username: string
|
||||
password: string
|
||||
token: string
|
||||
}
|
||||
|
|
|
|||
26
src/utils/toast.ts
Normal file
26
src/utils/toast.ts
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
import { Platform, ToastAndroid } from 'react-native'
|
||||
|
||||
let _toastQueue: Array<{message: string; duration: number}> = []
|
||||
let _showFn: ((message: string, duration: number) => void) | null = null
|
||||
|
||||
export function setToastShowFn(fn: (message: string, duration: number) => void) {
|
||||
_showFn = fn
|
||||
}
|
||||
|
||||
export function showToast(message: string, duration = 3000) {
|
||||
if (_showFn) {
|
||||
_showFn(message, duration)
|
||||
return
|
||||
}
|
||||
if (Platform.OS === 'android') {
|
||||
ToastAndroid.show(message, duration > 2000 ? ToastAndroid.LONG : ToastAndroid.SHORT)
|
||||
}
|
||||
}
|
||||
|
||||
export function showSuccess(message: string) {
|
||||
showToast(message, 2500)
|
||||
}
|
||||
|
||||
export function showError(message: string) {
|
||||
showToast(message, 4000)
|
||||
}
|
||||
|
|
@ -18,7 +18,6 @@ const mockMeasurement: Measurement = {
|
|||
measuredAt: '2024-01-01T08:00:00.000Z',
|
||||
createdAt: '2024-01-01T08:00:00.000Z',
|
||||
updatedAt: '2024-01-01T08:00:00.000Z',
|
||||
syncStatus: 'local',
|
||||
}
|
||||
|
||||
describe('database', () => {
|
||||
|
|
@ -27,7 +26,7 @@ describe('database', () => {
|
|||
let insertMeasurement: any
|
||||
let getAllMeasurements: any
|
||||
let getLatestMeasurement: any
|
||||
let updateMeasurement: any
|
||||
let setDeletedAt: any
|
||||
|
||||
beforeEach(async () => {
|
||||
jest.resetModules()
|
||||
|
|
@ -46,7 +45,7 @@ describe('database', () => {
|
|||
insertMeasurement = mod.insertMeasurement
|
||||
getAllMeasurements = mod.getAllMeasurements
|
||||
getLatestMeasurement = mod.getLatestMeasurement
|
||||
updateMeasurement = mod.updateMeasurement
|
||||
setDeletedAt = mod.setDeletedAt
|
||||
})
|
||||
|
||||
it('initDatabase creates the measurements table', async () => {
|
||||
|
|
@ -82,13 +81,15 @@ describe('database', () => {
|
|||
expect(result!.id).toBe(mockMeasurement.id)
|
||||
})
|
||||
|
||||
it('updateMeasurement updates syncStatus', async () => {
|
||||
it('setDeletedAt sets both deletedAt and updatedAt timestamps', async () => {
|
||||
db.runAsync.mockResolvedValue(undefined)
|
||||
await initDatabase()
|
||||
await updateMeasurement(mockMeasurement.id, 'synced')
|
||||
await setDeletedAt(mockMeasurement.id)
|
||||
expect(db.runAsync).toHaveBeenCalledWith(
|
||||
expect.stringContaining('UPDATE measurements'),
|
||||
expect.arrayContaining(['synced', mockMeasurement.id])
|
||||
expect.stringContaining('UPDATE measurements SET deletedAt'),
|
||||
expect.arrayContaining([mockMeasurement.id])
|
||||
)
|
||||
const args = (db.runAsync as jest.Mock).mock.calls.slice(-1)[0]
|
||||
expect(args[1][0]).toBe(args[1][1])
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,57 +1,86 @@
|
|||
import { mergeMeasurements } from '../src/sync/syncService'
|
||||
import { Measurement } from '../src/types'
|
||||
|
||||
jest.mock('expo-sqlite', () => ({
|
||||
openDatabaseAsync: jest.fn(),
|
||||
}))
|
||||
jest.mock('expo-sqlite', () => ({ openDatabaseAsync: jest.fn() }))
|
||||
|
||||
const base: Measurement = {
|
||||
id: 'id-1',
|
||||
weight: 75,
|
||||
bmi: 22,
|
||||
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',
|
||||
}
|
||||
const make = (overrides: Partial<Measurement> = {}): Measurement => ({
|
||||
id: 'id-1', weight: 75, bmi: 22, 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: 'synced', ...overrides,
|
||||
})
|
||||
|
||||
describe('mergeMeasurements', () => {
|
||||
it('returns empty array when both are empty', () => {
|
||||
it('both empty returns empty', () => {
|
||||
expect(mergeMeasurements([], [])).toEqual([])
|
||||
})
|
||||
|
||||
it('keeps local items not on remote', () => {
|
||||
const result = mergeMeasurements([base], [])
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0].id).toBe('id-1')
|
||||
it('local only kept', () => {
|
||||
const r = mergeMeasurements([make()], [])
|
||||
expect(r).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('keeps remote items not on local', () => {
|
||||
const remote = { ...base, syncStatus: 'synced' as const }
|
||||
const result = mergeMeasurements([], [remote])
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0].id).toBe('id-1')
|
||||
it('remote only kept', () => {
|
||||
const r = mergeMeasurements([], [make({ id: 'r1' })])
|
||||
expect(r).toHaveLength(1)
|
||||
expect(r[0].id).toBe('r1')
|
||||
})
|
||||
|
||||
it('picks the newer version when both have same id', () => {
|
||||
const local = { ...base, weight: 75, updatedAt: '2024-06-02T08:00:00.000Z' }
|
||||
const remote = { ...base, weight: 76, updatedAt: '2024-06-03T08:00:00.000Z' }
|
||||
const result = mergeMeasurements([local], [remote])
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0].weight).toBe(76)
|
||||
it('last-writer-wins: remote newer', () => {
|
||||
const local = make({ weight: 75, updatedAt: '2024-06-02T08:00:00.000Z' })
|
||||
const remote = make({ weight: 76, updatedAt: '2024-06-03T08:00:00.000Z' })
|
||||
const r = mergeMeasurements([local], [remote])
|
||||
expect(r[0].weight).toBe(76)
|
||||
})
|
||||
|
||||
it('picks local when local is newer', () => {
|
||||
const local = { ...base, weight: 77, updatedAt: '2024-06-04T08:00:00.000Z' }
|
||||
const remote = { ...base, weight: 76, updatedAt: '2024-06-03T08:00:00.000Z' }
|
||||
const result = mergeMeasurements([local], [remote])
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0].weight).toBe(77)
|
||||
it('last-writer-wins: local newer', () => {
|
||||
const local = make({ weight: 77, updatedAt: '2024-06-04T08:00:00.000Z' })
|
||||
const remote = make({ weight: 76, updatedAt: '2024-06-03T08:00:00.000Z' })
|
||||
const r = mergeMeasurements([local], [remote])
|
||||
expect(r[0].weight).toBe(77)
|
||||
})
|
||||
|
||||
it('remote deleted wins over synced local', () => {
|
||||
const local = make({ updatedAt: '2024-06-01T08:00:00.000Z' })
|
||||
const remote: any = { ...make({ updatedAt: '2024-06-03T08:00:00.000Z' }), deleted: true }
|
||||
const r = mergeMeasurements([local], [remote])
|
||||
expect(r[0].deletedAt).toBe('2024-06-03T08:00:00.000Z')
|
||||
})
|
||||
|
||||
it('local synced wins over older remote deleted', () => {
|
||||
const local = make({ updatedAt: '2024-06-03T08:00:00.000Z' })
|
||||
const remote: any = { ...make({ updatedAt: '2024-06-01T08:00:00.000Z' }), deleted: true }
|
||||
const r = mergeMeasurements([local], [remote])
|
||||
expect(r[0].deletedAt).toBeUndefined()
|
||||
})
|
||||
|
||||
it('local pending-delete wins over remote deleted', () => {
|
||||
const local = make({ updatedAt: '2024-06-01T08:00:00.000Z', syncStatus: 'pending-delete', deletedAt: '2024-06-05T08:00:00.000Z' })
|
||||
const remote: any = { ...make({ updatedAt: '2024-06-03T08:00:00.000Z' }), deleted: true }
|
||||
const r = mergeMeasurements([local], [remote])
|
||||
expect(r[0].syncStatus).toBe('pending-delete')
|
||||
expect(r[0].deletedAt).toBe('2024-06-05T08:00:00.000Z')
|
||||
})
|
||||
|
||||
it('local pending wins over remote newer', () => {
|
||||
const local = make({ weight: 75, updatedAt: '2024-06-01T08:00:00.000Z', syncStatus: 'pending' })
|
||||
const remote: any = { ...make({ weight: 76, updatedAt: '2024-06-03T08:00:00.000Z' }) }
|
||||
const r = mergeMeasurements([local], [remote])
|
||||
expect(r[0].weight).toBe(75)
|
||||
expect(r[0].syncStatus).toBe('pending')
|
||||
})
|
||||
|
||||
it('remote deleted with no local record is skipped', () => {
|
||||
const remote: any = { ...make({ id: 'r1', updatedAt: '2024-06-03T08:00:00.000Z' }), deleted: true }
|
||||
const r = mergeMeasurements([], [remote])
|
||||
expect(r).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('local without syncStatus treated as synced', () => {
|
||||
const local = make({ weight: 75, updatedAt: '2024-06-01T08:00:00.000Z' })
|
||||
delete local.syncStatus
|
||||
const remote = make({ weight: 76, updatedAt: '2024-06-03T08:00:00.000Z' })
|
||||
const r = mergeMeasurements([local], [remote])
|
||||
expect(r[0].weight).toBe(76)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
140
upgrade.md
Normal file
140
upgrade.md
Normal file
|
|
@ -0,0 +1,140 @@
|
|||
# Upgrade: REST API sync i.p.v. WebDAV
|
||||
|
||||
Vervang WebDAV-sync door een eigen REST API op `weegschaal.eddydevink.nl`,
|
||||
met MariaDB-backend en Bearer token-authenticatie. Incrementele sync i.p.v. volledig bestand.
|
||||
|
||||
## Fase 1 — Server-side: PHP API
|
||||
|
||||
### Subdomein & nginx
|
||||
|
||||
- DNS: `weegschaal.eddydevink.nl` → zelfde server als `git.eddydevink.nl`
|
||||
- nginx server block in `/etc/nginx/conf.d/weegschaal-api.conf`
|
||||
- SSL via Let's Encrypt (`sudo certbot`)
|
||||
- Document root: `/usr/share/nginx/html/weegschaal-api/`
|
||||
|
||||
### MariaDB
|
||||
|
||||
```sql
|
||||
CREATE DATABASE weegschaal;
|
||||
CREATE USER 'weegschaal'@'localhost' IDENTIFIED BY '<random>';
|
||||
GRANT ALL ON weegschaal.* TO 'weegschaal'@'localhost';
|
||||
|
||||
CREATE TABLE users (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE auth_tokens (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
user_id INT NOT NULL REFERENCES users(id),
|
||||
token VARCHAR(64) NOT NULL UNIQUE,
|
||||
description VARCHAR(255),
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE measurements (
|
||||
id VARCHAR(36) NOT NULL,
|
||||
user_id INT NOT NULL,
|
||||
weight DECIMAL(5,2) NOT NULL,
|
||||
bmi DECIMAL(4,1) NOT NULL,
|
||||
body_fat_percentage DECIMAL(4,1) NOT NULL,
|
||||
fat_mass DECIMAL(5,2) NOT NULL,
|
||||
muscle_percentage DECIMAL(4,1) NOT NULL,
|
||||
muscle_mass DECIMAL(5,2) NOT NULL,
|
||||
visceral_fat TINYINT NOT NULL,
|
||||
bmr DECIMAL(6,1) NOT NULL,
|
||||
measured_at DATE NOT NULL,
|
||||
created_at DATETIME NOT NULL,
|
||||
updated_at DATETIME NOT NULL,
|
||||
PRIMARY KEY (user_id, id),
|
||||
INDEX idx_updated_at (user_id, updated_at)
|
||||
);
|
||||
```
|
||||
|
||||
### PHP API endpoints (Slim Framework via Composer)
|
||||
|
||||
| Methode | Pad | Beschrijving |
|
||||
|---------|-----|-------------|
|
||||
| `POST` | `/api/v1/auth/register` | (later) Nieuwe gebruiker |
|
||||
| `POST` | `/api/v1/auth/tokens` | (later) Nieuwe token aanmaken |
|
||||
| `GET` | `/api/v1/measurements` | Alle metingen (of `?since=ISO` voor delta) |
|
||||
| `POST` | `/api/v1/measurements` | Nieuwe meting aanmaken |
|
||||
| `PUT` | `/api/v1/measurements/:id` | Meting bijwerken |
|
||||
| `DELETE` | `/api/v1/measurements/:id` | Meting verwijderen |
|
||||
|
||||
Authenticatie: `Authorization: Bearer <token>`, gevalideerd tegen `auth_tokens`-tabel.
|
||||
|
||||
Sync-logica server:
|
||||
- `GET /api/v1/measurements?since=2025-01-01T12:00:00Z` → alleen metingen met `updated_at > since`
|
||||
- Bij PUT/DELETE: controleer `updated_at` client, bij conflict → HTTP 409 met server-versie
|
||||
|
||||
### Bestandsstructuur API
|
||||
|
||||
```
|
||||
/usr/share/nginx/html/weegschaal-api/
|
||||
├── composer.json
|
||||
├── public/
|
||||
│ └── index.php ← Entry point, routing
|
||||
├── src/
|
||||
│ ├── Database.php ← PDO singleton
|
||||
│ ├── Auth.php ← Token validation middleware
|
||||
│ ├── Measurements.php ← CRUD + sync queries
|
||||
│ └── helpers.php
|
||||
└── config.php ← DB credentials (uit .env of env vars)
|
||||
```
|
||||
|
||||
## Fase 2 — Client-side: App aanpassen
|
||||
|
||||
### Types (`src/types.ts`)
|
||||
|
||||
- Vervang `WebDAVConfig` door `ApiConfig { url: string; token: string }`
|
||||
- Verwijder `SyncStatus` (niet meer nodig — sync status volgt uit `updatedAt`)
|
||||
|
||||
### SettingsScreen (`src/screens/SettingsScreen.tsx`)
|
||||
|
||||
- Vervang URL/username/password door API-URL + token (met oogje)
|
||||
- Opslaan → SecureStore
|
||||
- Wissen → SecureStore verwijderen
|
||||
- 'Nu synchroniseren' knop blijft
|
||||
|
||||
### Nieuwe module `src/sync/api.ts`
|
||||
|
||||
```typescript
|
||||
const api = (config: ApiConfig) => ({
|
||||
getMeasurements: (since?: string) => GET /api/v1/measurements?since=...
|
||||
createMeasurement: (m: Measurement) => POST /api/v1/measurements
|
||||
updateMeasurement: (m: Measurement) => PUT /api/v1/measurements/:id
|
||||
deleteMeasurement: (id: string) => DELETE /api/v1/measurements/:id
|
||||
})
|
||||
```
|
||||
|
||||
### syncService.ts — volledig herschreven
|
||||
|
||||
Nieuwe sync-flow:
|
||||
|
||||
| Stap | Actie |
|
||||
|------|-------|
|
||||
| 1 | Lees lokale `lastSyncAt` (uit SecureStore) |
|
||||
| 2 | `GET ?since=lastSyncAt` → alleen wijzigingen ophalen |
|
||||
| 3 | Upsert remote wijzigingen lokaal (last-writer-wins) |
|
||||
| 4 | Voor elke lokale meting met `updatedAt > lastSyncAt`: push naar API |
|
||||
| 5 | Update `lastSyncAt` naar server-tijd |
|
||||
|
||||
Bij opslaan in `NewMeasurementScreen`:
|
||||
1. Direct naar lokale SQLite (offline-first)
|
||||
2. Direct erna: push naar API (async, niet-blokkerend via `Promise.allSettled`)
|
||||
|
||||
## Fase 3 — Migratie
|
||||
|
||||
Eerste keer na API-setup:
|
||||
1. App stuurt alle lokale metingen naar de API (1-voor-1 via POST)
|
||||
2. Alle metingen nu in MariaDB
|
||||
3. Vanaf dan: alleen delta-sync
|
||||
|
||||
## Benodigde server-toegang
|
||||
|
||||
- SSH naar nextcloud (`aiaiaiaiagent@nextcloud`) — key werkte niet
|
||||
- `sudo -A mysql` voor DB + user aanmaken
|
||||
- nginx config aanpassen (`/etc/nginx/conf.d/`)
|
||||
- DNS controleren voor subdomein
|
||||
BIN
weegschaal-app.jpeg
Normal file
BIN
weegschaal-app.jpeg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 50 KiB |
Loading…
Reference in a new issue