weegschaal-app/docs/superpowers/plans/2026-06-27-weegschaal-app-implementation.md
Eddy de Vink f66034136d Initial commit
Generated by create-expo-app 4.0.0.
2026-06-27 22:39:13 +02:00

1186 lines
34 KiB
Markdown

# Weegschaal App — Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Build a React Native (Expo) app to manually record weight scale measurements and sync them to Nextcloud via WebDAV.
**Architecture:** Expo managed workflow, local SQLite via expo-sqlite, React Navigation for screens, custom sync layer fetching a single JSON file over WebDAV.
**Tech Stack:** Expo SDK 52+, expo-sqlite, @react-navigation/native-stack, react-native-chart-kit, expo-secure-store, Jest
---
### Task 1: Project scaffold
**Files:**
- Create: `App.tsx`
- Create: `app.json`
- Create: `package.json`
- Create: `tsconfig.json`
- Create: `babel.config.js`
- Create: `src/types.ts`
- Create: `src/utils/uuid.ts`
- [ ] **Step 1: Initialize Expo project**
```bash
npx create-expo-app@latest weegschaal-app --template blank-typescript
```
- [ ] **Step 2: Install core dependencies**
```bash
npx expo install expo-sqlite @react-navigation/native @react-navigation/native-stack react-native-screens react-native-safe-area-context expo-secure-store
npx expo install react-native-chart-kit react-native-svg
npm install --save-dev jest @types/jest
```
- [ ] **Step 3: Create folder structure and types file**
```bash
mkdir -p src/database src/sync src/screens src/components src/utils tests
```
```typescript
// src/types.ts
export type SyncStatus = 'local' | 'synced'
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
syncStatus: SyncStatus
}
export type WebDAVConfig = {
url: string
username: string
password: string
}
```
- [ ] **Step 4: Create UUID utility**
```typescript
// src/utils/uuid.ts
export function generateId(): string {
const chars = '0123456789abcdef'
const segments = [8, 4, 4, 4, 12]
return segments
.map(len => {
let s = ''
for (let i = 0; i < len; i++) {
s += chars[Math.floor(Math.random() * 16)]
}
return s
})
.join('-')
}
```
- [ ] **Step 5: Add Jest config to package.json**
```json
{
"jest": {
"preset": "jest-expo"
}
}
```
Run: `npm install --save-dev jest-expo`
- [ ] **Step 6: Commit**
```bash
git init
git add -A
git commit -m "chore: scaffold Expo project with types and utils"
```
---
### Task 2: Database layer — types & schema
**Files:**
- Create: `src/database/db.ts`
- Create: `tests/database.test.ts`
- [ ] **Step 1: Write the database tests**
```typescript
// tests/database.test.ts
import { initDatabase, insertMeasurement, getAllMeasurements, getLatestMeasurement, updateMeasurement } from '../src/database/db'
import { Measurement } from '../src/types'
import * as SQLite from 'expo-sqlite'
jest.mock('expo-sqlite', () => ({
openDatabaseAsync: jest.fn(),
}))
const mockMeasurement: Measurement = {
id: '550e8400-e29b-41d4-a716-446655440000',
weight: 75.5,
bmi: 22.3,
bodyFatPercentage: 15.2,
fatMass: 11.5,
musclePercentage: 40.1,
muscleMass: 30.3,
visceralFat: 8,
bmr: 1650,
measuredAt: '2024-01-01T08:00:00.000Z',
createdAt: '2024-01-01T08:00:00.000Z',
updatedAt: '2024-01-01T08:00:00.000Z',
syncStatus: 'local',
}
describe('database', () => {
let db: any
beforeEach(() => {
db = {
execAsync: jest.fn(),
runAsync: jest.fn(),
getAllAsync: jest.fn(),
}
;(SQLite.openDatabaseAsync as jest.Mock).mockResolvedValue(db)
})
it('initDatabase creates the measurements table', async () => {
await initDatabase()
expect(db.execAsync).toHaveBeenCalledWith(
expect.stringContaining('CREATE TABLE IF NOT EXISTS measurements')
)
})
it('insertMeasurement inserts a row', async () => {
db.runAsync.mockResolvedValue(undefined)
await initDatabase()
await insertMeasurement(mockMeasurement)
expect(db.runAsync).toHaveBeenCalledWith(
expect.stringContaining('INSERT INTO measurements'),
expect.arrayContaining([mockMeasurement.id])
)
})
it('getAllMetrics returns all measurements ordered by measuredAt desc', async () => {
db.getAllAsync.mockResolvedValue([{ ...mockMeasurement }])
await initDatabase()
const result = await getAllMeasurements()
expect(result).toHaveLength(1)
expect(result[0].id).toBe(mockMeasurement.id)
})
it('getLatestMeasurement returns the most recent', async () => {
db.getAllAsync.mockResolvedValue([{ ...mockMeasurement }])
await initDatabase()
const result = await getLatestMeasurement()
expect(result).not.toBeNull()
expect(result!.id).toBe(mockMeasurement.id)
})
it('updateMeasurement updates syncStatus', async () => {
db.runAsync.mockResolvedValue(undefined)
await initDatabase()
await updateMeasurement(mockMeasurement.id, 'synced')
expect(db.runAsync).toHaveBeenCalledWith(
expect.stringContaining('UPDATE measurements'),
expect.arrayContaining(['synced', mockMeasurement.id])
)
})
})
```
- [ ] **Step 2: Run test to verify it fails**
Run: `npx jest tests/database.test.ts --no-cache`
Expected: FAIL — cannot find module `../src/database/db`
- [ ] **Step 3: Write minimal implementation**
```typescript
// src/database/db.ts
import * as SQLite from 'expo-sqlite'
import { Measurement, SyncStatus } from '../types'
let db: SQLite.SQLiteDatabase
export async function initDatabase(): Promise<void> {
db = await SQLite.openDatabaseAsync('weegschaal.db')
await db.execAsync(`
CREATE TABLE IF NOT EXISTS measurements (
id TEXT PRIMARY KEY,
weight REAL NOT NULL,
bmi REAL NOT NULL,
bodyFatPercentage REAL NOT NULL,
fatMass REAL NOT NULL,
musclePercentage REAL NOT NULL,
muscleMass REAL NOT NULL,
visceralFat INTEGER NOT NULL,
bmr REAL NOT NULL,
measuredAt TEXT NOT NULL,
createdAt TEXT NOT NULL,
updatedAt TEXT NOT NULL,
syncStatus TEXT NOT NULL DEFAULT 'local'
);
`)
}
export async function insertMeasurement(m: Measurement): Promise<void> {
await db.runAsync(
`INSERT INTO measurements (id, weight, bmi, bodyFatPercentage, fatMass, musclePercentage, muscleMass, visceralFat, bmr, measuredAt, createdAt, updatedAt, syncStatus)
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]
)
}
export async function getAllMeasurements(): Promise<Measurement[]> {
const rows = await db.getAllAsync<any>(
'SELECT * FROM measurements ORDER BY measuredAt DESC'
)
return rows as Measurement[]
}
export async function getLatestMeasurement(): Promise<Measurement | null> {
const rows = await db.getAllAsync<any>(
'SELECT * FROM measurements ORDER BY measuredAt DESC LIMIT 1'
)
return rows.length > 0 ? (rows[0] as Measurement) : null
}
export async function updateMeasurement(id: string, syncStatus: SyncStatus): Promise<void> {
await db.runAsync(
'UPDATE measurements SET syncStatus = ?, updatedAt = ? WHERE id = ?',
[syncStatus, new Date().toISOString(), id]
)
}
export async function upsertMeasurement(m: Measurement): Promise<void> {
await db.runAsync(
`INSERT OR REPLACE INTO measurements (id, weight, bmi, bodyFatPercentage, fatMass, musclePercentage, muscleMass, visceralFat, bmr, measuredAt, createdAt, updatedAt, syncStatus)
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]
)
}
export async function getUnsyncedMeasurements(): Promise<Measurement[]> {
const rows = await db.getAllAsync<any>(
"SELECT * FROM measurements WHERE syncStatus = 'local' ORDER BY updatedAt ASC"
)
return rows as Measurement[]
}
```
- [ ] **Step 4: Run test to verify it passes**
Run: `npx jest tests/database.test.ts --no-cache`
Expected: PASS
- [ ] **Step 5: Commit**
```bash
git add -A
git commit -m "feat: add database layer with SQLite CRUD"
```
---
### Task 3: MeasurementForm component & NewMeasurement screen
**Files:**
- Create: `src/components/MeasurementForm.tsx`
- Create: `src/screens/NewMeasurementScreen.tsx`
- [ ] **Step 1: Write MeasurementForm component**
```typescript
// src/components/MeasurementForm.tsx
import React, { useState } from 'react'
import { View, Text, TextInput, TouchableOpacity, StyleSheet, KeyboardAvoidingView, Platform, ScrollView } from 'react-native'
import { Measurement } from '../types'
import { generateId } from '../utils/uuid'
type Props = {
onSubmit: (m: Measurement) => void
initialValues?: Partial<Measurement>
}
const fields: { key: keyof Measurement; label: string; placeholder: string; keyboardType: 'decimal-pad' | 'number-pad' }[] = [
{ key: 'weight', label: 'Gewicht (kg)', placeholder: '75.5', keyboardType: 'decimal-pad' },
{ key: 'bmi', label: 'BMI', placeholder: '22.3', keyboardType: 'decimal-pad' },
{ key: 'bodyFatPercentage', label: 'Vetpercentage (%)', placeholder: '15.2', keyboardType: 'decimal-pad' },
{ key: 'fatMass', label: 'Vetmassa (kg)', placeholder: '11.5', keyboardType: 'decimal-pad' },
{ key: 'musclePercentage', label: 'Spierpercentage (%)', placeholder: '40.1', keyboardType: 'decimal-pad' },
{ key: 'muscleMass', label: 'Spiermassa (kg)', placeholder: '30.3', keyboardType: 'decimal-pad' },
{ key: 'visceralFat', label: 'Buikvet (index)', placeholder: '8', keyboardType: 'number-pad' },
{ key: 'bmr', label: 'Stofwisseling (kcal)', placeholder: '1650', keyboardType: 'number-pad' },
]
export default function MeasurementForm({ onSubmit, initialValues }: Props) {
const [values, setValues] = useState<Record<string, string>>(() => {
const init: Record<string, string> = {}
for (const f of fields) {
init[f.key] = initialValues?.[f.key]?.toString() ?? ''
}
return init
})
const handleSubmit = () => {
const now = new Date().toISOString()
const m: Measurement = {
id: generateId(),
weight: parseFloat(values.weight) || 0,
bmi: parseFloat(values.bmi) || 0,
bodyFatPercentage: parseFloat(values.bodyFatPercentage) || 0,
fatMass: parseFloat(values.fatMass) || 0,
musclePercentage: parseFloat(values.musclePercentage) || 0,
muscleMass: parseFloat(values.muscleMass) || 0,
visceralFat: parseInt(values.visceralFat, 10) || 0,
bmr: parseFloat(values.bmr) || 0,
measuredAt: now,
createdAt: now,
updatedAt: now,
syncStatus: 'local',
}
onSubmit(m)
}
const setField = (key: string, value: string) => {
setValues(prev => ({ ...prev, [key]: value }))
}
return (
<KeyboardAvoidingView behavior={Platform.OS === 'ios' ? 'padding' : undefined} style={styles.container}>
<ScrollView contentContainerStyle={styles.scroll}>
{fields.map(f => (
<View key={f.key} style={styles.field}>
<Text style={styles.label}>{f.label}</Text>
<TextInput
style={styles.input}
keyboardType={f.keyboardType}
placeholder={f.placeholder}
value={values[f.key]}
onChangeText={v => setField(f.key, v)}
placeholderTextColor="#999"
/>
</View>
))}
<TouchableOpacity style={styles.button} onPress={handleSubmit}>
<Text style={styles.buttonText}>Opslaan</Text>
</TouchableOpacity>
</ScrollView>
</KeyboardAvoidingView>
)
}
const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: '#f5f5f5' },
scroll: { padding: 16 },
field: { marginBottom: 12 },
label: { fontSize: 14, fontWeight: '600', color: '#333', marginBottom: 4 },
input: { backgroundColor: '#fff', borderRadius: 8, padding: 12, fontSize: 16, borderWidth: 1, borderColor: '#ddd' },
button: { backgroundColor: '#4CAF50', padding: 16, borderRadius: 8, alignItems: 'center', marginTop: 8 },
buttonText: { color: '#fff', fontSize: 16, fontWeight: '700' },
})
```
- [ ] **Step 2: Write NewMeasurementScreen**
```typescript
// src/screens/NewMeasurementScreen.tsx
import React from 'react'
import { Alert } from 'react-native'
import MeasurementForm from '../components/MeasurementForm'
import { Measurement } from '../types'
import { initDatabase, insertMeasurement } from '../database/db'
type Props = {
navigation: any
}
export default function NewMeasurementScreen({ navigation }: Props) {
const handleSubmit = async (m: Measurement) => {
await initDatabase()
await insertMeasurement(m)
Alert.alert('Opgeslagen', 'Meting is opgeslagen', [
{ text: 'OK', onPress: () => navigation.goBack() },
])
}
return <MeasurementForm onSubmit={handleSubmit} />
}
```
- [ ] **Step 3: Commit**
```bash
git add -A
git commit -m "feat: add MeasurementForm and NewMeasurementScreen"
```
---
### Task 4: Dashboard screen (card + chart)
**Files:**
- Create: `src/components/LatestMeasurementCard.tsx`
- Create: `src/components/WeightChart.tsx`
- Create: `src/screens/DashboardScreen.tsx`
- [ ] **Step 1: Write LatestMeasurementCard**
```typescript
// src/components/LatestMeasurementCard.tsx
import React from 'react'
import { View, Text, StyleSheet } from 'react-native'
import { Measurement } from '../types'
type Props = { measurement: Measurement | null }
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 LatestMeasurementCard({ measurement }: Props) {
if (!measurement) {
return (
<View style={styles.card}>
<Text style={styles.emptyText}>Nog geen metingen</Text>
</View>
)
}
return (
<View style={styles.card}>
<Text style={styles.title}>Laatste meting</Text>
<Text style={styles.date}>{new Date(measurement.measuredAt).toLocaleDateString('nl-NL')}</Text>
{rows.map(r => (
<View key={r.key} style={styles.row}>
<Text style={styles.rowLabel}>{r.label}</Text>
<Text style={styles.rowValue}>
{typeof measurement[r.key] === 'number'
? (measurement[r.key] as number).toFixed(1)
: measurement[r.key]}{' '}
{r.suffix}
</Text>
</View>
))}
</View>
)
}
const styles = StyleSheet.create({
card: { backgroundColor: '#fff', borderRadius: 12, padding: 16, margin: 16, shadowColor: '#000', shadowOpacity: 0.1, shadowRadius: 8, elevation: 3 },
title: { fontSize: 18, fontWeight: '700', marginBottom: 4 },
date: { fontSize: 14, color: '#666', marginBottom: 12 },
row: { flexDirection: 'row', justifyContent: 'space-between', paddingVertical: 4 },
rowLabel: { fontSize: 14, color: '#555' },
rowValue: { fontSize: 14, fontWeight: '600' },
emptyText: { fontSize: 16, color: '#999', textAlign: 'center', padding: 20 },
})
```
- [ ] **Step 2: Write WeightChart**
```typescript
// src/components/WeightChart.tsx
import React from 'react'
import { View, Text, StyleSheet, Dimensions } from 'react-native'
import { LineChart } from 'react-native-chart-kit'
import { Measurement } from '../types'
type Props = { measurements: Measurement[] }
export default function WeightChart({ measurements }: Props) {
if (measurements.length < 2) {
return null
}
const sorted = [...measurements].sort(
(a, b) => new Date(a.measuredAt).getTime() - new Date(b.measuredAt).getTime()
)
const labels = sorted.map(m => {
const d = new Date(m.measuredAt)
return `${d.getDate()}/${d.getMonth() + 1}`
})
const data = sorted.map(m => m.weight)
return (
<View style={styles.container}>
<Text style={styles.title}>Gewichtsgrafiek</Text>
<LineChart
data={{
labels: labels.length > 10 ? labels.filter((_, i) => i % Math.ceil(labels.length / 10) === 0) : labels,
datasets: [{ data }],
}}
width={Dimensions.get('window').width - 32}
height={220}
yAxisSuffix=" kg"
chartConfig={{
backgroundColor: '#ffffff',
backgroundGradientFrom: '#ffffff',
backgroundGradientTo: '#ffffff',
decimalCount: 1,
color: (opacity = 1) => `rgba(76, 175, 80, ${opacity})`,
labelColor: () => '#666',
}}
bezier
style={styles.chart}
/>
</View>
)
}
const styles = StyleSheet.create({
container: { marginHorizontal: 16, marginBottom: 16 },
title: { fontSize: 18, fontWeight: '700', marginBottom: 8 },
chart: { borderRadius: 12 },
})
```
- [ ] **Step 3: Write DashboardScreen**
```typescript
// src/screens/DashboardScreen.tsx
import React, { useCallback, useState } from 'react'
import { View, ScrollView, TouchableOpacity, Text, StyleSheet } from 'react-native'
import { useFocusEffect } from '@react-navigation/native'
import { initDatabase, getAllMeasurements, getLatestMeasurement } from '../database/db'
import { Measurement } from '../types'
import LatestMeasurementCard from '../components/LatestMeasurementCard'
import WeightChart from '../components/WeightChart'
type Props = { navigation: any }
export default function DashboardScreen({ navigation }: Props) {
const [latest, setLatest] = useState<Measurement | null>(null)
const [all, setAll] = useState<Measurement[]>([])
useFocusEffect(
useCallback(() => {
const load = async () => {
await initDatabase()
const allM = await getAllMeasurements()
setAll(allM)
const latestM = await getLatestMeasurement()
setLatest(latestM)
}
load()
}, [])
)
return (
<ScrollView style={styles.container}>
<LatestMeasurementCard measurement={latest} />
<WeightChart measurements={all} />
<TouchableOpacity style={styles.fab} onPress={() => navigation.navigate('NewMeasurement')}>
<Text style={styles.fabText}>+ Nieuwe meting</Text>
</TouchableOpacity>
</ScrollView>
)
}
const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: '#f5f5f5' },
fab: { backgroundColor: '#4CAF50', padding: 16, borderRadius: 8, margin: 16, alignItems: 'center' },
fabText: { color: '#fff', fontSize: 16, fontWeight: '700' },
})
```
- [ ] **Step 4: Commit**
```bash
git add -A
git commit -m "feat: add Dashboard with latest card and weight chart"
```
---
### Task 5: History screen
**Files:**
- Create: `src/components/MeasurementRow.tsx`
- Create: `src/screens/HistoryScreen.tsx`
- [ ] **Step 1: Write MeasurementRow**
```typescript
// src/components/MeasurementRow.tsx
import React from 'react'
import { View, Text, StyleSheet } from 'react-native'
import { Measurement } from '../types'
type Props = { measurement: Measurement }
export default function MeasurementRow({ measurement }: Props) {
return (
<View style={styles.row}>
<View style={styles.left}>
<Text style={styles.date}>
{new Date(measurement.measuredAt).toLocaleDateString('nl-NL', {
weekday: 'short',
day: 'numeric',
month: 'long',
year: 'numeric',
})}
</Text>
<Text style={styles.time}>
{new Date(measurement.measuredAt).toLocaleTimeString('nl-NL', {
hour: '2-digit',
minute: '2-digit',
})}
</Text>
</View>
<View style={styles.right}>
<Text style={styles.weight}>{measurement.weight.toFixed(1)} kg</Text>
<Text style={styles.detail}>BMI {measurement.bmi.toFixed(1)}</Text>
</View>
</View>
)
}
const styles = StyleSheet.create({
row: {
flexDirection: 'row',
justifyContent: 'space-between',
backgroundColor: '#fff',
padding: 16,
marginHorizontal: 16,
marginVertical: 4,
borderRadius: 8,
shadowColor: '#000',
shadowOpacity: 0.05,
shadowRadius: 4,
elevation: 2,
},
left: {},
date: { fontSize: 14, fontWeight: '600' },
time: { fontSize: 12, color: '#666', marginTop: 2 },
right: { alignItems: 'flex-end' },
weight: { fontSize: 18, fontWeight: '700' },
detail: { fontSize: 12, color: '#666' },
})
```
- [ ] **Step 2: Write HistoryScreen**
```typescript
// src/screens/HistoryScreen.tsx
import React, { useCallback, useState } from 'react'
import { View, FlatList, StyleSheet, Text } from 'react-native'
import { useFocusEffect } from '@react-navigation/native'
import { initDatabase, getAllMeasurements } from '../database/db'
import { Measurement } from '../types'
import MeasurementRow from '../components/MeasurementRow'
export default function HistoryScreen() {
const [measurements, setMeasurements] = useState<Measurement[]>([])
useFocusEffect(
useCallback(() => {
const load = async () => {
await initDatabase()
const all = await getAllMeasurements()
setMeasurements(all)
}
load()
}, [])
)
return (
<View style={styles.container}>
<Text style={styles.count}>{measurements.length} metingen</Text>
<FlatList
data={measurements}
keyExtractor={item => item.id}
renderItem={({ item }) => <MeasurementRow measurement={item} />}
contentContainerStyle={measurements.length === 0 ? styles.empty : undefined}
ListEmptyComponent={<Text style={styles.emptyText}>Nog geen metingen</Text>}
/>
</View>
)
}
const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: '#f5f5f5' },
count: { fontSize: 14, color: '#666', padding: 16, paddingBottom: 8 },
empty: { flex: 1, justifyContent: 'center', alignItems: 'center' },
emptyText: { fontSize: 16, color: '#999' },
})
```
- [ ] **Step 3: Commit**
```bash
git add -A
git commit -m "feat: add history screen with measurement list"
```
---
### Task 6: Settings screen (WebDAV config)
**Files:**
- Create: `src/screens/SettingsScreen.tsx`
- [ ] **Step 1: Write SettingsScreen**
```typescript
// src/screens/SettingsScreen.tsx
import React, { useEffect, useState } from 'react'
import { View, Text, TextInput, TouchableOpacity, StyleSheet, Alert, ScrollView } from 'react-native'
import * as SecureStore from 'expo-secure-store'
import { WebDAVConfig } from '../types'
const CONFIG_KEY = 'weegschaal_webdav_config'
async function loadConfig(): Promise<WebDAVConfig | null> {
try {
const json = await SecureStore.getItemAsync(CONFIG_KEY)
return json ? JSON.parse(json) : null
} catch {
return null
}
}
async function saveConfig(config: WebDAVConfig): Promise<void> {
await SecureStore.setItemAsync(CONFIG_KEY, JSON.stringify(config))
}
async function deleteConfig(): Promise<void> {
await SecureStore.deleteItemAsync(CONFIG_KEY)
}
export async function getWebDAVConfig(): Promise<WebDAVConfig | null> {
return loadConfig()
}
export default function SettingsScreen() {
const [url, setUrl] = useState('')
const [username, setUsername] = useState('')
const [password, setPassword] = useState('')
const [syncing, setSyncing] = useState(false)
useEffect(() => {
loadConfig().then(c => {
if (c) {
setUrl(c.url)
setUsername(c.username)
setPassword(c.password)
}
})
}, [])
const handleSave = async () => {
await saveConfig({ url, username, password })
Alert.alert('Opgeslagen', 'WebDAV instellingen zijn opgeslagen')
}
const handleClear = async () => {
await deleteConfig()
setUrl('')
setUsername('')
setPassword('')
Alert.alert('Verwijderd', 'WebDAV instellingen zijn gewist')
}
const handleSync = async () => {
setSyncing(true)
try {
const { syncAll } = await import('../sync/syncService')
const result = await syncAll()
Alert.alert('Sync voltooid', `Lokaal: ${result.local}, WebDAV: ${result.remote}, samengevoegd: ${result.merged}`)
} catch (e: any) {
Alert.alert('Sync fout', e?.message ?? 'Onbekende fout')
} finally {
setSyncing(false)
}
}
return (
<ScrollView style={styles.container} contentContainerStyle={styles.content}>
<Text style={styles.sectionTitle}>WebDAV (Nextcloud)</Text>
<Text style={styles.label}>URL</Text>
<TextInput
style={styles.input}
value={url}
onChangeText={setUrl}
placeholder="https://nextcloud.example.nl/remote.php/dav/files/gebruiker/"
autoCapitalize="none"
autoCorrect={false}
/>
<Text style={styles.label}>Gebruikersnaam</Text>
<TextInput
style={styles.input}
value={username}
onChangeText={setUsername}
autoCapitalize="none"
autoCorrect={false}
/>
<Text style={styles.label}>Wachtwoord</Text>
<TextInput
style={styles.input}
value={password}
onChangeText={setPassword}
secureTextEntry
/>
<TouchableOpacity style={styles.saveButton} onPress={handleSave}>
<Text style={styles.buttonText}>Instellingen opslaan</Text>
</TouchableOpacity>
<TouchableOpacity style={styles.clearButton} onPress={handleClear}>
<Text style={styles.clearButtonText}>Instellingen wissen</Text>
</TouchableOpacity>
<TouchableOpacity
style={[styles.syncButton, syncing && styles.syncButtonDisabled]}
onPress={handleSync}
disabled={syncing}
>
<Text style={styles.buttonText}>{syncing ? 'Bezig met sync...' : 'Nu synchroniseren'}</Text>
</TouchableOpacity>
</ScrollView>
)
}
const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: '#f5f5f5' },
content: { padding: 16 },
sectionTitle: { fontSize: 18, fontWeight: '700', marginBottom: 16 },
label: { fontSize: 14, fontWeight: '600', color: '#333', marginBottom: 4, marginTop: 12 },
input: { backgroundColor: '#fff', borderRadius: 8, padding: 12, fontSize: 16, borderWidth: 1, borderColor: '#ddd' },
saveButton: { backgroundColor: '#4CAF50', padding: 14, borderRadius: 8, alignItems: 'center', marginTop: 24 },
clearButton: { padding: 14, alignItems: 'center', marginTop: 8 },
clearButtonText: { color: '#e53935', fontSize: 14 },
syncButton: { backgroundColor: '#2196F3', padding: 14, borderRadius: 8, alignItems: 'center', marginTop: 24 },
syncButtonDisabled: { opacity: 0.6 },
buttonText: { color: '#fff', fontSize: 16, fontWeight: '700' },
})
```
- [ ] **Step 2: Commit**
```bash
git add -A
git commit -m "feat: add settings screen with WebDAV config"
```
---
### Task 7: Sync service
**Files:**
- Create: `src/sync/syncService.ts`
- Create: `tests/syncService.test.ts`
- [ ] **Step 1: Write the sync service tests**
```typescript
// tests/syncService.test.ts
import { mergeMeasurements } from '../src/sync/syncService'
import { Measurement } from '../src/types'
const base: Measurement = {
id: 'id-1',
weight: 75,
bmi: 22,
bodyFatPercentage: 15,
fatMass: 10,
musclePercentage: 40,
muscleMass: 30,
visceralFat: 8,
bmr: 1600,
measuredAt: '2024-06-01T08:00:00.000Z',
createdAt: '2024-06-01T08:00:00.000Z',
updatedAt: '2024-06-01T08:00:00.000Z',
syncStatus: 'local',
}
describe('mergeMeasurements', () => {
it('returns empty array when both are empty', () => {
expect(mergeMeasurements([], [])).toEqual([])
})
it('keeps local items not on remote', () => {
const result = mergeMeasurements([base], [])
expect(result).toHaveLength(1)
expect(result[0].id).toBe('id-1')
})
it('keeps remote items not on local', () => {
const remote = { ...base, syncStatus: 'synced' as const }
const result = mergeMeasurements([], [remote])
expect(result).toHaveLength(1)
expect(result[0].id).toBe('id-1')
})
it('picks the newer version when both have same id', () => {
const local = { ...base, weight: 75, updatedAt: '2024-06-02T08:00:00.000Z' }
const remote = { ...base, weight: 76, updatedAt: '2024-06-03T08:00:00.000Z' }
const result = mergeMeasurements([local], [remote])
expect(result).toHaveLength(1)
expect(result[0].weight).toBe(76)
})
it('picks local when local is newer', () => {
const local = { ...base, weight: 77, updatedAt: '2024-06-04T08:00:00.000Z' }
const remote = { ...base, weight: 76, updatedAt: '2024-06-03T08:00:00.000Z' }
const result = mergeMeasurements([local], [remote])
expect(result).toHaveLength(1)
expect(result[0].weight).toBe(77)
})
})
```
- [ ] **Step 2: Run test to verify it fails**
Run: `npx jest tests/syncService.test.ts --no-cache`
Expected: FAIL — cannot find module `../src/sync/syncService`
- [ ] **Step 3: Write minimal implementation**
```typescript
// src/sync/syncService.ts
import { Measurement, WebDAVConfig } from '../types'
import { initDatabase, getAllMeasurements, upsertMeasurement, updateMeasurement } from '../database/db'
import { getWebDAVConfig } from '../screens/SettingsScreen'
const WEBDAV_PATH = 'weegschaal/measurements.json'
type SyncResult = { local: number; remote: number; merged: number }
export function mergeMeasurements(local: Measurement[], remote: Measurement[]): Measurement[] {
const map = new Map<string, Measurement>()
for (const m of local) {
map.set(m.id, m)
}
for (const m of remote) {
const existing = map.get(m.id)
if (!existing || new Date(m.updatedAt) > new Date(existing.updatedAt)) {
map.set(m.id, { ...m, syncStatus: 'synced' })
}
}
return Array.from(map.values())
}
async function fetchRemote(config: WebDAVConfig): Promise<Measurement[]> {
const url = `${config.url.replace(/\/+$/, '')}/${WEBDAV_PATH}`
const response = await fetch(url, {
headers: {
Authorization: 'Basic ' + btoa(`${config.username}:${config.password}`),
},
})
if (response.status === 404) {
return []
}
if (!response.ok) {
throw new Error(`WebDAV fetch failed: ${response.status}`)
}
return response.json()
}
async function uploadRemote(config: WebDAVConfig, measurements: Measurement[]): Promise<void> {
const url = `${config.url.replace(/\/+$/, '')}/${WEBDAV_PATH}`
const body = JSON.stringify(measurements)
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> {
const config = await getWebDAVConfig()
if (!config) {
throw new Error('Geen WebDAV configuratie. Stel deze eerst in in Instellingen.')
}
await initDatabase()
const local = await getAllMeasurements()
const remote = await fetchRemote(config)
const merged = mergeMeasurements(local, remote)
for (const m of merged) {
await upsertMeasurement(m)
}
await uploadRemote(config, merged)
return {
local: local.length,
remote: remote.length,
merged: merged.length,
}
}
```
- [ ] **Step 4: Run test to verify it passes**
Run: `npx jest tests/syncService.test.ts --no-cache`
Expected: PASS
- [ ] **Step 5: Commit**
```bash
git add -A
git commit -m "feat: add sync service with WebDAV merge logic"
```
---
### Task 8: Wire up App.tsx with navigation
**Files:**
- Modify: `App.tsx`
- [ ] **Step 1: Rewrite App.tsx with navigation**
```typescript
// App.tsx
import React from 'react'
import { NavigationContainer } from '@react-navigation/native'
import { createNativeStackNavigator } from '@react-navigation/native-stack'
import DashboardScreen from './src/screens/DashboardScreen'
import NewMeasurementScreen from './src/screens/NewMeasurementScreen'
import HistoryScreen from './src/screens/HistoryScreen'
import SettingsScreen from './src/screens/SettingsScreen'
const Stack = createNativeStackNavigator()
export default function App() {
return (
<NavigationContainer>
<Stack.Navigator initialRouteName="Dashboard">
<Stack.Screen
name="Dashboard"
component={DashboardScreen}
options={{ title: 'Weegschaal' }}
/>
<Stack.Screen
name="NewMeasurement"
component={NewMeasurementScreen}
options={{ title: 'Nieuwe meting' }}
/>
<Stack.Screen
name="History"
component={HistoryScreen}
options={{ title: 'Historie' }}
/>
<Stack.Screen
name="Settings"
component={SettingsScreen}
options={{ title: 'Instellingen' }}
/>
</Stack.Navigator>
</NavigationContainer>
)
}
```
- [ ] **Step 2: Add navigation buttons to DashboardScreen**
Update the navigation header or add buttons. Modify `DashboardScreen.tsx` to add a header bar with nav links:
Edit `src/screens/DashboardScreen.tsx` — replace the return block with:
```typescript
return (
<View style={styles.container}>
<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>
<ScrollView>
<LatestMeasurementCard measurement={latest} />
<WeightChart measurements={all} />
<TouchableOpacity style={styles.fab} onPress={() => navigation.navigate('NewMeasurement')}>
<Text style={styles.fabText}>+ Nieuwe meting</Text>
</TouchableOpacity>
</ScrollView>
</View>
)
```
Add to the StyleSheet:
```typescript
navBar: { flexDirection: 'row', justifyContent: 'space-around', padding: 12, backgroundColor: '#fff' },
navLink: { fontSize: 16, color: '#2196F3', fontWeight: '600' },
```
And update the import to include `View`:
```typescript
import { View, ScrollView, TouchableOpacity, Text, StyleSheet } from 'react-native'
```
- [ ] **Step 3: Run typecheck**
Run: `npx tsc --noEmit`
Expected: No type errors
- [ ] **Step 4: Commit**
```bash
git add -A
git commit -m "feat: wire up App.tsx with navigation"
```
---
### Task 9: Final verification
- [ ] **Step 1: Run all tests**
Run: `npx jest --no-cache`
Expected: All tests pass
- [ ] **Step 2: Run typecheck**
Run: `npx tsc --noEmit`
Expected: No errors
- [ ] **Step 3: Start Expo dev server**
Run: `npx expo start`
Expected: Dev server starts successfully (verify on Android/iOS simulator)