1186 lines
34 KiB
Markdown
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)
|