feat: add MeasurementDetailScreen, 365-day charts, and edit support

- New MeasurementDetailScreen with wijzigen/verwijderen
- WeightChart now supports 30 and 365 days
- NewMeasurementScreen now handles both create and edit
- Updated README to match current code
- Add build-toolchain documentation
- Ignore *.apk build artifacts
This commit is contained in:
eddy 2026-07-14 09:14:18 +02:00
commit ed714af8d1
15 changed files with 730 additions and 130 deletions

View file

@ -47,8 +47,8 @@ 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' },
row: { flexDirection: 'row', paddingVertical: 4 },
rowLabel: { fontSize: 14, color: '#555', flex: 1 },
rowValue: { fontSize: 14, fontWeight: '600' },
emptyText: { fontSize: 16, color: '#999', textAlign: 'center', padding: 20 },
})

View file

@ -8,6 +8,7 @@ import { generateId } from '../utils/uuid'
type Props = {
onSubmit: (m: Measurement) => void
initialValues?: Partial<Measurement>
isEdit?: boolean
}
type FieldDef = {
@ -29,13 +30,10 @@ const fields: FieldDef[] = [
{ key: 'bmr', label: 'Stofwisseling (kcal)', placeholder: '1650', keyboardType: 'number-pad' },
]
export default function MeasurementForm({ onSubmit, initialValues }: Props) {
export default function MeasurementForm({ onSubmit, initialValues, isEdit }: Props) {
const insets = useSafeAreaInsets()
const [pickerOpen, setPickerOpen] = useState(false)
const [measurementDate, setMeasurementDate] = useState<Date>(() => {
if (initialValues?.measuredAt) return new Date(initialValues.measuredAt)
return new Date()
})
const [measurementDate, setMeasurementDate] = useState<Date>(new Date())
const [values, setValues] = useState<Record<string, string>>(() => {
const init: Record<string, string> = {}
for (const f of fields) {
@ -47,9 +45,18 @@ export default function MeasurementForm({ onSubmit, initialValues }: Props) {
const weight = parseFloat(values.weight) || 0
const bodyFatPct = parseFloat(values.bodyFatPercentage) || 0
const musclePct = parseFloat(values.musclePercentage) || 0
function toFloat(s: string): number {
return parseFloat(s.replace(',', '.')) || 0
}
function toInt(s: string): number {
return parseInt(s.replace(',', '.'), 10) || 0
}
const weight = toFloat(values.weight)
const bodyFatPct = toFloat(values.bodyFatPercentage)
const musclePct = toFloat(values.musclePercentage)
const computed = useMemo(() => ({
fatMass: weight > 0 && bodyFatPct > 0 ? ((weight * bodyFatPct) / 100).toFixed(1) : '',
@ -59,17 +66,17 @@ export default function MeasurementForm({ onSubmit, initialValues }: Props) {
const handleSubmit = () => {
const now = new Date().toISOString()
const m: Measurement = {
id: generateId(),
id: isEdit && initialValues?.id ? initialValues.id : generateId(),
weight,
bmi: parseFloat(values.bmi) || 0,
bmi: toFloat(values.bmi),
bodyFatPercentage: bodyFatPct,
fatMass: parseFloat(computed.fatMass) || 0,
fatMass: toFloat(computed.fatMass),
musclePercentage: musclePct,
muscleMass: parseFloat(computed.muscleMass) || 0,
visceralFat: parseInt(values.visceralFat, 10) || 0,
bmr: parseFloat(values.bmr) || 0,
muscleMass: toFloat(computed.muscleMass),
visceralFat: toInt(values.visceralFat),
bmr: toFloat(values.bmr),
measuredAt: measurementDate.toISOString(),
createdAt: now,
createdAt: isEdit && initialValues?.createdAt ? initialValues.createdAt : now,
updatedAt: now,
syncStatus: 'local',
}
@ -140,7 +147,7 @@ export default function MeasurementForm({ onSubmit, initialValues }: Props) {
)
})}
</ScrollView>
<TouchableOpacity style={[styles.button, { marginBottom: insets.bottom + 16 }]} onPress={handleSubmit}>
<TouchableOpacity style={[styles.button, { marginBottom: insets.bottom + 48 }]} onPress={handleSubmit}>
<Text style={styles.buttonText}>Opslaan</Text>
</TouchableOpacity>
</KeyboardAvoidingView>
@ -149,7 +156,7 @@ export default function MeasurementForm({ onSubmit, initialValues }: Props) {
const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: '#f5f5f5' },
scroll: { padding: 16, paddingBottom: 0 },
scroll: { padding: 16 },
field: { marginBottom: 12 },
label: { fontSize: 14, fontWeight: '600', color: '#333', marginBottom: 4 },
input: { backgroundColor: '#fff', borderRadius: 8, padding: 12, fontSize: 16, borderWidth: 1, borderColor: '#ddd' },

View file

@ -1,5 +1,5 @@
import React from 'react'
import { View, Text, StyleSheet, Dimensions } from 'react-native'
import React, { useRef, useState } from 'react'
import { View, Text, StyleSheet, Dimensions, ScrollView, NativeSyntheticEvent, NativeScrollEvent } from 'react-native'
import { LineChart } from 'react-native-chart-kit'
import { Measurement } from '../types'
@ -11,6 +11,9 @@ const COLORS = {
muscleMass: '#2196F3',
}
const screenWidth = Dimensions.get('window').width
const chartWidth = screenWidth - 32
function SingleChart({
title,
data,
@ -29,7 +32,7 @@ function SingleChart({
<Text style={styles.chartTitle}>{title}</Text>
<LineChart
data={{ labels, datasets: [{ data, color: () => color, strokeWidth: 2 }] }}
width={Dimensions.get('window').width - 32}
width={chartWidth}
height={180}
yAxisSuffix={unit}
chartConfig={{
@ -57,7 +60,56 @@ function hexToRgb(hex: string): string {
return `${r}, ${g}, ${b}`
}
function filterData(sorted: Measurement[], days: number, lastDate: Date) {
const cutoff = new Date(lastDate)
cutoff.setDate(cutoff.getDate() - days)
const filtered = sorted.filter(m => new Date(m.measuredAt) >= cutoff)
return filtered.length >= 2 ? filtered : sorted
}
function prepareLabels(dataPoints: Measurement[]) {
const labels = dataPoints.map(m => {
const d = new Date(m.measuredAt)
return `${d.getDate()}/${d.getMonth() + 1}`
})
const labelStep = labels.length > 8 ? Math.ceil(labels.length / 8) : 1
return labels.map((l, i) => (i % labelStep === 0 ? l : ''))
}
function ChartSet({ dataPoints, title }: { dataPoints: Measurement[]; title: string }) {
const labels = prepareLabels(dataPoints)
return (
<View style={styles.page}>
<Text style={styles.title}>{title}</Text>
<SingleChart
title="Gewicht"
data={dataPoints.map(m => m.weight)}
labels={labels}
color={COLORS.weight}
unit=" kg"
/>
<SingleChart
title="Vetmassa"
data={dataPoints.map(m => m.fatMass)}
labels={labels}
color={COLORS.fatMass}
unit=" kg"
/>
<SingleChart
title="Spiermassa"
data={dataPoints.map(m => m.muscleMass)}
labels={labels}
color={COLORS.muscleMass}
unit=" kg"
/>
</View>
)
}
export default function WeightChart({ measurements }: Props) {
const scrollRef = useRef<ScrollView>(null)
const [page, setPage] = useState(0)
if (measurements.length < 2) {
return null
}
@ -66,52 +118,38 @@ export default function WeightChart({ measurements }: Props) {
(a, b) => new Date(a.measuredAt).getTime() - new Date(b.measuredAt).getTime()
)
const cutoff = new Date(sorted[sorted.length - 1].measuredAt)
cutoff.setDate(cutoff.getDate() - 30)
const filtered = sorted.filter(m => new Date(m.measuredAt) >= cutoff)
const dataPoints = filtered.length >= 2 ? filtered : sorted
const lastDate = new Date(sorted[sorted.length - 1].measuredAt)
const labels = dataPoints.map(m => {
const d = new Date(m.measuredAt)
return `${d.getDate()}/${d.getMonth() + 1}`
})
const data30 = filterData(sorted, 30, lastDate)
const data365 = filterData(sorted, 365, lastDate)
const labelStep = labels.length > 8 ? Math.ceil(labels.length / 8) : 1
const sparseLabels = labels.map((l, i) => (i % labelStep === 0 ? l : ''))
const onScroll = (e: NativeSyntheticEvent<NativeScrollEvent>) => {
const offset = e.nativeEvent.contentOffset.x
const newPage = Math.round(offset / screenWidth)
if (newPage !== page) {
setPage(newPage)
}
}
return (
<View style={styles.container}>
<Text style={styles.title}>Voortgang (30 dagen)</Text>
<SingleChart
title="Gewicht"
data={dataPoints.map(m => m.weight)}
labels={sparseLabels}
color={COLORS.weight}
unit=" kg"
/>
<SingleChart
title="Vetmassa"
data={dataPoints.map(m => m.fatMass)}
labels={sparseLabels}
color={COLORS.fatMass}
unit=" kg"
/>
<SingleChart
title="Spiermassa"
data={dataPoints.map(m => m.muscleMass)}
labels={sparseLabels}
color={COLORS.muscleMass}
unit=" kg"
/>
<ScrollView
ref={scrollRef}
horizontal
pagingEnabled
showsHorizontalScrollIndicator={false}
onMomentumScrollEnd={onScroll}
>
<ChartSet dataPoints={data30} title="Voortgang (30 dagen)" />
<ChartSet dataPoints={data365} title="Voortgang (365 dagen)" />
</ScrollView>
</View>
)
}
const styles = StyleSheet.create({
container: { marginHorizontal: 16, marginBottom: 16 },
container: { marginBottom: 16 },
page: { width: screenWidth, paddingHorizontal: 16 },
title: { fontSize: 18, fontWeight: '700', marginBottom: 8 },
chartBlock: { marginBottom: 12 },
chartTitle: { fontSize: 13, fontWeight: '600', color: '#555', marginBottom: 4, marginLeft: 4 },

View file

@ -1,5 +1,5 @@
import React, { useCallback, useState } from 'react'
import { View, FlatList, StyleSheet, Text, RefreshControl, Alert } from 'react-native'
import { View, FlatList, StyleSheet, Text, RefreshControl, TouchableOpacity } from 'react-native'
import { useFocusEffect } from '@react-navigation/native'
import { initDatabase, getAllMeasurements, deleteMeasurement } from '../database/db'
import { Measurement } from '../types'
@ -80,7 +80,9 @@ export default function HistoryScreen({ navigation }: Props) {
data={measurements}
keyExtractor={item => item.id}
renderItem={({ item }) => (
<MeasurementRow measurement={item} onPress={() => handlePress(item)} />
<TouchableOpacity onPress={() => navigation.navigate('MeasurementDetail', { measurementId: item.id })}>
<MeasurementRow measurement={item} />
</TouchableOpacity>
)}
contentContainerStyle={measurements.length === 0 ? styles.empty : undefined}
ListEmptyComponent={<Text style={styles.emptyText}>Nog geen metingen</Text>}

View file

@ -0,0 +1,135 @@
import React, { useCallback, useState } from 'react'
import { View, Text, StyleSheet, TouchableOpacity, Alert, ScrollView } from 'react-native'
import { useFocusEffect } from '@react-navigation/native'
import { useSafeAreaInsets } from 'react-native-safe-area-context'
import { initDatabase, getAllMeasurements, deleteMeasurement } from '../database/db'
import { Measurement } from '../types'
type Props = {
navigation: any
route: {
params: {
measurementId: string
}
}
}
const rows: { label: string; key: keyof Measurement; suffix: string }[] = [
{ label: 'Gewicht', key: 'weight', suffix: 'kg' },
{ label: 'BMI', key: 'bmi', suffix: '' },
{ label: 'Vetpercentage', key: 'bodyFatPercentage', suffix: '%' },
{ label: 'Vetmassa', key: 'fatMass', suffix: 'kg' },
{ label: 'Spierpercentage', key: 'musclePercentage', suffix: '%' },
{ label: 'Spiermassa', key: 'muscleMass', suffix: 'kg' },
{ label: 'Buikvet', key: 'visceralFat', suffix: '' },
{ label: 'Stofwisseling', key: 'bmr', suffix: 'kcal' },
]
export default function MeasurementDetailScreen({ navigation, route }: Props) {
const insets = useSafeAreaInsets()
const { measurementId } = route.params
const [measurement, setMeasurement] = useState<Measurement | null>(null)
useFocusEffect(
useCallback(() => {
const load = async () => {
await initDatabase()
const all = await getAllMeasurements()
const m = all.find(x => x.id === measurementId)
setMeasurement(m ?? null)
}
load()
}, [measurementId])
)
const handleEdit = () => {
navigation.navigate('NewMeasurement', { measurement })
}
const handleDelete = () => {
Alert.alert(
'Meting verwijderen?',
'Dit kan niet ongedaan worden gemaakt.',
[
{ text: 'Annuleren', style: 'cancel' },
{
text: 'Verwijderen',
style: 'destructive',
onPress: async () => {
await initDatabase()
await deleteMeasurement(measurementId)
try {
const { syncAll } = await import('../sync/syncService')
await syncAll(new Set([measurementId]))
} catch {}
navigation.goBack()
},
},
]
)
}
if (!measurement) {
return (
<View style={styles.centered}>
<Text style={{ color: '#999' }}>Laden...</Text>
</View>
)
}
return (
<View style={styles.container}>
<ScrollView contentContainerStyle={styles.content}>
<Text style={styles.date}>
{new Date(measurement.measuredAt).toLocaleDateString('nl-NL', {
weekday: 'long',
day: 'numeric',
month: 'long',
year: 'numeric',
})}
</Text>
{rows.map(r => (
<View key={r.key} style={styles.row}>
<Text style={styles.label}>{r.label}</Text>
<Text style={styles.value}>
{typeof measurement[r.key] === 'number'
? (measurement[r.key] as number).toFixed(1)
: measurement[r.key]}{' '}
{r.suffix}
</Text>
</View>
))}
</ScrollView>
<View style={[styles.buttons, { paddingBottom: insets.bottom + 16 }]}>
<TouchableOpacity style={styles.editButton} onPress={handleEdit}>
<Text style={styles.buttonText}>Wijzigen</Text>
</TouchableOpacity>
<TouchableOpacity style={styles.deleteButton} onPress={handleDelete}>
<Text style={styles.buttonText}>Verwijderen</Text>
</TouchableOpacity>
</View>
</View>
)
}
const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: '#f5f5f5' },
content: { padding: 16 },
centered: { flex: 1, justifyContent: 'center', alignItems: 'center', backgroundColor: '#f5f5f5' },
date: { fontSize: 18, fontWeight: '700', marginBottom: 16, textAlign: 'center' },
row: {
flexDirection: 'row',
backgroundColor: '#fff',
padding: 14,
marginBottom: 8,
borderRadius: 8,
},
label: { fontSize: 15, color: '#555', flex: 1 },
value: { fontSize: 15, fontWeight: '600' },
buttons: { flexDirection: 'row', gap: 12, padding: 16 },
editButton: { flex: 1, backgroundColor: '#2196F3', padding: 14, borderRadius: 8, alignItems: 'center' },
deleteButton: { flex: 1, backgroundColor: '#e53935', padding: 14, borderRadius: 8, alignItems: 'center' },
buttonText: { color: '#fff', fontSize: 16, fontWeight: '700' },
})

View file

@ -3,88 +3,74 @@ import { View, Text, Alert } from 'react-native'
import { useFocusEffect } from '@react-navigation/native'
import MeasurementForm from '../components/MeasurementForm'
import { Measurement } from '../types'
import {
initDatabase,
insertMeasurement,
getLatestMeasurement,
getMeasurementByDate,
upsertMeasurement,
} from '../database/db'
import { initDatabase, insertMeasurement, upsertMeasurement, getLatestMeasurement, getMeasurementByDate } from '../database/db'
type Props = {
navigation: any
route: any
route: {
params: {
measurement?: Measurement
}
}
}
export default function NewMeasurementScreen({ navigation, route }: Props) {
const editing = route.params?.measurement as Measurement | undefined
const editMeasurement = route.params?.measurement
const isEdit = !!editMeasurement
const [latest, setLatest] = useState<Partial<Measurement> | undefined>(undefined)
const [loaded, setLoaded] = useState(false)
React.useLayoutEffect(() => {
navigation.setOptions({ title: isEdit ? 'Meting wijzigen' : 'Nieuwe meting' })
}, [navigation, isEdit])
useFocusEffect(
useCallback(() => {
if (isEdit) {
setLatest(editMeasurement)
setLoaded(true)
return
}
let cancelled = false
const load = async () => {
await initDatabase()
if (editing) {
if (!cancelled) {
setLatest(editing)
setLoaded(true)
}
} else {
const m = await getLatestMeasurement()
if (!cancelled) {
setLatest(m || {})
setLoaded(true)
}
const m = await getLatestMeasurement()
if (!cancelled) {
setLatest(m || {})
setLoaded(true)
}
}
load()
return () => { cancelled = true }
}, [editing])
}, [isEdit, editMeasurement])
)
useEffect(() => {
if (editing) {
navigation.setOptions({ title: 'Meting wijzigen' })
}
}, [editing, navigation])
const handleSubmit = async (m: Measurement) => {
await initDatabase()
if (editing) {
m.id = editing.id
m.createdAt = editing.createdAt
if (isEdit) {
await upsertMeasurement(m)
doSync()
return
}
const existing = await getMeasurementByDate(m.measuredAt)
if (existing) {
Alert.alert(
'Dubbele datum',
`Er bestaat al een meting van ${new Date(m.measuredAt).toLocaleDateString('nl-NL')}. Overschrijven?`,
[
{ text: 'Annuleren', style: 'cancel' },
{ text: 'Overschrijven', style: 'destructive', onPress: () => doSave(m, existing.id) },
]
)
return
} else {
const existing = await getMeasurementByDate(m.measuredAt)
if (existing) {
Alert.alert(
'Dubbele datum',
`Er bestaat al een meting van ${new Date(m.measuredAt).toLocaleDateString('nl-NL')}. Overschrijven?`,
[
{ text: 'Annuleren', style: 'cancel' },
{ text: 'Overschrijven', style: 'destructive', onPress: () => {
m.id = existing.id
doSave(m)
}},
]
)
return
}
await insertMeasurement(m)
}
doSave(m)
}
const doSave = async (m: Measurement, existingId?: string) => {
if (existingId) {
m.id = existingId
await upsertMeasurement(m)
} else {
await insertMeasurement(m)
}
doSync()
}
const doSync = async () => {
const doSave = async (m: Measurement) => {
try {
const { syncAll } = await import('../sync/syncService')
await syncAll()
@ -110,6 +96,7 @@ export default function NewMeasurementScreen({ navigation, route }: Props) {
<MeasurementForm
onSubmit={handleSubmit}
initialValues={latest as Partial<Measurement> | undefined}
isEdit={isEdit}
/>
)
}

View file

@ -6,14 +6,16 @@ const WEBDAV_PATH = 'weegschaal/measurements.json'
type SyncResult = { local: number; remote: number; merged: number }
export function mergeMeasurements(local: Measurement[], remote: Measurement[]): Measurement[] {
export function mergeMeasurements(local: Measurement[], remote: Measurement[], excludeIds?: Set<string>): Measurement[] {
const map = new Map<string, Measurement>()
for (const m of local) {
if (excludeIds?.has(m.id)) continue
map.set(m.id, m)
}
for (const m of remote) {
if (excludeIds?.has(m.id)) continue
const existing = map.get(m.id)
if (!existing || new Date(m.updatedAt) > new Date(existing.updatedAt)) {
map.set(m.id, { ...m, syncStatus: 'synced' })
@ -82,7 +84,7 @@ async function uploadRemote(
}
}
export async function syncAll(): Promise<SyncResult> {
export async function syncAll(excludeIds?: Set<string>): Promise<SyncResult> {
const config = await getWebDAVConfig()
if (!config) {
throw new Error('Geen WebDAV configuratie. Stel deze eerst in in Instellingen.')
@ -93,7 +95,7 @@ export async function syncAll(): Promise<SyncResult> {
const local = await getAllMeasurements()
const remote = await fetchRemote(config)
const merged = mergeMeasurements(local, remote)
const merged = mergeMeasurements(local, remote, excludeIds)
for (const m of merged) {
await upsertMeasurement(m)