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/sample-pdfs/
|
||||||
test/output/
|
test/output/
|
||||||
|
|
||||||
|
# Expo
|
||||||
|
.expo/
|
||||||
|
|
||||||
|
# APK builds
|
||||||
|
*.apk
|
||||||
|
|
||||||
# Temporary files
|
# Temporary files
|
||||||
temp/
|
temp/
|
||||||
tmp/
|
tmp/
|
||||||
|
|
|
||||||
17
App.tsx
17
App.tsx
|
|
@ -5,8 +5,18 @@ import DashboardScreen from './src/screens/DashboardScreen'
|
||||||
import NewMeasurementScreen from './src/screens/NewMeasurementScreen'
|
import NewMeasurementScreen from './src/screens/NewMeasurementScreen'
|
||||||
import HistoryScreen from './src/screens/HistoryScreen'
|
import HistoryScreen from './src/screens/HistoryScreen'
|
||||||
import SettingsScreen from './src/screens/SettingsScreen'
|
import SettingsScreen from './src/screens/SettingsScreen'
|
||||||
|
import MeasurementDetailScreen from './src/screens/MeasurementDetailScreen'
|
||||||
|
import { Measurement } from './src/types'
|
||||||
|
|
||||||
const Stack = createNativeStackNavigator()
|
type RootStackParamList = {
|
||||||
|
Dashboard: undefined
|
||||||
|
NewMeasurement: { measurement?: Measurement }
|
||||||
|
History: undefined
|
||||||
|
MeasurementDetail: { measurementId: string }
|
||||||
|
Settings: undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
const Stack = createNativeStackNavigator<RootStackParamList>()
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
return (
|
return (
|
||||||
|
|
@ -27,6 +37,11 @@ export default function App() {
|
||||||
component={HistoryScreen}
|
component={HistoryScreen}
|
||||||
options={{ title: 'Historie' }}
|
options={{ title: 'Historie' }}
|
||||||
/>
|
/>
|
||||||
|
<Stack.Screen
|
||||||
|
name="MeasurementDetail"
|
||||||
|
component={MeasurementDetailScreen}
|
||||||
|
options={{ title: 'Meting' }}
|
||||||
|
/>
|
||||||
<Stack.Screen
|
<Stack.Screen
|
||||||
name="Settings"
|
name="Settings"
|
||||||
component={SettingsScreen}
|
component={SettingsScreen}
|
||||||
|
|
|
||||||
145
README.md
145
README.md
|
|
@ -1,24 +1,117 @@
|
||||||
# Weegschaal App
|
# Weegschaal App
|
||||||
|
|
||||||
React Native (Expo) app voor het handmatig invoeren van meetgegevens van een slimme weegschaal. Data wordt lokaal opgeslagen in SQLite en gesynchroniseerd met Nextcloud via WebDAV.
|
_*Gemaakt met vibecoding — DeepSeek V4 Pro*_
|
||||||
|
|
||||||
## Meetwaarden
|
React Native (Expo) app voor het invoeren en synchroniseren van lichaamsmetingen (weegschaal). Data wordt lokaal opgeslagen in SQLite en gesynchroniseerd via een eigen REST API met outbox-pattern sync.
|
||||||
|
|
||||||
- Gewicht (kg)
|
## Inhoud
|
||||||
- BMI
|
|
||||||
- Vetpercentage (%)
|
- [Functionaliteit](#functionaliteit)
|
||||||
- Vetmassa (kg) — automatisch berekend
|
- [Schermen](#schermen)
|
||||||
- Spierpercentage (%)
|
- [Synchronisatie](#synchronisatie)
|
||||||
- Spiermassa (kg) — automatisch berekend
|
- [Server-side API](#server-side-api)
|
||||||
- Buikvet (index)
|
- [Beveiliging](#beveiliging)
|
||||||
- Stofwisseling in rust (kcal)
|
- [Offline resilience](#offline-resilience)
|
||||||
|
- [Ontwikkeling](#ontwikkeling)
|
||||||
|
- [Build & deploy](#build--deploy)
|
||||||
|
- [Testen](#testen)
|
||||||
|
- [Licentie](#licentie)
|
||||||
|
|
||||||
|
## Functionaliteit
|
||||||
|
|
||||||
|
- **Metingen invoeren** van een slimme weegschaal: gewicht, BMI, vetpercentage, vetmassa, spierpercentage, spiermassa, buikvet en stofwisseling
|
||||||
|
- **Dashboard** met laatste en voorlaatste meting (direct zichtbaar via progressieve loading), gewichtsverschil, en grafieken (gewicht, vetmassa, spiermassa)
|
||||||
|
- **Delen** van meetresultaten als PNG-afbeelding via WhatsApp, e-mail, etc.
|
||||||
|
- **Historie** van alle metingen, gesorteerd op datum
|
||||||
|
- **Offline-first**: metingen worden lokaal opgeslagen en later automatisch gesynchroniseerd
|
||||||
|
- **Instellingen** voor API-configuratie en handmatige sync
|
||||||
|
|
||||||
## Schermen
|
## Schermen
|
||||||
|
|
||||||
- **Dashboard** — laatste meting (groene kaart) + drie losse grafieken onder elkaar (gewicht, vetmassa, spiermassa), elk met eigen y-as-schaal en 30-dagen filter gerekend vanaf de nieuwste meting
|
| Scherm | Beschrijving |
|
||||||
- **Nieuwe meting** — datumkiezer bovenaan (standaard vandaag), formulier vooraf ingevuld met waarden van de laatste meting. Opslaan-knop altijd in beeld (buiten ScrollView). Bij opslaan wordt automatisch gesynchroniseerd met Nextcloud
|
|--------|-------------|
|
||||||
- **Historie** — FlatList gesorteerd op ingevoerde datum (nieuwste bovenaan), pull-to-refresh
|
| Dashboard | Progressive loading: laatste 2 metingen direct zichtbaar, grafieken volgen daarna |
|
||||||
- **Instellingen** — WebDAV configuratie (URL, gebruikersnaam, wachtwoord met oogje voor tonen/verbergen), handmatige sync, wissen met bevestigingsdialoog
|
| Nieuwe meting / Wijzigen | Formulier met datumkiezer, waarden vooraf ingevuld vanaf laatste meting |
|
||||||
|
| Meting detail | Alle meetwaarden met knoppen voor wijzigen en verwijderen |
|
||||||
|
| Historie | FlatList van alle metingen, pull-to-refresh, tik voor detail |
|
||||||
|
| Instellingen | API URL + token, opslaan/wissen, handmatige sync |
|
||||||
|
|
||||||
|
## Synchronisatie
|
||||||
|
|
||||||
|
De app synchroniseert via een zelfgebouwde REST API. Geen WebDAV, geen bestandssync — pure delta-sync.
|
||||||
|
|
||||||
|
### Hoe het werkt
|
||||||
|
|
||||||
|
1. **Outbox pattern**: elke meting heeft een `syncStatus` — `pending` (nieuw/gewijzigd), `pending-delete` (verwijderd), of `synced` (in sync met server)
|
||||||
|
2. **Push vóór pull**: eerst worden lokale wijzigingen (`pending` + `pending-delete`) naar de server gepusht, daarna pas remote changes opgehaald
|
||||||
|
3. **Local-first merge**: remote data overschrijft nooit lokale `pending` of `pending-delete` records — de gebruiker wint altijd
|
||||||
|
4. **Verwijderen**: `setDeletedAt(id)` lokaal + `updateSyncStatus(id, 'pending-delete')` → push in outbox-fase → server DELETE → hard delete in pull-fase
|
||||||
|
5. **Cursor tracking**: `lastSyncAt` (SecureStore) voor delta-pull, alleen records sinds laatste sync
|
||||||
|
|
||||||
|
### Waarom outbox pattern?
|
||||||
|
|
||||||
|
Betrouwbaarder dan timestamp-only push: `syncStatus` markeert expliciet wat gepusht moet worden, onafhankelijk van klokverschillen. Push vóór pull voorkomt dat eigen wijzigingen overschreven worden door oudere remote data. Local-first merge garandeert dat de gebruiker nooit data verliest.
|
||||||
|
|
||||||
|
### Offline resilience
|
||||||
|
|
||||||
|
Bij netwerkfout tijdens opslaan blijft de meting lokaal staan met `syncStatus='pending'`. De outbox (`getPendingRecords()`) pikt hem automatisch op bij de volgende sync of dashboard-focus.
|
||||||
|
|
||||||
|
## Server-side API
|
||||||
|
|
||||||
|
**Stack**: nginx, PHP 8.5 (Slim Framework 4), MariaDB, Let's Encrypt SSL, SELinux
|
||||||
|
|
||||||
|
**Endpoints**:
|
||||||
|
|
||||||
|
| Methode | Pad | Auth | Beschrijving |
|
||||||
|
|---------|-----|------|-------------|
|
||||||
|
| `GET` | `/api/v1/measurements?since=ISO` | Bearer | Delta-sync |
|
||||||
|
| `POST` | `/api/v1/measurements` | Bearer | Upsert (INSERT ON DUPLICATE KEY) |
|
||||||
|
| `DELETE` | `/api/v1/measurements/:id` | Bearer | Soft delete (`deleted=1`, update `updated_at`) |
|
||||||
|
|
||||||
|
**Database**: MariaDB met drie tabellen — `users`, `auth_tokens`, `measurements`. De `measurements` tabel gebruikt `ON DUPLICATE KEY UPDATE` voor conflictvrije upserts.
|
||||||
|
|
||||||
|
**Bestandsstructuur**:
|
||||||
|
```
|
||||||
|
/usr/share/nginx/html/weegschaal-api/
|
||||||
|
├── composer.json
|
||||||
|
├── config.php
|
||||||
|
├── public/
|
||||||
|
│ └── index.php
|
||||||
|
└── src/
|
||||||
|
├── Auth.php ← Bearer token validatie middleware
|
||||||
|
├── Database.php ← PDO singleton
|
||||||
|
├── Measurements.php ← CRUD + sync queries
|
||||||
|
└── helpers.php ← JSON response, datetime conversie, logging
|
||||||
|
```
|
||||||
|
|
||||||
|
## Beveiliging
|
||||||
|
|
||||||
|
### Authenticatie
|
||||||
|
|
||||||
|
Bearer token-validatie via `auth_tokens` tabel. Tokens worden handmatig aangemaakt via SQL.
|
||||||
|
|
||||||
|
### Logging
|
||||||
|
|
||||||
|
Elke mislukte authenticatie en 404 wordt gelogd naar `/var/log/weegschaal-api/auth.log`:
|
||||||
|
|
||||||
|
```
|
||||||
|
[2026-07-20 18:06:07] 401 94.157.121.179 GET /api/v1/measurements - invalid Bearer token
|
||||||
|
[2026-07-20 18:06:07] 404 94.157.121.179 GET /.env - unknown route
|
||||||
|
```
|
||||||
|
|
||||||
|
### Fail2ban
|
||||||
|
|
||||||
|
Jail `weegschaal-api`: 5 foutieve pogingen binnen 5 minuten = 1 uur IP-ban. Filter regex:
|
||||||
|
|
||||||
|
```
|
||||||
|
^\[.*\] 401 <HOST> .* - .*$
|
||||||
|
```
|
||||||
|
|
||||||
|
Logrotering wekelijks, 12 weken retentie.
|
||||||
|
|
||||||
|
### SELinux
|
||||||
|
|
||||||
|
Logmap heeft context `httpd_log_t` gezet via `semanage fcontext`.
|
||||||
|
|
||||||
## Ontwikkeling
|
## Ontwikkeling
|
||||||
|
|
||||||
|
|
@ -29,15 +122,25 @@ npx expo start
|
||||||
|
|
||||||
Scannen met Expo Go op Android of iOS.
|
Scannen met Expo Go op Android of iOS.
|
||||||
|
|
||||||
## Synchronisatie
|
## Build & deploy
|
||||||
|
|
||||||
Data wordt opgeslagen als `weegschaal/measurements.json` in je Nextcloud WebDAV. Vul in het Instellingen scherm het volgende in:
|
```bash
|
||||||
|
# APK bouwen (lokaal via EAS)
|
||||||
|
npx eas build --local --profile apk --platform android
|
||||||
|
|
||||||
- **URL**: `https://<jouw-nextcloud>/remote.php/dav/files/<gebruikersnaam>/`
|
# Draadloos sideloaden via ADB
|
||||||
- **Gebruikersnaam**: je Nextcloud gebruikersnaam
|
adb-wireless-connect
|
||||||
- **Wachtwoord**: je Nextcloud app-wachtwoord
|
adb install -r build-*.apk
|
||||||
|
```
|
||||||
|
|
||||||
Bij sync worden lokale en externe metingen samengevoegd (last-write-wins op basis van `updatedAt`). De map `weegschaal/` wordt automatisch aangemaakt via MKCOL indien deze nog niet bestaat.
|
Vereisten: Android SDK 36, OpenJDK 21, ADB 37+, Gradle 8.14.
|
||||||
|
|
||||||
|
## Database migratie
|
||||||
|
|
||||||
|
Bestaande SQLite-databases worden automatisch gemigreerd bij `initDatabase()`:
|
||||||
|
|
||||||
|
- `deletedAt` kolom: toegevoegd via `ALTER TABLE` als deze nog niet bestaat
|
||||||
|
- `syncStatus` migratie: bestaande records met `syncStatus='local'` worden omgezet naar `'synced'`, nieuwe records krijgen DEFAULT `'pending'`
|
||||||
|
|
||||||
## Testen
|
## Testen
|
||||||
|
|
||||||
|
|
@ -45,6 +148,8 @@ Bij sync worden lokale en externe metingen samengevoegd (last-write-wins op basi
|
||||||
npm test
|
npm test
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Tests richten zich op de merge-logica (`mergeMeasurements`) en database-operaties.
|
||||||
|
|
||||||
## Licentie
|
## Licentie
|
||||||
|
|
||||||
Public domain (Unlicense)
|
Public domain (Unlicense)
|
||||||
|
|
|
||||||
17
app.json
17
app.json
|
|
@ -13,9 +13,9 @@
|
||||||
"package": "nl.eddydevink.weegschaal",
|
"package": "nl.eddydevink.weegschaal",
|
||||||
"adaptiveIcon": {
|
"adaptiveIcon": {
|
||||||
"backgroundColor": "#E6F4FE",
|
"backgroundColor": "#E6F4FE",
|
||||||
"foregroundImage": "./assets/android-icon-foreground.png",
|
"foregroundImage": "./assets/icon.png",
|
||||||
"backgroundImage": "./assets/android-icon-background.png",
|
"backgroundImage": "./assets/android-icon-background.png",
|
||||||
"monochromeImage": "./assets/android-icon-monochrome.png"
|
"monochromeImage": "./assets/icon.png"
|
||||||
},
|
},
|
||||||
"predictiveBackGestureEnabled": false
|
"predictiveBackGestureEnabled": false
|
||||||
},
|
},
|
||||||
|
|
@ -26,6 +26,17 @@
|
||||||
"expo-sqlite",
|
"expo-sqlite",
|
||||||
"expo-secure-store",
|
"expo-secure-store",
|
||||||
"@react-native-community/datetimepicker"
|
"@react-native-community/datetimepicker"
|
||||||
]
|
],
|
||||||
|
"extra": {
|
||||||
|
"eas": {
|
||||||
|
"projectId": "9707c719-d5aa-45a4-a6cd-b95ac3d32996"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"runtimeVersion": {
|
||||||
|
"policy": "appVersion"
|
||||||
|
},
|
||||||
|
"updates": {
|
||||||
|
"url": "https://u.expo.dev/9707c719-d5aa-45a4-a6cd-b95ac3d32996"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
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": {
|
"cli": {
|
||||||
"version": ">= 5.9.1"
|
"version": ">= 5.9.1",
|
||||||
|
"appVersionSource": "local"
|
||||||
},
|
},
|
||||||
"build": {
|
"build": {
|
||||||
"development": {
|
"development": {
|
||||||
|
|
|
||||||
385
package-lock.json
generated
385
package-lock.json
generated
|
|
@ -13,14 +13,19 @@
|
||||||
"@react-navigation/native-stack": "^7.17.6",
|
"@react-navigation/native-stack": "^7.17.6",
|
||||||
"expo": "~54.0.0",
|
"expo": "~54.0.0",
|
||||||
"expo-secure-store": "~15.0.8",
|
"expo-secure-store": "~15.0.8",
|
||||||
|
"expo-sharing": "~14.0.8",
|
||||||
"expo-sqlite": "~16.0.10",
|
"expo-sqlite": "~16.0.10",
|
||||||
"expo-status-bar": "~3.0.9",
|
"expo-status-bar": "~3.0.9",
|
||||||
|
"expo-updates": "~29.0.18",
|
||||||
"react": "19.1.0",
|
"react": "19.1.0",
|
||||||
|
"react-dom": "19.1.0",
|
||||||
"react-native": "0.81.5",
|
"react-native": "0.81.5",
|
||||||
"react-native-chart-kit": "^7.0.1",
|
"react-native-chart-kit": "^7.0.1",
|
||||||
"react-native-safe-area-context": "~5.6.0",
|
"react-native-safe-area-context": "~5.6.0",
|
||||||
"react-native-screens": "~4.16.0",
|
"react-native-screens": "~4.16.0",
|
||||||
"react-native-svg": "15.12.1"
|
"react-native-svg": "15.12.1",
|
||||||
|
"react-native-view-shot": "4.0.3",
|
||||||
|
"react-native-web": "^0.21.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/jest": "29.5.14",
|
"@types/jest": "29.5.14",
|
||||||
|
|
@ -1734,25 +1739,6 @@
|
||||||
"semver": "^7.6.0"
|
"semver": "^7.6.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@expo/image-utils/node_modules/@expo/require-utils": {
|
|
||||||
"version": "55.0.5",
|
|
||||||
"resolved": "https://registry.npmjs.org/@expo/require-utils/-/require-utils-55.0.5.tgz",
|
|
||||||
"integrity": "sha512-U4K/CQ2VpXuwfNGsN+daKmYOt15hCP8v/pXaYH6eut7kdYZo6SfJ1yr67BIcJ+1Gzzs+QzTxswAZChKpXmceyw==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@babel/code-frame": "^7.20.0",
|
|
||||||
"@babel/core": "^7.25.2",
|
|
||||||
"@babel/plugin-transform-modules-commonjs": "^7.24.8"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"typescript": "^5.0.0 || ^5.0.0-0"
|
|
||||||
},
|
|
||||||
"peerDependenciesMeta": {
|
|
||||||
"typescript": {
|
|
||||||
"optional": true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@expo/json-file": {
|
"node_modules/@expo/json-file": {
|
||||||
"version": "10.2.0",
|
"version": "10.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/@expo/json-file/-/json-file-10.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/@expo/json-file/-/json-file-10.2.0.tgz",
|
||||||
|
|
@ -2161,6 +2147,25 @@
|
||||||
"xmlbuilder": "^15.1.1"
|
"xmlbuilder": "^15.1.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@expo/require-utils": {
|
||||||
|
"version": "55.0.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@expo/require-utils/-/require-utils-55.0.5.tgz",
|
||||||
|
"integrity": "sha512-U4K/CQ2VpXuwfNGsN+daKmYOt15hCP8v/pXaYH6eut7kdYZo6SfJ1yr67BIcJ+1Gzzs+QzTxswAZChKpXmceyw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@babel/code-frame": "^7.20.0",
|
||||||
|
"@babel/core": "^7.25.2",
|
||||||
|
"@babel/plugin-transform-modules-commonjs": "^7.24.8"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"typescript": "^5.0.0 || ^5.0.0-0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"typescript": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@expo/schema-utils": {
|
"node_modules/@expo/schema-utils": {
|
||||||
"version": "0.1.8",
|
"version": "0.1.8",
|
||||||
"resolved": "https://registry.npmjs.org/@expo/schema-utils/-/schema-utils-0.1.8.tgz",
|
"resolved": "https://registry.npmjs.org/@expo/schema-utils/-/schema-utils-0.1.8.tgz",
|
||||||
|
|
@ -3725,6 +3730,15 @@
|
||||||
"node": "18 || 20 || >=22"
|
"node": "18 || 20 || >=22"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/base64-arraybuffer": {
|
||||||
|
"version": "1.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/base64-arraybuffer/-/base64-arraybuffer-1.0.2.tgz",
|
||||||
|
"integrity": "sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.6.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/base64-js": {
|
"node_modules/base64-js": {
|
||||||
"version": "1.5.1",
|
"version": "1.5.1",
|
||||||
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
|
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
|
||||||
|
|
@ -4326,6 +4340,57 @@
|
||||||
"node": "^14.15.0 || ^16.10.0 || >=18.0.0"
|
"node": "^14.15.0 || ^16.10.0 || >=18.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/cross-fetch": {
|
||||||
|
"version": "3.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-3.2.0.tgz",
|
||||||
|
"integrity": "sha512-Q+xVJLoGOeIMXZmbUK4HYk+69cQH6LudR0Vu/pRm2YlU/hDV9CiS0gKUMaWY5f2NeUH9C1nV3bsTlCo0FsTV1Q==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"node-fetch": "^2.7.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/cross-fetch/node_modules/node-fetch": {
|
||||||
|
"version": "2.7.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz",
|
||||||
|
"integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"whatwg-url": "^5.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "4.x || >=6.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"encoding": "^0.1.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"encoding": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/cross-fetch/node_modules/tr46": {
|
||||||
|
"version": "0.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
|
||||||
|
"integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/cross-fetch/node_modules/webidl-conversions": {
|
||||||
|
"version": "3.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
|
||||||
|
"integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==",
|
||||||
|
"license": "BSD-2-Clause"
|
||||||
|
},
|
||||||
|
"node_modules/cross-fetch/node_modules/whatwg-url": {
|
||||||
|
"version": "5.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz",
|
||||||
|
"integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"tr46": "~0.0.3",
|
||||||
|
"webidl-conversions": "^3.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/cross-spawn": {
|
"node_modules/cross-spawn": {
|
||||||
"version": "7.0.6",
|
"version": "7.0.6",
|
||||||
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
||||||
|
|
@ -4340,6 +4405,24 @@
|
||||||
"node": ">= 8"
|
"node": ">= 8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/css-in-js-utils": {
|
||||||
|
"version": "3.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/css-in-js-utils/-/css-in-js-utils-3.1.0.tgz",
|
||||||
|
"integrity": "sha512-fJAcud6B3rRu+KHYk+Bwf+WFL2MDCJJ1XG9x137tJQ0xYxor7XziQtuGFbWNdqrvF4Tk26O3H73nfVqXt/fW1A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"hyphenate-style-name": "^1.0.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/css-line-break": {
|
||||||
|
"version": "2.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/css-line-break/-/css-line-break-2.1.0.tgz",
|
||||||
|
"integrity": "sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"utrie": "^1.0.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/css-select": {
|
"node_modules/css-select": {
|
||||||
"version": "5.2.2",
|
"version": "5.2.2",
|
||||||
"resolved": "https://registry.npmjs.org/css-select/-/css-select-5.2.2.tgz",
|
"resolved": "https://registry.npmjs.org/css-select/-/css-select-5.2.2.tgz",
|
||||||
|
|
@ -5077,6 +5160,31 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/expo-eas-client": {
|
||||||
|
"version": "1.0.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/expo-eas-client/-/expo-eas-client-1.0.8.tgz",
|
||||||
|
"integrity": "sha512-5or11NJhSeDoHHI6zyvQDW2cz/yFyE+1Cz8NTs5NK8JzC7J0JrkUgptWtxyfB6Xs/21YRNifd3qgbBN3hfKVgA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/expo-json-utils": {
|
||||||
|
"version": "0.15.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/expo-json-utils/-/expo-json-utils-0.15.0.tgz",
|
||||||
|
"integrity": "sha512-duRT6oGl80IDzH2LD2yEFWNwGIC2WkozsB6HF3cDYNoNNdUvFk6uN3YiwsTsqVM/D0z6LEAQ01/SlYvN+Fw0JQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/expo-manifests": {
|
||||||
|
"version": "1.0.11",
|
||||||
|
"resolved": "https://registry.npmjs.org/expo-manifests/-/expo-manifests-1.0.11.tgz",
|
||||||
|
"integrity": "sha512-6zItytTewN37Cjhp3glUg0ozrgW2GwB8x9wtfzUNoJIMmxO38nnGdTLMaotYhRqdf5PP2Dzdmej1HDHXVNUpRw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@expo/config": "~12.0.13",
|
||||||
|
"expo-json-utils": "~0.15.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"expo": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/expo-modules-autolinking": {
|
"node_modules/expo-modules-autolinking": {
|
||||||
"version": "3.0.26",
|
"version": "3.0.26",
|
||||||
"resolved": "https://registry.npmjs.org/expo-modules-autolinking/-/expo-modules-autolinking-3.0.26.tgz",
|
"resolved": "https://registry.npmjs.org/expo-modules-autolinking/-/expo-modules-autolinking-3.0.26.tgz",
|
||||||
|
|
@ -5124,6 +5232,15 @@
|
||||||
"node": ">=20.16.0"
|
"node": ">=20.16.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/expo-sharing": {
|
||||||
|
"version": "14.0.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/expo-sharing/-/expo-sharing-14.0.8.tgz",
|
||||||
|
"integrity": "sha512-A1pPr2iBrxypFDCWVAESk532HK+db7MFXbvO2sCV9ienaFXAk7lIBm6bkqgE6vzRd9O3RGdEGzYx80cYlc089Q==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"expo": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/expo-sqlite": {
|
"node_modules/expo-sqlite": {
|
||||||
"version": "16.0.10",
|
"version": "16.0.10",
|
||||||
"resolved": "https://registry.npmjs.org/expo-sqlite/-/expo-sqlite-16.0.10.tgz",
|
"resolved": "https://registry.npmjs.org/expo-sqlite/-/expo-sqlite-16.0.10.tgz",
|
||||||
|
|
@ -5151,6 +5268,57 @@
|
||||||
"react-native": "*"
|
"react-native": "*"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/expo-structured-headers": {
|
||||||
|
"version": "5.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/expo-structured-headers/-/expo-structured-headers-5.0.0.tgz",
|
||||||
|
"integrity": "sha512-RmrBtnSphk5REmZGV+lcdgdpxyzio5rJw8CXviHE6qH5pKQQ83fhMEcigvrkBdsn2Efw2EODp4Yxl1/fqMvOZw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/expo-updates": {
|
||||||
|
"version": "29.0.18",
|
||||||
|
"resolved": "https://registry.npmjs.org/expo-updates/-/expo-updates-29.0.18.tgz",
|
||||||
|
"integrity": "sha512-qn0YDM7wVwghQAaxb9NYzzwrmj+v8Djz5H+Zft4/myG9SPg4ICAfDy52IVF0K+/GH/oNgsFfzmmLl6hIkDu42g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@expo/code-signing-certificates": "^0.0.6",
|
||||||
|
"@expo/plist": "^0.4.9",
|
||||||
|
"@expo/spawn-async": "^1.7.2",
|
||||||
|
"arg": "4.1.0",
|
||||||
|
"chalk": "^4.1.2",
|
||||||
|
"debug": "^4.3.4",
|
||||||
|
"expo-eas-client": "~1.0.8",
|
||||||
|
"expo-manifests": "~1.0.11",
|
||||||
|
"expo-structured-headers": "~5.0.0",
|
||||||
|
"expo-updates-interface": "~2.0.0",
|
||||||
|
"getenv": "^2.0.0",
|
||||||
|
"glob": "^13.0.0",
|
||||||
|
"ignore": "^5.3.1",
|
||||||
|
"resolve-from": "^5.0.0"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"expo-updates": "bin/cli.js"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"expo": "*",
|
||||||
|
"react": "*",
|
||||||
|
"react-native": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/expo-updates-interface": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/expo-updates-interface/-/expo-updates-interface-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-pTzAIufEZdVPKql6iMi5ylVSPqV1qbEopz9G6TSECQmnNde2nwq42PxdFBaUEd8IZJ/fdJLQnOT3m6+XJ5s7jg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"expo": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/expo-updates/node_modules/arg": {
|
||||||
|
"version": "4.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/arg/-/arg-4.1.0.tgz",
|
||||||
|
"integrity": "sha512-ZWc51jO3qegGkVh8Hwpv636EkbesNV5ZNQPCtRa+0qytRYPEs9IYT9qITY9buezqUH5uqyzlWLcufrzU2rffdg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/expo/node_modules/@expo/cli": {
|
"node_modules/expo/node_modules/@expo/cli": {
|
||||||
"version": "54.0.25",
|
"version": "54.0.25",
|
||||||
"resolved": "https://registry.npmjs.org/@expo/cli/-/cli-54.0.25.tgz",
|
"resolved": "https://registry.npmjs.org/@expo/cli/-/cli-54.0.25.tgz",
|
||||||
|
|
@ -5580,6 +5748,36 @@
|
||||||
"bser": "2.1.1"
|
"bser": "2.1.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/fbjs": {
|
||||||
|
"version": "3.0.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/fbjs/-/fbjs-3.0.5.tgz",
|
||||||
|
"integrity": "sha512-ztsSx77JBtkuMrEypfhgc3cI0+0h+svqeie7xHbh1k/IKdcydnvadp/mUaGgjAOXQmQSxsqgaRhS3q9fy+1kxg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"cross-fetch": "^3.1.5",
|
||||||
|
"fbjs-css-vars": "^1.0.0",
|
||||||
|
"loose-envify": "^1.0.0",
|
||||||
|
"object-assign": "^4.1.0",
|
||||||
|
"promise": "^7.1.1",
|
||||||
|
"setimmediate": "^1.0.5",
|
||||||
|
"ua-parser-js": "^1.0.35"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/fbjs-css-vars": {
|
||||||
|
"version": "1.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/fbjs-css-vars/-/fbjs-css-vars-1.0.2.tgz",
|
||||||
|
"integrity": "sha512-b2XGFAFdWZWg0phtAWLHCk836A1Xann+I+Dgd3Gk64MHKZO44FfoD1KxyvbSh0qZsIoXQGGlVztIY+oitJPpRQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/fbjs/node_modules/promise": {
|
||||||
|
"version": "7.3.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/promise/-/promise-7.3.1.tgz",
|
||||||
|
"integrity": "sha512-nolQXZ/4L+bP/UGlkfaIujX9BKxGwmQ9OT4mOt5yvy8iK1h3wqTEJCijzGANTCCl9nWjY41juyAn2K3Q1hLLTg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"asap": "~2.0.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/fill-range": {
|
"node_modules/fill-range": {
|
||||||
"version": "7.1.1",
|
"version": "7.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
|
||||||
|
|
@ -5975,6 +6173,19 @@
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true
|
"peer": true
|
||||||
},
|
},
|
||||||
|
"node_modules/html2canvas": {
|
||||||
|
"version": "1.4.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/html2canvas/-/html2canvas-1.4.1.tgz",
|
||||||
|
"integrity": "sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"css-line-break": "^2.1.0",
|
||||||
|
"text-segmentation": "^1.0.3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/http-errors": {
|
"node_modules/http-errors": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
|
||||||
|
|
@ -6056,6 +6267,12 @@
|
||||||
"node": ">=10.17.0"
|
"node": ">=10.17.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/hyphenate-style-name": {
|
||||||
|
"version": "1.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/hyphenate-style-name/-/hyphenate-style-name-1.1.0.tgz",
|
||||||
|
"integrity": "sha512-WDC/ui2VVRrz3jOVi+XtjqkDjiVjTtFaAGiW37k6b+ohyQ5wYDOGkvCZa8+H0nx3gyvv0+BST9xuOgIyGQ00gw==",
|
||||||
|
"license": "BSD-3-Clause"
|
||||||
|
},
|
||||||
"node_modules/iconv-lite": {
|
"node_modules/iconv-lite": {
|
||||||
"version": "0.6.3",
|
"version": "0.6.3",
|
||||||
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
|
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
|
||||||
|
|
@ -6166,6 +6383,15 @@
|
||||||
"integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==",
|
"integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==",
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
|
"node_modules/inline-style-prefixer": {
|
||||||
|
"version": "7.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/inline-style-prefixer/-/inline-style-prefixer-7.0.1.tgz",
|
||||||
|
"integrity": "sha512-lhYo5qNTQp3EvSSp3sRvXMbVQTLrvGV6DycRMJ5dm2BLMiJ30wpXKdDdgX+GmJZ5uQMucwRKHamXSst3Sj/Giw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"css-in-js-utils": "^3.1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/invariant": {
|
"node_modules/invariant": {
|
||||||
"version": "2.2.4",
|
"version": "2.2.4",
|
||||||
"resolved": "https://registry.npmjs.org/invariant/-/invariant-2.2.4.tgz",
|
"resolved": "https://registry.npmjs.org/invariant/-/invariant-2.2.4.tgz",
|
||||||
|
|
@ -9051,6 +9277,12 @@
|
||||||
"node": "^10 || ^12 || >=14"
|
"node": "^10 || ^12 || >=14"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/postcss-value-parser": {
|
||||||
|
"version": "4.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz",
|
||||||
|
"integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/pretty-bytes": {
|
"node_modules/pretty-bytes": {
|
||||||
"version": "5.6.0",
|
"version": "5.6.0",
|
||||||
"resolved": "https://registry.npmjs.org/pretty-bytes/-/pretty-bytes-5.6.0.tgz",
|
"resolved": "https://registry.npmjs.org/pretty-bytes/-/pretty-bytes-5.6.0.tgz",
|
||||||
|
|
@ -9263,6 +9495,18 @@
|
||||||
"ws": "^7"
|
"ws": "^7"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/react-dom": {
|
||||||
|
"version": "19.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.0.tgz",
|
||||||
|
"integrity": "sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"scheduler": "^0.26.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^19.1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/react-freeze": {
|
"node_modules/react-freeze": {
|
||||||
"version": "1.0.4",
|
"version": "1.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/react-freeze/-/react-freeze-1.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/react-freeze/-/react-freeze-1.0.4.tgz",
|
||||||
|
|
@ -9409,6 +9653,51 @@
|
||||||
"react-native": "*"
|
"react-native": "*"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/react-native-view-shot": {
|
||||||
|
"version": "4.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-native-view-shot/-/react-native-view-shot-4.0.3.tgz",
|
||||||
|
"integrity": "sha512-USNjYmED7C0me02c1DxKA0074Hw+y/nxo+xJKlffMvfUWWzL5ELh/TJA/pTnVqFurIrzthZDPtDM7aBFJuhrHQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"html2canvas": "^1.4.1"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "*",
|
||||||
|
"react-native": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/react-native-web": {
|
||||||
|
"version": "0.21.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-native-web/-/react-native-web-0.21.2.tgz",
|
||||||
|
"integrity": "sha512-SO2t9/17zM4iEnFvlu2DA9jqNbzNhoUP+AItkoCOyFmDMOhUnBBznBDCYN92fGdfAkfQlWzPoez6+zLxFNsZEg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@babel/runtime": "^7.18.6",
|
||||||
|
"@react-native/normalize-colors": "^0.74.1",
|
||||||
|
"fbjs": "^3.0.4",
|
||||||
|
"inline-style-prefixer": "^7.0.1",
|
||||||
|
"memoize-one": "^6.0.0",
|
||||||
|
"nullthrows": "^1.1.1",
|
||||||
|
"postcss-value-parser": "^4.2.0",
|
||||||
|
"styleq": "^0.1.3"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^18.0.0 || ^19.0.0",
|
||||||
|
"react-dom": "^18.0.0 || ^19.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/react-native-web/node_modules/@react-native/normalize-colors": {
|
||||||
|
"version": "0.74.89",
|
||||||
|
"resolved": "https://registry.npmjs.org/@react-native/normalize-colors/-/normalize-colors-0.74.89.tgz",
|
||||||
|
"integrity": "sha512-qoMMXddVKVhZ8PA1AbUCk83trpd6N+1nF2A6k1i6LsQObyS92fELuk8kU/lQs6M7BsMHwqyLCpQJ1uFgNvIQXg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/react-native-web/node_modules/memoize-one": {
|
||||||
|
"version": "6.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-6.0.0.tgz",
|
||||||
|
"integrity": "sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/react-native/node_modules/@react-native/js-polyfills": {
|
"node_modules/react-native/node_modules/@react-native/js-polyfills": {
|
||||||
"version": "0.81.5",
|
"version": "0.81.5",
|
||||||
"resolved": "https://registry.npmjs.org/@react-native/js-polyfills/-/js-polyfills-0.81.5.tgz",
|
"resolved": "https://registry.npmjs.org/@react-native/js-polyfills/-/js-polyfills-0.81.5.tgz",
|
||||||
|
|
@ -9957,6 +10246,12 @@
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/setimmediate": {
|
||||||
|
"version": "1.0.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz",
|
||||||
|
"integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/setprototypeof": {
|
"node_modules/setprototypeof": {
|
||||||
"version": "1.2.0",
|
"version": "1.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
|
||||||
|
|
@ -10304,6 +10599,12 @@
|
||||||
"integrity": "sha512-0MP/Cxx5SzeeZ10p/bZI0S6MpgD+yxAhi1BOQ34jgnMXsCq3j1t6tQnZu+KdlL7dvJTLT3g9xN8tl10TqgFMcg==",
|
"integrity": "sha512-0MP/Cxx5SzeeZ10p/bZI0S6MpgD+yxAhi1BOQ34jgnMXsCq3j1t6tQnZu+KdlL7dvJTLT3g9xN8tl10TqgFMcg==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/styleq": {
|
||||||
|
"version": "0.1.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/styleq/-/styleq-0.1.3.tgz",
|
||||||
|
"integrity": "sha512-3ZUifmCDCQanjeej1f6kyl/BeP/Vae5EYkQ9iJfUm/QwZvlgnZzyflqAsAWYURdtea8Vkvswu2GrC57h3qffcA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/sucrase": {
|
"node_modules/sucrase": {
|
||||||
"version": "3.35.1",
|
"version": "3.35.1",
|
||||||
"resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz",
|
"resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz",
|
||||||
|
|
@ -10507,6 +10808,15 @@
|
||||||
"node": "*"
|
"node": "*"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/text-segmentation": {
|
||||||
|
"version": "1.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/text-segmentation/-/text-segmentation-1.0.3.tgz",
|
||||||
|
"integrity": "sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"utrie": "^1.0.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/thenify": {
|
"node_modules/thenify": {
|
||||||
"version": "3.3.1",
|
"version": "3.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz",
|
||||||
|
|
@ -10673,6 +10983,32 @@
|
||||||
"node": ">=14.17"
|
"node": ">=14.17"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/ua-parser-js": {
|
||||||
|
"version": "1.0.41",
|
||||||
|
"resolved": "https://registry.npmjs.org/ua-parser-js/-/ua-parser-js-1.0.41.tgz",
|
||||||
|
"integrity": "sha512-LbBDqdIC5s8iROCUjMbW1f5dJQTEFB1+KO9ogbvlb3nm9n4YHa5p4KTvFPWvh2Hs8gZMBuiB1/8+pdfe/tDPug==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/ua-parser-js"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "paypal",
|
||||||
|
"url": "https://paypal.me/faisalman"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/faisalman"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"bin": {
|
||||||
|
"ua-parser-js": "script/cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/undici": {
|
"node_modules/undici": {
|
||||||
"version": "6.27.0",
|
"version": "6.27.0",
|
||||||
"resolved": "https://registry.npmjs.org/undici/-/undici-6.27.0.tgz",
|
"resolved": "https://registry.npmjs.org/undici/-/undici-6.27.0.tgz",
|
||||||
|
|
@ -10815,6 +11151,15 @@
|
||||||
"node": ">= 0.4.0"
|
"node": ">= 0.4.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/utrie": {
|
||||||
|
"version": "1.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/utrie/-/utrie-1.0.2.tgz",
|
||||||
|
"integrity": "sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"base64-arraybuffer": "^1.0.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/uuid": {
|
"node_modules/uuid": {
|
||||||
"version": "7.0.3",
|
"version": "7.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/uuid/-/uuid-7.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/uuid/-/uuid-7.0.3.tgz",
|
||||||
|
|
|
||||||
11
package.json
11
package.json
|
|
@ -8,14 +8,19 @@
|
||||||
"@react-navigation/native-stack": "^7.17.6",
|
"@react-navigation/native-stack": "^7.17.6",
|
||||||
"expo": "~54.0.0",
|
"expo": "~54.0.0",
|
||||||
"expo-secure-store": "~15.0.8",
|
"expo-secure-store": "~15.0.8",
|
||||||
|
"expo-sharing": "~14.0.8",
|
||||||
"expo-sqlite": "~16.0.10",
|
"expo-sqlite": "~16.0.10",
|
||||||
"expo-status-bar": "~3.0.9",
|
"expo-status-bar": "~3.0.9",
|
||||||
|
"expo-updates": "~29.0.18",
|
||||||
"react": "19.1.0",
|
"react": "19.1.0",
|
||||||
|
"react-dom": "19.1.0",
|
||||||
"react-native": "0.81.5",
|
"react-native": "0.81.5",
|
||||||
"react-native-chart-kit": "^7.0.1",
|
"react-native-chart-kit": "^7.0.1",
|
||||||
"react-native-safe-area-context": "~5.6.0",
|
"react-native-safe-area-context": "~5.6.0",
|
||||||
"react-native-screens": "~4.16.0",
|
"react-native-screens": "~4.16.0",
|
||||||
"react-native-svg": "15.12.1"
|
"react-native-svg": "15.12.1",
|
||||||
|
"react-native-view-shot": "4.0.3",
|
||||||
|
"react-native-web": "^0.21.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/jest": "29.5.14",
|
"@types/jest": "29.5.14",
|
||||||
|
|
@ -25,8 +30,8 @@
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "expo start",
|
"start": "expo start",
|
||||||
"android": "expo start --android",
|
"android": "expo run:android",
|
||||||
"ios": "expo start --ios",
|
"ios": "expo run:ios",
|
||||||
"web": "expo start --web",
|
"web": "expo start --web",
|
||||||
"test": "jest"
|
"test": "jest"
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,11 @@
|
||||||
import React from 'react'
|
import React, { useRef } from 'react'
|
||||||
import { View, Text, StyleSheet } from 'react-native'
|
import { View, Text, StyleSheet, TouchableOpacity } from 'react-native'
|
||||||
|
import ViewShot from 'react-native-view-shot'
|
||||||
|
import * as Sharing from 'expo-sharing'
|
||||||
|
import { MaterialIcons } from '@expo/vector-icons'
|
||||||
import { Measurement } from '../types'
|
import { Measurement } from '../types'
|
||||||
|
|
||||||
type Props = { measurement: Measurement | null }
|
type Props = { measurements: Measurement[] }
|
||||||
|
|
||||||
const rows: { label: string; key: keyof Measurement; suffix: string }[] = [
|
const rows: { label: string; key: keyof Measurement; suffix: string }[] = [
|
||||||
{ label: 'Gewicht', key: 'weight', suffix: 'kg' },
|
{ label: 'Gewicht', key: 'weight', suffix: 'kg' },
|
||||||
|
|
@ -15,8 +18,10 @@ const rows: { label: string; key: keyof Measurement; suffix: string }[] = [
|
||||||
{ label: 'Stofwisseling', key: 'bmr', suffix: 'kcal' },
|
{ label: 'Stofwisseling', key: 'bmr', suffix: 'kcal' },
|
||||||
]
|
]
|
||||||
|
|
||||||
export default function LatestMeasurementCard({ measurement }: Props) {
|
export default function LatestMeasurementCard({ measurements }: Props) {
|
||||||
if (!measurement) {
|
const viewShotRef = useRef<ViewShot>(null)
|
||||||
|
|
||||||
|
if (measurements.length === 0) {
|
||||||
return (
|
return (
|
||||||
<View style={styles.card}>
|
<View style={styles.card}>
|
||||||
<Text style={styles.emptyText}>Nog geen metingen</Text>
|
<Text style={styles.emptyText}>Nog geen metingen</Text>
|
||||||
|
|
@ -24,31 +29,89 @@ export default function LatestMeasurementCard({ measurement }: Props) {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const latest = measurements[0]
|
||||||
|
const previous = measurements[1]
|
||||||
|
const diff = previous ? (latest.weight - previous.weight) : null
|
||||||
|
const fatMassDiff = previous ? (latest.fatMass - previous.fatMass) : null
|
||||||
|
const muscleMassDiff = previous ? (latest.muscleMass - previous.muscleMass) : null
|
||||||
|
|
||||||
|
let positiveComment: string | null = null
|
||||||
|
if (previous && diff !== null && diff >= 0) {
|
||||||
|
const parts: string[] = []
|
||||||
|
if (fatMassDiff !== null && fatMassDiff < 0) parts.push('Vetje naar beneden')
|
||||||
|
if (muscleMassDiff !== null && muscleMassDiff > 0) parts.push('💪')
|
||||||
|
if (parts.length > 0) positiveComment = parts.join(' ')
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleShare = async () => {
|
||||||
|
try {
|
||||||
|
const uri = await viewShotRef.current?.capture?.()
|
||||||
|
if (uri) {
|
||||||
|
await Sharing.shareAsync(uri, { mimeType: 'image/png' })
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={styles.card}>
|
<View style={styles.card}>
|
||||||
<Text style={styles.title}>Laatste meting</Text>
|
<View style={styles.header}>
|
||||||
<Text style={styles.date}>{new Date(measurement.measuredAt).toLocaleDateString('nl-NL')}</Text>
|
<View style={{ flex: 1 }} />
|
||||||
{rows.map(r => (
|
<TouchableOpacity onPress={handleShare} hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}>
|
||||||
<View key={r.key} style={styles.row}>
|
<MaterialIcons name="share" size={24} color="#2196F3" />
|
||||||
<Text style={styles.rowLabel}>{r.label}</Text>
|
</TouchableOpacity>
|
||||||
<Text style={styles.rowValue}>
|
</View>
|
||||||
{typeof measurement[r.key] === 'number'
|
|
||||||
? (measurement[r.key] as number).toFixed(1)
|
<ViewShot ref={viewShotRef} options={{ format: 'png', quality: 1 }}>
|
||||||
: measurement[r.key]}{' '}
|
<View style={styles.shotContent}>
|
||||||
{r.suffix}
|
{previous && (
|
||||||
</Text>
|
<View style={styles.previous}>
|
||||||
|
<Text style={styles.previousTitle}>Voorlaatste meting</Text>
|
||||||
|
<Text style={styles.previousDate}>
|
||||||
|
{new Date(previous.measuredAt).toLocaleDateString('nl-NL')} — {previous.weight.toFixed(1)} kg
|
||||||
|
</Text>
|
||||||
|
<Text style={styles.diffLabel}>
|
||||||
|
Verschil {diff !== null && (diff > 0 ? '+' : '')}{diff?.toFixed(1)} kg
|
||||||
|
</Text>
|
||||||
|
{positiveComment && (
|
||||||
|
<Text style={styles.positiveComment}>{positiveComment}</Text>
|
||||||
|
)}
|
||||||
|
<View style={styles.divider} />
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Text style={styles.title}>Laatste meting</Text>
|
||||||
|
<Text style={styles.date}>{new Date(latest.measuredAt).toLocaleDateString('nl-NL')}</Text>
|
||||||
|
{rows.map(r => (
|
||||||
|
<View key={r.key} style={styles.row}>
|
||||||
|
<Text style={styles.rowLabel}>{r.label}</Text>
|
||||||
|
<Text style={styles.rowValue}>
|
||||||
|
{typeof latest[r.key] === 'number'
|
||||||
|
? (latest[r.key] as number).toFixed(1)
|
||||||
|
: latest[r.key]}{' '}
|
||||||
|
{r.suffix}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
</View>
|
</View>
|
||||||
))}
|
</ViewShot>
|
||||||
</View>
|
</View>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
card: { backgroundColor: '#fff', borderRadius: 12, padding: 16, margin: 16, shadowColor: '#000', shadowOpacity: 0.1, shadowRadius: 8, elevation: 3 },
|
card: { backgroundColor: '#fff', borderRadius: 12, padding: 16, margin: 16, shadowColor: '#000', shadowOpacity: 0.1, shadowRadius: 8, elevation: 3 },
|
||||||
|
header: { flexDirection: 'row', alignItems: 'center', marginBottom: 8 },
|
||||||
|
shotContent: { backgroundColor: '#fff', padding: 16 },
|
||||||
title: { fontSize: 18, fontWeight: '700', marginBottom: 4 },
|
title: { fontSize: 18, fontWeight: '700', marginBottom: 4 },
|
||||||
date: { fontSize: 14, color: '#666', marginBottom: 12 },
|
date: { fontSize: 14, color: '#666', marginBottom: 12 },
|
||||||
row: { flexDirection: 'row', justifyContent: 'space-between', paddingVertical: 4 },
|
row: { flexDirection: 'row', paddingVertical: 4 },
|
||||||
rowLabel: { fontSize: 14, color: '#555' },
|
rowLabel: { fontSize: 14, color: '#555', flex: 1 },
|
||||||
rowValue: { fontSize: 14, fontWeight: '600' },
|
rowValue: { fontSize: 14, fontWeight: '600' },
|
||||||
emptyText: { fontSize: 16, color: '#999', textAlign: 'center', padding: 20 },
|
emptyText: { fontSize: 16, color: '#999', textAlign: 'center', padding: 20 },
|
||||||
|
previous: { marginBottom: 4 },
|
||||||
|
divider: { height: 1, backgroundColor: '#eee', marginVertical: 12 },
|
||||||
|
previousTitle: { fontSize: 18, fontWeight: '700', marginBottom: 4 },
|
||||||
|
previousDate: { fontSize: 14, color: '#666', marginBottom: 4 },
|
||||||
|
diffLabel: { fontSize: 16, fontWeight: '700', color: '#2196F3' },
|
||||||
|
positiveComment: { fontSize: 14, color: '#4CAF50', marginTop: 4 },
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ import { generateId } from '../utils/uuid'
|
||||||
type Props = {
|
type Props = {
|
||||||
onSubmit: (m: Measurement) => void
|
onSubmit: (m: Measurement) => void
|
||||||
initialValues?: Partial<Measurement>
|
initialValues?: Partial<Measurement>
|
||||||
|
isEdit?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
type FieldDef = {
|
type FieldDef = {
|
||||||
|
|
@ -29,13 +30,10 @@ const fields: FieldDef[] = [
|
||||||
{ key: 'bmr', label: 'Stofwisseling (kcal)', placeholder: '1650', keyboardType: 'number-pad' },
|
{ key: 'bmr', label: 'Stofwisseling (kcal)', placeholder: '1650', keyboardType: 'number-pad' },
|
||||||
]
|
]
|
||||||
|
|
||||||
export default function MeasurementForm({ onSubmit, initialValues }: Props) {
|
export default function MeasurementForm({ onSubmit, initialValues, isEdit }: Props) {
|
||||||
const insets = useSafeAreaInsets()
|
const insets = useSafeAreaInsets()
|
||||||
const [pickerOpen, setPickerOpen] = useState(false)
|
const [pickerOpen, setPickerOpen] = useState(false)
|
||||||
const [measurementDate, setMeasurementDate] = useState<Date>(() => {
|
const [measurementDate, setMeasurementDate] = useState<Date>(new Date())
|
||||||
if (initialValues?.measuredAt) return new Date(initialValues.measuredAt)
|
|
||||||
return new Date()
|
|
||||||
})
|
|
||||||
const [values, setValues] = useState<Record<string, string>>(() => {
|
const [values, setValues] = useState<Record<string, string>>(() => {
|
||||||
const init: Record<string, string> = {}
|
const init: Record<string, string> = {}
|
||||||
for (const f of fields) {
|
for (const f of fields) {
|
||||||
|
|
@ -47,9 +45,18 @@ export default function MeasurementForm({ onSubmit, initialValues }: Props) {
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
const weight = parseFloat(values.weight) || 0
|
function toFloat(s: string): number {
|
||||||
const bodyFatPct = parseFloat(values.bodyFatPercentage) || 0
|
return parseFloat(s.replace(',', '.')) || 0
|
||||||
const musclePct = parseFloat(values.musclePercentage) || 0
|
}
|
||||||
|
|
||||||
|
function toInt(s: string): number {
|
||||||
|
return parseInt(s.replace(',', '.'), 10) || 0
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
const weight = toFloat(values.weight)
|
||||||
|
const bodyFatPct = toFloat(values.bodyFatPercentage)
|
||||||
|
const musclePct = toFloat(values.musclePercentage)
|
||||||
|
|
||||||
const computed = useMemo(() => ({
|
const computed = useMemo(() => ({
|
||||||
fatMass: weight > 0 && bodyFatPct > 0 ? ((weight * bodyFatPct) / 100).toFixed(1) : '',
|
fatMass: weight > 0 && bodyFatPct > 0 ? ((weight * bodyFatPct) / 100).toFixed(1) : '',
|
||||||
|
|
@ -59,19 +66,18 @@ export default function MeasurementForm({ onSubmit, initialValues }: Props) {
|
||||||
const handleSubmit = () => {
|
const handleSubmit = () => {
|
||||||
const now = new Date().toISOString()
|
const now = new Date().toISOString()
|
||||||
const m: Measurement = {
|
const m: Measurement = {
|
||||||
id: generateId(),
|
id: isEdit && initialValues?.id ? initialValues.id : generateId(),
|
||||||
weight,
|
weight,
|
||||||
bmi: parseFloat(values.bmi) || 0,
|
bmi: toFloat(values.bmi),
|
||||||
bodyFatPercentage: bodyFatPct,
|
bodyFatPercentage: bodyFatPct,
|
||||||
fatMass: parseFloat(computed.fatMass) || 0,
|
fatMass: toFloat(computed.fatMass),
|
||||||
musclePercentage: musclePct,
|
musclePercentage: musclePct,
|
||||||
muscleMass: parseFloat(computed.muscleMass) || 0,
|
muscleMass: toFloat(computed.muscleMass),
|
||||||
visceralFat: parseInt(values.visceralFat, 10) || 0,
|
visceralFat: toInt(values.visceralFat),
|
||||||
bmr: parseFloat(values.bmr) || 0,
|
bmr: toFloat(values.bmr),
|
||||||
measuredAt: measurementDate.toISOString(),
|
measuredAt: measurementDate.toISOString(),
|
||||||
createdAt: now,
|
createdAt: isEdit && initialValues?.createdAt ? initialValues.createdAt : now,
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
syncStatus: 'local',
|
|
||||||
}
|
}
|
||||||
onSubmit(m)
|
onSubmit(m)
|
||||||
}
|
}
|
||||||
|
|
@ -140,7 +146,7 @@ export default function MeasurementForm({ onSubmit, initialValues }: Props) {
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
<TouchableOpacity style={[styles.button, { marginBottom: insets.bottom + 16 }]} onPress={handleSubmit}>
|
<TouchableOpacity style={[styles.button, { marginBottom: insets.bottom + 48 }]} onPress={handleSubmit}>
|
||||||
<Text style={styles.buttonText}>Opslaan</Text>
|
<Text style={styles.buttonText}>Opslaan</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</KeyboardAvoidingView>
|
</KeyboardAvoidingView>
|
||||||
|
|
@ -149,7 +155,7 @@ export default function MeasurementForm({ onSubmit, initialValues }: Props) {
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
container: { flex: 1, backgroundColor: '#f5f5f5' },
|
container: { flex: 1, backgroundColor: '#f5f5f5' },
|
||||||
scroll: { padding: 16, paddingBottom: 0 },
|
scroll: { padding: 16 },
|
||||||
field: { marginBottom: 12 },
|
field: { marginBottom: 12 },
|
||||||
label: { fontSize: 14, fontWeight: '600', color: '#333', marginBottom: 4 },
|
label: { fontSize: 14, fontWeight: '600', color: '#333', marginBottom: 4 },
|
||||||
input: { backgroundColor: '#fff', borderRadius: 8, padding: 12, fontSize: 16, borderWidth: 1, borderColor: '#ddd' },
|
input: { backgroundColor: '#fff', borderRadius: 8, padding: 12, fontSize: 16, borderWidth: 1, borderColor: '#ddd' },
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import React from 'react'
|
import React, { useRef, useState } from 'react'
|
||||||
import { View, Text, StyleSheet, Dimensions } from 'react-native'
|
import { View, Text, StyleSheet, Dimensions, ScrollView, NativeSyntheticEvent, NativeScrollEvent } from 'react-native'
|
||||||
import { LineChart } from 'react-native-chart-kit'
|
import { LineChart } from 'react-native-chart-kit'
|
||||||
import { Measurement } from '../types'
|
import { Measurement } from '../types'
|
||||||
|
|
||||||
|
|
@ -11,6 +11,9 @@ const COLORS = {
|
||||||
muscleMass: '#2196F3',
|
muscleMass: '#2196F3',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const screenWidth = Dimensions.get('window').width
|
||||||
|
const chartWidth = screenWidth - 32
|
||||||
|
|
||||||
function SingleChart({
|
function SingleChart({
|
||||||
title,
|
title,
|
||||||
data,
|
data,
|
||||||
|
|
@ -29,7 +32,7 @@ function SingleChart({
|
||||||
<Text style={styles.chartTitle}>{title}</Text>
|
<Text style={styles.chartTitle}>{title}</Text>
|
||||||
<LineChart
|
<LineChart
|
||||||
data={{ labels, datasets: [{ data, color: () => color, strokeWidth: 2 }] }}
|
data={{ labels, datasets: [{ data, color: () => color, strokeWidth: 2 }] }}
|
||||||
width={Dimensions.get('window').width - 32}
|
width={chartWidth}
|
||||||
height={180}
|
height={180}
|
||||||
yAxisSuffix={unit}
|
yAxisSuffix={unit}
|
||||||
chartConfig={{
|
chartConfig={{
|
||||||
|
|
@ -57,7 +60,63 @@ function hexToRgb(hex: string): string {
|
||||||
return `${r}, ${g}, ${b}`
|
return `${r}, ${g}, ${b}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function filterData(sorted: Measurement[], days: number, lastDate: Date) {
|
||||||
|
const cutoff = new Date(lastDate)
|
||||||
|
cutoff.setDate(cutoff.getDate() - days)
|
||||||
|
const filtered = sorted.filter(m => new Date(m.measuredAt) >= cutoff)
|
||||||
|
return filtered.length >= 2 ? filtered : sorted
|
||||||
|
}
|
||||||
|
|
||||||
|
function prepareLabels(dataPoints: Measurement[]) {
|
||||||
|
const labels = dataPoints.map(m => {
|
||||||
|
const d = new Date(m.measuredAt)
|
||||||
|
return `${d.getDate()}/${d.getMonth() + 1}`
|
||||||
|
})
|
||||||
|
const labelStep = labels.length > 8 ? Math.ceil(labels.length / 8) : 1
|
||||||
|
return labels.map((l, i) => (i % labelStep === 0 ? l : ''))
|
||||||
|
}
|
||||||
|
|
||||||
|
function ChartSet({ dataPoints, title }: { dataPoints: Measurement[]; title: string }) {
|
||||||
|
const labels = prepareLabels(dataPoints)
|
||||||
|
const fatMassPoints = dataPoints.filter(m => m.bodyFatPercentage > 0)
|
||||||
|
const muscleMassPoints = dataPoints.filter(m => m.musclePercentage > 0)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.page}>
|
||||||
|
<Text style={styles.title}>{title}</Text>
|
||||||
|
<SingleChart
|
||||||
|
title="Gewicht"
|
||||||
|
data={dataPoints.map(m => m.weight)}
|
||||||
|
labels={labels}
|
||||||
|
color={COLORS.weight}
|
||||||
|
unit=" kg"
|
||||||
|
/>
|
||||||
|
{fatMassPoints.length >= 2 && (
|
||||||
|
<SingleChart
|
||||||
|
title="Vetmassa"
|
||||||
|
data={fatMassPoints.map(m => m.fatMass)}
|
||||||
|
labels={prepareLabels(fatMassPoints)}
|
||||||
|
color={COLORS.fatMass}
|
||||||
|
unit=" kg"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{muscleMassPoints.length >= 2 && (
|
||||||
|
<SingleChart
|
||||||
|
title="Spiermassa"
|
||||||
|
data={muscleMassPoints.map(m => m.muscleMass)}
|
||||||
|
labels={prepareLabels(muscleMassPoints)}
|
||||||
|
color={COLORS.muscleMass}
|
||||||
|
unit=" kg"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export default function WeightChart({ measurements }: Props) {
|
export default function WeightChart({ measurements }: Props) {
|
||||||
|
const scrollRef = useRef<ScrollView>(null)
|
||||||
|
const [page, setPage] = useState(0)
|
||||||
|
|
||||||
if (measurements.length < 2) {
|
if (measurements.length < 2) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
@ -66,52 +125,38 @@ export default function WeightChart({ measurements }: Props) {
|
||||||
(a, b) => new Date(a.measuredAt).getTime() - new Date(b.measuredAt).getTime()
|
(a, b) => new Date(a.measuredAt).getTime() - new Date(b.measuredAt).getTime()
|
||||||
)
|
)
|
||||||
|
|
||||||
const cutoff = new Date(sorted[sorted.length - 1].measuredAt)
|
const lastDate = new Date(sorted[sorted.length - 1].measuredAt)
|
||||||
cutoff.setDate(cutoff.getDate() - 30)
|
|
||||||
const filtered = sorted.filter(m => new Date(m.measuredAt) >= cutoff)
|
|
||||||
const dataPoints = filtered.length >= 2 ? filtered : sorted
|
|
||||||
|
|
||||||
const labels = dataPoints.map(m => {
|
const data30 = filterData(sorted, 30, lastDate)
|
||||||
const d = new Date(m.measuredAt)
|
const data365 = filterData(sorted, 365, lastDate)
|
||||||
return `${d.getDate()}/${d.getMonth() + 1}`
|
|
||||||
})
|
|
||||||
|
|
||||||
const labelStep = labels.length > 8 ? Math.ceil(labels.length / 8) : 1
|
const onScroll = (e: NativeSyntheticEvent<NativeScrollEvent>) => {
|
||||||
const sparseLabels = labels.map((l, i) => (i % labelStep === 0 ? l : ''))
|
const offset = e.nativeEvent.contentOffset.x
|
||||||
|
const newPage = Math.round(offset / screenWidth)
|
||||||
|
if (newPage !== page) {
|
||||||
|
setPage(newPage)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={styles.container}>
|
<View style={styles.container}>
|
||||||
<Text style={styles.title}>Voortgang (30 dagen)</Text>
|
<ScrollView
|
||||||
|
ref={scrollRef}
|
||||||
<SingleChart
|
horizontal
|
||||||
title="Gewicht"
|
pagingEnabled
|
||||||
data={dataPoints.map(m => m.weight)}
|
showsHorizontalScrollIndicator={false}
|
||||||
labels={sparseLabels}
|
onMomentumScrollEnd={onScroll}
|
||||||
color={COLORS.weight}
|
>
|
||||||
unit=" kg"
|
<ChartSet dataPoints={data30} title="Voortgang (30 dagen)" />
|
||||||
/>
|
<ChartSet dataPoints={data365} title="Voortgang (365 dagen)" />
|
||||||
|
</ScrollView>
|
||||||
<SingleChart
|
|
||||||
title="Vetmassa"
|
|
||||||
data={dataPoints.map(m => m.fatMass)}
|
|
||||||
labels={sparseLabels}
|
|
||||||
color={COLORS.fatMass}
|
|
||||||
unit=" kg"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<SingleChart
|
|
||||||
title="Spiermassa"
|
|
||||||
data={dataPoints.map(m => m.muscleMass)}
|
|
||||||
labels={sparseLabels}
|
|
||||||
color={COLORS.muscleMass}
|
|
||||||
unit=" kg"
|
|
||||||
/>
|
|
||||||
</View>
|
</View>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
container: { marginHorizontal: 16, marginBottom: 16 },
|
container: { marginBottom: 16 },
|
||||||
|
page: { width: screenWidth, paddingHorizontal: 16 },
|
||||||
title: { fontSize: 18, fontWeight: '700', marginBottom: 8 },
|
title: { fontSize: 18, fontWeight: '700', marginBottom: 8 },
|
||||||
chartBlock: { marginBottom: 12 },
|
chartBlock: { marginBottom: 12 },
|
||||||
chartTitle: { fontSize: 13, fontWeight: '600', color: '#555', marginBottom: 4, marginLeft: 4 },
|
chartTitle: { fontSize: 13, fontWeight: '600', color: '#555', marginBottom: 4, marginLeft: 4 },
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,8 @@
|
||||||
import * as SQLite from 'expo-sqlite'
|
import * as SQLite from 'expo-sqlite'
|
||||||
import { Measurement, SyncStatus } from '../types'
|
import { Measurement } from '../types'
|
||||||
|
|
||||||
let db: SQLite.SQLiteDatabase | null = null
|
let db: SQLite.SQLiteDatabase | null = null
|
||||||
|
let dbReady = false
|
||||||
|
|
||||||
export async function initDatabase(): Promise<void> {
|
export async function initDatabase(): Promise<void> {
|
||||||
if (db) return
|
if (db) return
|
||||||
|
|
@ -20,20 +21,39 @@ export async function initDatabase(): Promise<void> {
|
||||||
measuredAt TEXT NOT NULL,
|
measuredAt TEXT NOT NULL,
|
||||||
createdAt TEXT NOT NULL,
|
createdAt TEXT NOT NULL,
|
||||||
updatedAt TEXT NOT NULL,
|
updatedAt TEXT NOT NULL,
|
||||||
syncStatus TEXT NOT NULL DEFAULT 'local'
|
syncStatus TEXT NOT NULL DEFAULT 'pending',
|
||||||
|
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> {
|
export async function insertMeasurement(m: Measurement): Promise<void> {
|
||||||
await db!.runAsync(
|
await db!.runAsync(
|
||||||
`INSERT INTO measurements (id, weight, bmi, bodyFatPercentage, fatMass, musclePercentage, muscleMass, visceralFat, bmr, measuredAt, createdAt, updatedAt, syncStatus)
|
`INSERT INTO measurements (id, weight, bmi, bodyFatPercentage, fatMass, musclePercentage, muscleMass, visceralFat, bmr, measuredAt, createdAt, updatedAt, deletedAt)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
[m.id, m.weight, m.bmi, m.bodyFatPercentage, m.fatMass, m.musclePercentage, m.muscleMass, m.visceralFat, m.bmr, m.measuredAt, m.createdAt, m.updatedAt, m.syncStatus]
|
[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[]> {
|
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>(
|
const rows = await db!.getAllAsync<any>(
|
||||||
'SELECT * FROM measurements ORDER BY measuredAt DESC'
|
'SELECT * FROM measurements ORDER BY measuredAt DESC'
|
||||||
)
|
)
|
||||||
|
|
@ -42,42 +62,71 @@ export async function getAllMeasurements(): Promise<Measurement[]> {
|
||||||
|
|
||||||
export async function getLatestMeasurement(): Promise<Measurement | null> {
|
export async function getLatestMeasurement(): Promise<Measurement | null> {
|
||||||
const rows = await db!.getAllAsync<any>(
|
const rows = await db!.getAllAsync<any>(
|
||||||
'SELECT * FROM measurements ORDER BY measuredAt DESC LIMIT 1'
|
'SELECT * FROM measurements WHERE deletedAt IS NULL ORDER BY measuredAt DESC LIMIT 1'
|
||||||
)
|
)
|
||||||
return rows.length > 0 ? (rows[0] as Measurement) : null
|
return rows.length > 0 ? (rows[0] as Measurement) : null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getLatestMeasurements(count: number): Promise<Measurement[]> {
|
||||||
|
const rows = await db!.getAllAsync<any>(
|
||||||
|
'SELECT * FROM measurements WHERE deletedAt IS NULL ORDER BY measuredAt DESC LIMIT ?',
|
||||||
|
[count]
|
||||||
|
)
|
||||||
|
return rows as Measurement[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getMeasurementsSince(date: string): Promise<Measurement[]> {
|
||||||
|
const rows = await db!.getAllAsync<any>(
|
||||||
|
'SELECT * FROM measurements WHERE measuredAt >= ? AND deletedAt IS NULL ORDER BY measuredAt DESC',
|
||||||
|
[date]
|
||||||
|
)
|
||||||
|
return rows as Measurement[]
|
||||||
|
}
|
||||||
|
|
||||||
export async function getMeasurementByDate(date: string): Promise<Measurement | null> {
|
export async function getMeasurementByDate(date: string): Promise<Measurement | null> {
|
||||||
const day = date.slice(0, 10)
|
const day = date.slice(0, 10)
|
||||||
const rows = await db!.getAllAsync<any>(
|
const rows = await db!.getAllAsync<any>(
|
||||||
"SELECT * FROM measurements WHERE measuredAt LIKE ? LIMIT 1",
|
"SELECT * FROM measurements WHERE measuredAt LIKE ? AND deletedAt IS NULL LIMIT 1",
|
||||||
[`${day}%`]
|
[`${day}%`]
|
||||||
)
|
)
|
||||||
return rows.length > 0 ? (rows[0] as Measurement) : null
|
return rows.length > 0 ? (rows[0] as Measurement) : null
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateMeasurement(id: string, syncStatus: SyncStatus): Promise<void> {
|
|
||||||
await db!.runAsync(
|
|
||||||
'UPDATE measurements SET syncStatus = ?, updatedAt = ? WHERE id = ?',
|
|
||||||
[syncStatus, new Date().toISOString(), id]
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function upsertMeasurement(m: Measurement): Promise<void> {
|
export async function upsertMeasurement(m: Measurement): Promise<void> {
|
||||||
await db!.runAsync(
|
await db!.runAsync(
|
||||||
`INSERT OR REPLACE INTO measurements (id, weight, bmi, bodyFatPercentage, fatMass, musclePercentage, muscleMass, visceralFat, bmr, measuredAt, createdAt, updatedAt, syncStatus)
|
`INSERT OR REPLACE INTO measurements (id, weight, bmi, bodyFatPercentage, fatMass, musclePercentage, muscleMass, visceralFat, bmr, measuredAt, createdAt, updatedAt, deletedAt)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
[m.id, m.weight, m.bmi, m.bodyFatPercentage, m.fatMass, m.musclePercentage, m.muscleMass, m.visceralFat, m.bmr, m.measuredAt, m.createdAt, m.updatedAt, m.syncStatus]
|
[m.id, m.weight, m.bmi, m.bodyFatPercentage, m.fatMass, m.musclePercentage, m.muscleMass, m.visceralFat, m.bmr, m.measuredAt, m.createdAt, m.updatedAt, m.deletedAt ?? null]
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getUnsyncedMeasurements(): Promise<Measurement[]> {
|
export async function getMeasurementsUpdatedSince(since: string): Promise<Measurement[]> {
|
||||||
const rows = await db!.getAllAsync<any>(
|
const rows = await db!.getAllAsync<any>(
|
||||||
"SELECT * FROM measurements WHERE syncStatus = 'local' ORDER BY updatedAt ASC"
|
'SELECT * FROM measurements WHERE updatedAt > ? ORDER BY updatedAt ASC',
|
||||||
|
[since]
|
||||||
)
|
)
|
||||||
return rows as Measurement[]
|
return rows as Measurement[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteMeasurement(id: string): Promise<void> {
|
export async function setDeletedAt(id: string): Promise<void> {
|
||||||
|
const now = new Date().toISOString()
|
||||||
|
await db!.runAsync(
|
||||||
|
'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])
|
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 { View, ScrollView, TouchableOpacity, Text, StyleSheet } from 'react-native'
|
||||||
import { useFocusEffect } from '@react-navigation/native'
|
import { useFocusEffect } from '@react-navigation/native'
|
||||||
import { useSafeAreaInsets } from 'react-native-safe-area-context'
|
import { useSafeAreaInsets } from 'react-native-safe-area-context'
|
||||||
import { initDatabase, getAllMeasurements, getLatestMeasurement } from '../database/db'
|
import { getCached, getVersion, refreshCache } from '../store/measurementStore'
|
||||||
|
import { initDatabase, getLatestMeasurements } from '../database/db'
|
||||||
import { Measurement } from '../types'
|
import { Measurement } from '../types'
|
||||||
import LatestMeasurementCard from '../components/LatestMeasurementCard'
|
import LatestMeasurementCard from '../components/LatestMeasurementCard'
|
||||||
import WeightChart from '../components/WeightChart'
|
import WeightChart from '../components/WeightChart'
|
||||||
|
|
@ -11,22 +12,76 @@ type Props = { navigation: any }
|
||||||
|
|
||||||
export default function DashboardScreen({ navigation }: Props) {
|
export default function DashboardScreen({ navigation }: Props) {
|
||||||
const insets = useSafeAreaInsets()
|
const insets = useSafeAreaInsets()
|
||||||
const [latest, setLatest] = useState<Measurement | null>(null)
|
const [latestTwo, setLatestTwo] = useState<Measurement[]>([])
|
||||||
const [all, setAll] = useState<Measurement[]>([])
|
const [all, setAll] = useState<Measurement[]>([])
|
||||||
|
const [chartKey, setChartKey] = useState(0)
|
||||||
|
const seenVersion = useRef(0)
|
||||||
|
|
||||||
useFocusEffect(
|
useFocusEffect(
|
||||||
useCallback(() => {
|
useCallback(() => {
|
||||||
|
let active = true
|
||||||
const load = async () => {
|
const load = async () => {
|
||||||
await initDatabase()
|
await initDatabase()
|
||||||
const allM = await getAllMeasurements()
|
|
||||||
setAll(allM)
|
const two = await getLatestMeasurements(2)
|
||||||
const latestM = await getLatestMeasurement()
|
if (!active) return
|
||||||
setLatest(latestM)
|
setLatestTwo(two)
|
||||||
|
|
||||||
|
await refreshCache()
|
||||||
|
if (!active) return
|
||||||
|
const v = getVersion()
|
||||||
|
if (v !== seenVersion.current) {
|
||||||
|
setAll(getCached())
|
||||||
|
setChartKey(v)
|
||||||
|
seenVersion.current = v
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { syncAll } = await import('../sync/syncService')
|
||||||
|
const result = await syncAll()
|
||||||
|
if (!active) return
|
||||||
|
if (result.deleted > 0 || result.pulled > 0) {
|
||||||
|
await refreshCache()
|
||||||
|
const v2 = getVersion()
|
||||||
|
setAll(getCached())
|
||||||
|
setChartKey(v2)
|
||||||
|
seenVersion.current = v2
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
console.warn('Sync failed')
|
||||||
|
}
|
||||||
}
|
}
|
||||||
load()
|
load()
|
||||||
|
return () => { active = false }
|
||||||
}, [])
|
}, [])
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if (all.length === 0 && latestTwo.length === 0) {
|
||||||
|
return (
|
||||||
|
<View style={styles.outer}>
|
||||||
|
<View style={styles.navBar}>
|
||||||
|
<TouchableOpacity onPress={() => navigation.navigate('History')}>
|
||||||
|
<Text style={styles.navLink}>Historie</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
<TouchableOpacity onPress={() => navigation.navigate('Settings')}>
|
||||||
|
<Text style={styles.navLink}>Instellingen</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
<View style={styles.loadingContainer}>
|
||||||
|
<Text style={styles.loading}>Laden...</Text>
|
||||||
|
</View>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[styles.fab, { marginBottom: insets.bottom + 16 }]}
|
||||||
|
onPress={() => navigation.navigate('NewMeasurement')}
|
||||||
|
>
|
||||||
|
<Text style={styles.fabText}>+ Nieuwe meting</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const display = all.length > 0 ? all : latestTwo
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={styles.outer}>
|
<View style={styles.outer}>
|
||||||
<View style={styles.navBar}>
|
<View style={styles.navBar}>
|
||||||
|
|
@ -38,8 +93,8 @@ export default function DashboardScreen({ navigation }: Props) {
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
<ScrollView style={styles.container}>
|
<ScrollView style={styles.container}>
|
||||||
<LatestMeasurementCard measurement={latest} />
|
<LatestMeasurementCard measurements={display} />
|
||||||
<WeightChart measurements={all} />
|
{all.length >= 2 && <WeightChart key={chartKey} measurements={all} />}
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
|
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
|
|
@ -59,4 +114,6 @@ const styles = StyleSheet.create({
|
||||||
navLink: { fontSize: 16, color: '#2196F3', fontWeight: '600' },
|
navLink: { fontSize: 16, color: '#2196F3', fontWeight: '600' },
|
||||||
fab: { backgroundColor: '#4CAF50', padding: 16, borderRadius: 8, margin: 16, alignItems: 'center' },
|
fab: { backgroundColor: '#4CAF50', padding: 16, borderRadius: 8, margin: 16, alignItems: 'center' },
|
||||||
fabText: { color: '#fff', fontSize: 16, fontWeight: '700' },
|
fabText: { color: '#fff', fontSize: 16, fontWeight: '700' },
|
||||||
|
loadingContainer: { flex: 1, justifyContent: 'center', alignItems: 'center' },
|
||||||
|
loading: { fontSize: 16, color: '#999' },
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,8 @@
|
||||||
import React, { useCallback, useState } from 'react'
|
import React, { useCallback, useState } from 'react'
|
||||||
import { View, FlatList, StyleSheet, Text, RefreshControl, Alert } from 'react-native'
|
import { View, FlatList, StyleSheet, Text, RefreshControl, TouchableOpacity, Alert } from 'react-native'
|
||||||
import { useFocusEffect } from '@react-navigation/native'
|
import { useFocusEffect } from '@react-navigation/native'
|
||||||
import { initDatabase, getAllMeasurements, deleteMeasurement } from '../database/db'
|
import { initDatabase, getAllMeasurements, setDeletedAt, updateSyncStatus } from '../database/db'
|
||||||
|
import { refreshCache } from '../store/measurementStore'
|
||||||
import { Measurement } from '../types'
|
import { Measurement } from '../types'
|
||||||
import MeasurementRow from '../components/MeasurementRow'
|
import MeasurementRow from '../components/MeasurementRow'
|
||||||
|
|
||||||
|
|
@ -61,11 +62,9 @@ export default function HistoryScreen({ navigation }: Props) {
|
||||||
style: 'destructive',
|
style: 'destructive',
|
||||||
onPress: async () => {
|
onPress: async () => {
|
||||||
await initDatabase()
|
await initDatabase()
|
||||||
await deleteMeasurement(m.id)
|
await setDeletedAt(m.id)
|
||||||
try {
|
await updateSyncStatus(m.id, 'pending-delete')
|
||||||
const { syncAll } = await import('../sync/syncService')
|
await refreshCache()
|
||||||
await syncAll()
|
|
||||||
} catch {}
|
|
||||||
load()
|
load()
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
@ -80,7 +79,10 @@ export default function HistoryScreen({ navigation }: Props) {
|
||||||
data={measurements}
|
data={measurements}
|
||||||
keyExtractor={item => item.id}
|
keyExtractor={item => item.id}
|
||||||
renderItem={({ item }) => (
|
renderItem={({ item }) => (
|
||||||
<MeasurementRow measurement={item} onPress={() => handlePress(item)} />
|
<MeasurementRow
|
||||||
|
measurement={item}
|
||||||
|
onPress={() => navigation.navigate('MeasurementDetail', { measurementId: item.id })}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
contentContainerStyle={measurements.length === 0 ? styles.empty : undefined}
|
contentContainerStyle={measurements.length === 0 ? styles.empty : undefined}
|
||||||
ListEmptyComponent={<Text style={styles.emptyText}>Nog geen metingen</Text>}
|
ListEmptyComponent={<Text style={styles.emptyText}>Nog geen metingen</Text>}
|
||||||
|
|
|
||||||
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,100 +1,90 @@
|
||||||
import React, { useCallback, useEffect, useState } from 'react'
|
import React, { useCallback, useState } from 'react'
|
||||||
import { View, Text, Alert } from 'react-native'
|
import { View, Text, Alert } from 'react-native'
|
||||||
import { useFocusEffect } from '@react-navigation/native'
|
import { useFocusEffect } from '@react-navigation/native'
|
||||||
import MeasurementForm from '../components/MeasurementForm'
|
import MeasurementForm from '../components/MeasurementForm'
|
||||||
import { Measurement } from '../types'
|
import { Measurement } from '../types'
|
||||||
import {
|
import { initDatabase, insertMeasurement, upsertMeasurement, getLatestMeasurement, getMeasurementByDate } from '../database/db'
|
||||||
initDatabase,
|
import { refreshCache } from '../store/measurementStore'
|
||||||
insertMeasurement,
|
import { showSuccess, showError } from '../utils/toast'
|
||||||
getLatestMeasurement,
|
|
||||||
getMeasurementByDate,
|
|
||||||
upsertMeasurement,
|
|
||||||
} from '../database/db'
|
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
navigation: any
|
navigation: any
|
||||||
route: any
|
route: {
|
||||||
|
params: {
|
||||||
|
measurement?: Measurement
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function NewMeasurementScreen({ navigation, route }: Props) {
|
export default function NewMeasurementScreen({ navigation, route }: Props) {
|
||||||
const editing = route.params?.measurement as Measurement | undefined
|
const editMeasurement = route.params?.measurement
|
||||||
|
const isEdit = !!editMeasurement
|
||||||
const [latest, setLatest] = useState<Partial<Measurement> | undefined>(undefined)
|
const [latest, setLatest] = useState<Partial<Measurement> | undefined>(undefined)
|
||||||
const [loaded, setLoaded] = useState(false)
|
const [loaded, setLoaded] = useState(false)
|
||||||
|
|
||||||
|
React.useLayoutEffect(() => {
|
||||||
|
navigation.setOptions({ title: isEdit ? 'Meting wijzigen' : 'Nieuwe meting' })
|
||||||
|
}, [navigation, isEdit])
|
||||||
|
|
||||||
useFocusEffect(
|
useFocusEffect(
|
||||||
useCallback(() => {
|
useCallback(() => {
|
||||||
|
if (isEdit) {
|
||||||
|
setLatest(editMeasurement)
|
||||||
|
setLoaded(true)
|
||||||
|
return
|
||||||
|
}
|
||||||
let cancelled = false
|
let cancelled = false
|
||||||
const load = async () => {
|
const load = async () => {
|
||||||
await initDatabase()
|
await initDatabase()
|
||||||
if (editing) {
|
const m = await getLatestMeasurement()
|
||||||
if (!cancelled) {
|
if (!cancelled) {
|
||||||
setLatest(editing)
|
setLatest(m || {})
|
||||||
setLoaded(true)
|
setLoaded(true)
|
||||||
}
|
|
||||||
} else {
|
|
||||||
const m = await getLatestMeasurement()
|
|
||||||
if (!cancelled) {
|
|
||||||
setLatest(m || {})
|
|
||||||
setLoaded(true)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
load()
|
load()
|
||||||
return () => { cancelled = true }
|
return () => { cancelled = true }
|
||||||
}, [editing])
|
}, [isEdit, editMeasurement])
|
||||||
)
|
)
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (editing) {
|
|
||||||
navigation.setOptions({ title: 'Meting wijzigen' })
|
|
||||||
}
|
|
||||||
}, [editing, navigation])
|
|
||||||
|
|
||||||
const handleSubmit = async (m: Measurement) => {
|
const handleSubmit = async (m: Measurement) => {
|
||||||
await initDatabase()
|
await initDatabase()
|
||||||
if (editing) {
|
if (isEdit) {
|
||||||
m.id = editing.id
|
|
||||||
m.createdAt = editing.createdAt
|
|
||||||
await upsertMeasurement(m)
|
|
||||||
doSync()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const existing = await getMeasurementByDate(m.measuredAt)
|
|
||||||
if (existing) {
|
|
||||||
Alert.alert(
|
|
||||||
'Dubbele datum',
|
|
||||||
`Er bestaat al een meting van ${new Date(m.measuredAt).toLocaleDateString('nl-NL')}. Overschrijven?`,
|
|
||||||
[
|
|
||||||
{ text: 'Annuleren', style: 'cancel' },
|
|
||||||
{ text: 'Overschrijven', style: 'destructive', onPress: () => doSave(m, existing.id) },
|
|
||||||
]
|
|
||||||
)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
doSave(m)
|
|
||||||
}
|
|
||||||
|
|
||||||
const doSave = async (m: Measurement, existingId?: string) => {
|
|
||||||
if (existingId) {
|
|
||||||
m.id = existingId
|
|
||||||
await upsertMeasurement(m)
|
await upsertMeasurement(m)
|
||||||
} else {
|
} else {
|
||||||
|
const existing = await getMeasurementByDate(m.measuredAt)
|
||||||
|
if (existing) {
|
||||||
|
Alert.alert(
|
||||||
|
'Dubbele datum',
|
||||||
|
`Er bestaat al een meting van ${new Date(m.measuredAt).toLocaleDateString('nl-NL')}. Overschrijven?`,
|
||||||
|
[
|
||||||
|
{ text: 'Annuleren', style: 'cancel' },
|
||||||
|
{ text: 'Overschrijven', style: 'destructive', onPress: () => {
|
||||||
|
m.id = existing.id
|
||||||
|
upsertMeasurement(m).then(() => doSave(m))
|
||||||
|
}},
|
||||||
|
]
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
await insertMeasurement(m)
|
await insertMeasurement(m)
|
||||||
}
|
}
|
||||||
doSync()
|
await doSave(m)
|
||||||
}
|
}
|
||||||
|
|
||||||
const doSync = async () => {
|
const doSave = async (m: Measurement) => {
|
||||||
|
await refreshCache()
|
||||||
|
navigation.goBack()
|
||||||
|
uploadInBackground(m)
|
||||||
|
}
|
||||||
|
|
||||||
|
const uploadInBackground = async (m: Measurement) => {
|
||||||
try {
|
try {
|
||||||
const { syncAll } = await import('../sync/syncService')
|
const { uploadMeasurement } = await import('../sync/syncService')
|
||||||
await syncAll()
|
await uploadMeasurement(m)
|
||||||
Alert.alert('Opgeslagen & gesynct', 'Meting is opgeslagen en gesynchroniseerd', [
|
showSuccess('Opgeslagen & gesynct')
|
||||||
{ text: 'OK', onPress: () => navigation.goBack() },
|
|
||||||
])
|
|
||||||
} catch {
|
} catch {
|
||||||
Alert.alert('Opgeslagen', 'Meting is opgeslagen (sync mislukt)', [
|
showError('Opgeslagen (sync mislukt)')
|
||||||
{ text: 'OK', onPress: () => navigation.goBack() },
|
|
||||||
])
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -110,6 +100,7 @@ export default function NewMeasurementScreen({ navigation, route }: Props) {
|
||||||
<MeasurementForm
|
<MeasurementForm
|
||||||
onSubmit={handleSubmit}
|
onSubmit={handleSubmit}
|
||||||
initialValues={latest as Partial<Measurement> | undefined}
|
initialValues={latest as Partial<Measurement> | undefined}
|
||||||
|
isEdit={isEdit}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,11 @@
|
||||||
import React, { useEffect, useState } from 'react'
|
import React, { useEffect, useState } from 'react'
|
||||||
import { View, Text, TextInput, TouchableOpacity, StyleSheet, Alert, ScrollView } from 'react-native'
|
import { View, Text, TextInput, TouchableOpacity, StyleSheet, Alert, ScrollView } from 'react-native'
|
||||||
import * as SecureStore from 'expo-secure-store'
|
import * as SecureStore from 'expo-secure-store'
|
||||||
import { WebDAVConfig } from '../types'
|
import { ApiConfig } from '../types'
|
||||||
|
|
||||||
const CONFIG_KEY = 'weegschaal_webdav_config'
|
const CONFIG_KEY = 'weegschaal_api_config'
|
||||||
|
|
||||||
async function loadConfig(): Promise<WebDAVConfig | null> {
|
async function loadConfig(): Promise<ApiConfig | null> {
|
||||||
try {
|
try {
|
||||||
const json = await SecureStore.getItemAsync(CONFIG_KEY)
|
const json = await SecureStore.getItemAsync(CONFIG_KEY)
|
||||||
return json ? JSON.parse(json) : null
|
return json ? JSON.parse(json) : null
|
||||||
|
|
@ -14,7 +14,7 @@ async function loadConfig(): Promise<WebDAVConfig | null> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function saveConfig(config: WebDAVConfig): Promise<void> {
|
export async function saveConfig(config: ApiConfig): Promise<void> {
|
||||||
await SecureStore.setItemAsync(CONFIG_KEY, JSON.stringify(config))
|
await SecureStore.setItemAsync(CONFIG_KEY, JSON.stringify(config))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -22,50 +22,43 @@ async function deleteConfig(): Promise<void> {
|
||||||
await SecureStore.deleteItemAsync(CONFIG_KEY)
|
await SecureStore.deleteItemAsync(CONFIG_KEY)
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getWebDAVConfig(): Promise<WebDAVConfig | null> {
|
export async function getApiConfig(): Promise<ApiConfig | null> {
|
||||||
return loadConfig()
|
return loadConfig()
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function SettingsScreen() {
|
export default function SettingsScreen() {
|
||||||
const [url, setUrl] = useState('https://nextcloud.eddydevink.nl/remote.php/dav/files/eddy')
|
const [url, setUrl] = useState('https://weegschaal.eddydevink.nl')
|
||||||
const [username, setUsername] = useState('')
|
const [token, setToken] = useState('')
|
||||||
const [password, setPassword] = useState('')
|
const [showToken, setShowToken] = useState(false)
|
||||||
const [showPassword, setShowPassword] = useState(false)
|
|
||||||
const [syncing, setSyncing] = useState(false)
|
const [syncing, setSyncing] = useState(false)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadConfig().then(c => {
|
loadConfig().then(c => {
|
||||||
if (c) {
|
if (c) {
|
||||||
setUrl(c.url)
|
setUrl(c.url)
|
||||||
setUsername(c.username)
|
setToken(c.token)
|
||||||
setPassword(c.password)
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
await saveConfig({ url, username, password })
|
await saveConfig({ url, token })
|
||||||
Alert.alert('Opgeslagen', 'WebDAV instellingen zijn opgeslagen')
|
Alert.alert('Opgeslagen', 'API instellingen zijn opgeslagen')
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleClear = () => {
|
const handleClear = () => {
|
||||||
Alert.alert(
|
Alert.alert('Instellingen wissen?', 'API configuratie definitief verwijderen?', [
|
||||||
'Instellingen wissen?',
|
{ text: 'Annuleren', style: 'cancel' },
|
||||||
'WebDAV configuratie definitief verwijderen? Je kunt deze later opnieuw invoeren.',
|
{
|
||||||
[
|
text: 'Ja, wissen',
|
||||||
{ text: 'Annuleren', style: 'cancel' },
|
style: 'destructive',
|
||||||
{
|
onPress: async () => {
|
||||||
text: 'Ja, wissen',
|
await deleteConfig()
|
||||||
style: 'destructive',
|
setUrl('')
|
||||||
onPress: async () => {
|
setToken('')
|
||||||
await deleteConfig()
|
|
||||||
setUrl('')
|
|
||||||
setUsername('')
|
|
||||||
setPassword('')
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
]
|
},
|
||||||
)
|
])
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleSync = async () => {
|
const handleSync = async () => {
|
||||||
|
|
@ -73,7 +66,11 @@ export default function SettingsScreen() {
|
||||||
try {
|
try {
|
||||||
const { syncAll } = await import('../sync/syncService')
|
const { syncAll } = await import('../sync/syncService')
|
||||||
const result = await syncAll()
|
const result = await syncAll()
|
||||||
Alert.alert('Sync voltooid', `Lokaal: ${result.local}, WebDAV: ${result.remote}, samengevoegd: ${result.merged}`)
|
let msg = `Opgehaald: ${result.pulled}, gepusht: ${result.pushed}, verwijderd: ${result.deleted}`
|
||||||
|
if (result.errors.length > 0) {
|
||||||
|
msg += `\n\nFouten: ${result.errors.join(', ')}`
|
||||||
|
}
|
||||||
|
Alert.alert('Sync voltooid', msg)
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
Alert.alert('Sync fout', e?.message ?? 'Onbekende fout')
|
Alert.alert('Sync fout', e?.message ?? 'Onbekende fout')
|
||||||
} finally {
|
} finally {
|
||||||
|
|
@ -83,38 +80,30 @@ export default function SettingsScreen() {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ScrollView style={styles.container} contentContainerStyle={styles.content}>
|
<ScrollView style={styles.container} contentContainerStyle={styles.content}>
|
||||||
<Text style={styles.sectionTitle}>WebDAV (Nextcloud)</Text>
|
<Text style={styles.sectionTitle}>REST API</Text>
|
||||||
|
|
||||||
<Text style={styles.label}>URL</Text>
|
<Text style={styles.label}>API URL</Text>
|
||||||
<TextInput
|
<TextInput
|
||||||
style={styles.input}
|
style={styles.input}
|
||||||
value={url}
|
value={url}
|
||||||
onChangeText={setUrl}
|
onChangeText={setUrl}
|
||||||
placeholder="https://nextcloud.eddydevink.nl/remote.php/dav/files/gebruiker/"
|
placeholder="https://weegschaal.eddydevink.nl"
|
||||||
autoCapitalize="none"
|
autoCapitalize="none"
|
||||||
autoCorrect={false}
|
autoCorrect={false}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Text style={styles.label}>Gebruikersnaam</Text>
|
<Text style={styles.label}>Token</Text>
|
||||||
<TextInput
|
|
||||||
style={styles.input}
|
|
||||||
value={username}
|
|
||||||
onChangeText={setUsername}
|
|
||||||
autoCapitalize="none"
|
|
||||||
autoCorrect={false}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Text style={styles.label}>Wachtwoord</Text>
|
|
||||||
<View style={styles.passwordRow}>
|
<View style={styles.passwordRow}>
|
||||||
<TextInput
|
<TextInput
|
||||||
style={styles.passwordInput}
|
style={styles.passwordInput}
|
||||||
value={password}
|
value={token}
|
||||||
onChangeText={setPassword}
|
onChangeText={setToken}
|
||||||
secureTextEntry={!showPassword}
|
secureTextEntry={!showToken}
|
||||||
autoCapitalize="none"
|
autoCapitalize="none"
|
||||||
|
autoCorrect={false}
|
||||||
/>
|
/>
|
||||||
<TouchableOpacity style={styles.eyeButton} onPress={() => setShowPassword(!showPassword)}>
|
<TouchableOpacity style={styles.eyeButton} onPress={() => setShowToken(!showToken)}>
|
||||||
<Text style={styles.eyeIcon}>{showPassword ? '👁' : '👁🗨'}</Text>
|
<Text style={styles.eyeIcon}>{showToken ? '👁' : '🔒'}</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
|
|
|
||||||
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 { Measurement } from '../types'
|
||||||
import { initDatabase, getAllMeasurements, upsertMeasurement } from '../database/db'
|
import {
|
||||||
import { getWebDAVConfig } from '../screens/SettingsScreen'
|
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 }
|
type SyncResult = {
|
||||||
|
pushed: number
|
||||||
|
deleted: number
|
||||||
|
pulled: number
|
||||||
|
errors: string[]
|
||||||
|
}
|
||||||
|
|
||||||
export function mergeMeasurements(local: Measurement[], remote: Measurement[]): Measurement[] {
|
export function mergeMeasurements(local: Measurement[], remote: any[]): Measurement[] {
|
||||||
const map = new Map<string, Measurement>()
|
const localMap = new Map(local.map(m => [m.id, m]))
|
||||||
|
const resultMap = new Map(local.map(m => [m.id, m]))
|
||||||
|
|
||||||
for (const m of local) {
|
for (const r of remote) {
|
||||||
map.set(m.id, m)
|
const localRec = localMap.get(r.id)
|
||||||
}
|
const isDeleted = !!(r as any).deleted
|
||||||
|
const localPending =
|
||||||
|
localRec?.syncStatus === 'pending' ||
|
||||||
|
localRec?.syncStatus === 'pending-delete'
|
||||||
|
|
||||||
for (const m of remote) {
|
if (isDeleted) {
|
||||||
const existing = map.get(m.id)
|
if (!localRec || localPending) continue
|
||||||
if (!existing || new Date(m.updatedAt) > new Date(existing.updatedAt)) {
|
if (new Date(r.updatedAt) > new Date(localRec.updatedAt)) {
|
||||||
map.set(m.id, { ...m, syncStatus: 'synced' })
|
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[]> {
|
export async function uploadMeasurement(m: Measurement): Promise<void> {
|
||||||
const url = `${config.url.replace(/\/+$/, '')}/${WEBDAV_PATH}`
|
const { getApiConfig } = await import('../screens/SettingsScreen')
|
||||||
const response = await fetch(url, {
|
const config = await getApiConfig()
|
||||||
headers: {
|
if (!config) throw new Error('Geen API configuratie. Stel deze eerst in in Instellingen.')
|
||||||
Authorization: 'Basic ' + btoa(`${config.username}:${config.password}`),
|
const api = createApi(config)
|
||||||
},
|
await api.createMeasurement(m)
|
||||||
})
|
await updateSyncStatus(m.id, 'synced')
|
||||||
|
|
||||||
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 syncAll(): Promise<SyncResult> {
|
export async function syncAll(): Promise<SyncResult> {
|
||||||
const config = await getWebDAVConfig()
|
const { getApiConfig } = await import('../screens/SettingsScreen')
|
||||||
if (!config) {
|
const config = await getApiConfig()
|
||||||
throw new Error('Geen WebDAV configuratie. Stel deze eerst in in Instellingen.')
|
if (!config) throw new Error('Geen API configuratie. Stel deze eerst in in Instellingen.')
|
||||||
}
|
|
||||||
|
|
||||||
|
const api = createApi(config)
|
||||||
await initDatabase()
|
await initDatabase()
|
||||||
|
|
||||||
const local = await getAllMeasurements()
|
const errors: string[] = []
|
||||||
const remote = await fetchRemote(config)
|
let pushed = 0
|
||||||
|
let deleted = 0
|
||||||
|
let pulled = 0
|
||||||
|
|
||||||
const merged = mergeMeasurements(local, remote)
|
// === PUSH phase — outbox first ===
|
||||||
|
const pending = await getPendingRecords()
|
||||||
|
|
||||||
for (const m of merged) {
|
for (const m of pending) {
|
||||||
await upsertMeasurement(m)
|
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')
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await uploadRemote(config, merged)
|
// === PULL phase — fetch remote changes ===
|
||||||
|
const lastSyncAt = await SecureStore.getItemAsync(LAST_SYNC_KEY)
|
||||||
|
const remote = await api.getMeasurements(lastSyncAt || undefined)
|
||||||
|
|
||||||
return {
|
if (remote.length > 0) {
|
||||||
local: local.length,
|
const local = await getAllMeasurementsForSync()
|
||||||
remote: remote.length,
|
const merged = mergeMeasurements(local, remote)
|
||||||
merged: merged.length,
|
|
||||||
|
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 SecureStore.setItemAsync(LAST_SYNC_KEY, new Date().toISOString())
|
||||||
|
|
||||||
|
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 = {
|
export type Measurement = {
|
||||||
id: string
|
id: string
|
||||||
weight: number
|
weight: number
|
||||||
|
|
@ -13,11 +11,11 @@ export type Measurement = {
|
||||||
measuredAt: string
|
measuredAt: string
|
||||||
createdAt: string
|
createdAt: string
|
||||||
updatedAt: string
|
updatedAt: string
|
||||||
syncStatus: SyncStatus
|
syncStatus?: string
|
||||||
|
deletedAt?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export type WebDAVConfig = {
|
export type ApiConfig = {
|
||||||
url: string
|
url: string
|
||||||
username: string
|
token: string
|
||||||
password: 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',
|
measuredAt: '2024-01-01T08:00:00.000Z',
|
||||||
createdAt: '2024-01-01T08:00:00.000Z',
|
createdAt: '2024-01-01T08:00:00.000Z',
|
||||||
updatedAt: '2024-01-01T08:00:00.000Z',
|
updatedAt: '2024-01-01T08:00:00.000Z',
|
||||||
syncStatus: 'local',
|
|
||||||
}
|
}
|
||||||
|
|
||||||
describe('database', () => {
|
describe('database', () => {
|
||||||
|
|
@ -27,7 +26,7 @@ describe('database', () => {
|
||||||
let insertMeasurement: any
|
let insertMeasurement: any
|
||||||
let getAllMeasurements: any
|
let getAllMeasurements: any
|
||||||
let getLatestMeasurement: any
|
let getLatestMeasurement: any
|
||||||
let updateMeasurement: any
|
let setDeletedAt: any
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
jest.resetModules()
|
jest.resetModules()
|
||||||
|
|
@ -46,7 +45,7 @@ describe('database', () => {
|
||||||
insertMeasurement = mod.insertMeasurement
|
insertMeasurement = mod.insertMeasurement
|
||||||
getAllMeasurements = mod.getAllMeasurements
|
getAllMeasurements = mod.getAllMeasurements
|
||||||
getLatestMeasurement = mod.getLatestMeasurement
|
getLatestMeasurement = mod.getLatestMeasurement
|
||||||
updateMeasurement = mod.updateMeasurement
|
setDeletedAt = mod.setDeletedAt
|
||||||
})
|
})
|
||||||
|
|
||||||
it('initDatabase creates the measurements table', async () => {
|
it('initDatabase creates the measurements table', async () => {
|
||||||
|
|
@ -82,13 +81,15 @@ describe('database', () => {
|
||||||
expect(result!.id).toBe(mockMeasurement.id)
|
expect(result!.id).toBe(mockMeasurement.id)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('updateMeasurement updates syncStatus', async () => {
|
it('setDeletedAt sets both deletedAt and updatedAt timestamps', async () => {
|
||||||
db.runAsync.mockResolvedValue(undefined)
|
db.runAsync.mockResolvedValue(undefined)
|
||||||
await initDatabase()
|
await initDatabase()
|
||||||
await updateMeasurement(mockMeasurement.id, 'synced')
|
await setDeletedAt(mockMeasurement.id)
|
||||||
expect(db.runAsync).toHaveBeenCalledWith(
|
expect(db.runAsync).toHaveBeenCalledWith(
|
||||||
expect.stringContaining('UPDATE measurements'),
|
expect.stringContaining('UPDATE measurements SET deletedAt'),
|
||||||
expect.arrayContaining(['synced', mockMeasurement.id])
|
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 { mergeMeasurements } from '../src/sync/syncService'
|
||||||
import { Measurement } from '../src/types'
|
import { Measurement } from '../src/types'
|
||||||
|
|
||||||
jest.mock('expo-sqlite', () => ({
|
jest.mock('expo-sqlite', () => ({ openDatabaseAsync: jest.fn() }))
|
||||||
openDatabaseAsync: jest.fn(),
|
|
||||||
}))
|
|
||||||
|
|
||||||
const base: Measurement = {
|
const make = (overrides: Partial<Measurement> = {}): Measurement => ({
|
||||||
id: 'id-1',
|
id: 'id-1', weight: 75, bmi: 22, bodyFatPercentage: 15, fatMass: 10,
|
||||||
weight: 75,
|
musclePercentage: 40, muscleMass: 30, visceralFat: 8, bmr: 1600,
|
||||||
bmi: 22,
|
measuredAt: '2024-06-01T08:00:00.000Z', createdAt: '2024-06-01T08:00:00.000Z',
|
||||||
bodyFatPercentage: 15,
|
updatedAt: '2024-06-01T08:00:00.000Z', syncStatus: 'synced', ...overrides,
|
||||||
fatMass: 10,
|
})
|
||||||
musclePercentage: 40,
|
|
||||||
muscleMass: 30,
|
|
||||||
visceralFat: 8,
|
|
||||||
bmr: 1600,
|
|
||||||
measuredAt: '2024-06-01T08:00:00.000Z',
|
|
||||||
createdAt: '2024-06-01T08:00:00.000Z',
|
|
||||||
updatedAt: '2024-06-01T08:00:00.000Z',
|
|
||||||
syncStatus: 'local',
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('mergeMeasurements', () => {
|
describe('mergeMeasurements', () => {
|
||||||
it('returns empty array when both are empty', () => {
|
it('both empty returns empty', () => {
|
||||||
expect(mergeMeasurements([], [])).toEqual([])
|
expect(mergeMeasurements([], [])).toEqual([])
|
||||||
})
|
})
|
||||||
|
|
||||||
it('keeps local items not on remote', () => {
|
it('local only kept', () => {
|
||||||
const result = mergeMeasurements([base], [])
|
const r = mergeMeasurements([make()], [])
|
||||||
expect(result).toHaveLength(1)
|
expect(r).toHaveLength(1)
|
||||||
expect(result[0].id).toBe('id-1')
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it('keeps remote items not on local', () => {
|
it('remote only kept', () => {
|
||||||
const remote = { ...base, syncStatus: 'synced' as const }
|
const r = mergeMeasurements([], [make({ id: 'r1' })])
|
||||||
const result = mergeMeasurements([], [remote])
|
expect(r).toHaveLength(1)
|
||||||
expect(result).toHaveLength(1)
|
expect(r[0].id).toBe('r1')
|
||||||
expect(result[0].id).toBe('id-1')
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it('picks the newer version when both have same id', () => {
|
it('last-writer-wins: remote newer', () => {
|
||||||
const local = { ...base, weight: 75, updatedAt: '2024-06-02T08:00:00.000Z' }
|
const local = make({ weight: 75, updatedAt: '2024-06-02T08:00:00.000Z' })
|
||||||
const remote = { ...base, weight: 76, updatedAt: '2024-06-03T08:00:00.000Z' }
|
const remote = make({ weight: 76, updatedAt: '2024-06-03T08:00:00.000Z' })
|
||||||
const result = mergeMeasurements([local], [remote])
|
const r = mergeMeasurements([local], [remote])
|
||||||
expect(result).toHaveLength(1)
|
expect(r[0].weight).toBe(76)
|
||||||
expect(result[0].weight).toBe(76)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it('picks local when local is newer', () => {
|
it('last-writer-wins: local newer', () => {
|
||||||
const local = { ...base, weight: 77, updatedAt: '2024-06-04T08:00:00.000Z' }
|
const local = make({ weight: 77, updatedAt: '2024-06-04T08:00:00.000Z' })
|
||||||
const remote = { ...base, weight: 76, updatedAt: '2024-06-03T08:00:00.000Z' }
|
const remote = make({ weight: 76, updatedAt: '2024-06-03T08:00:00.000Z' })
|
||||||
const result = mergeMeasurements([local], [remote])
|
const r = mergeMeasurements([local], [remote])
|
||||||
expect(result).toHaveLength(1)
|
expect(r[0].weight).toBe(77)
|
||||||
expect(result[0].weight).toBe(77)
|
})
|
||||||
|
|
||||||
|
it('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