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

34 KiB

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

npx create-expo-app@latest weegschaal-app --template blank-typescript
  • Step 2: Install core dependencies
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
mkdir -p src/database src/sync src/screens src/components src/utils tests
// 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
// 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
{
  "jest": {
    "preset": "jest-expo"
  }
}

Run: npm install --save-dev jest-expo

  • Step 6: Commit
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

// 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
// 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
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

// 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
// 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
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

// 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
// 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
// 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
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

// 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
// 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
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

// 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
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

// 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
// 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
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

// 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:

  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:

  navBar: { flexDirection: 'row', justifyContent: 'space-around', padding: 12, backgroundColor: '#fff' },
  navLink: { fontSize: 16, color: '#2196F3', fontWeight: '600' },

And update the import to include View:

import { View, ScrollView, TouchableOpacity, Text, StyleSheet } from 'react-native'
  • Step 3: Run typecheck

Run: npx tsc --noEmit Expected: No type errors

  • Step 4: Commit
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)