Initial commit: code as received (Create/Anything export)
Insole-production time tracker exported from the Create/Anything AI platform. Baseline snapshot before any reverse-engineering or cleanup. - apps/mobile: Expo Router app (iOS/Android/web), the only workspace - publisher/: standalone OpenNext/AWS deploy tooling for the web side - Backend (/api/tasks, /api/logs + DB) lives remotely, not in this repo
This commit is contained in:
46
apps/mobile/src/app/(tabs)/_layout.tsx
Normal file
46
apps/mobile/src/app/(tabs)/_layout.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
import { Tabs } from 'expo-router';
|
||||
import { Timer, History, Settings } from 'lucide-react-native';
|
||||
|
||||
export default function TabLayout() {
|
||||
return (
|
||||
<Tabs
|
||||
screenOptions={{
|
||||
headerShown: false,
|
||||
tabBarStyle: {
|
||||
backgroundColor: '#ffffff',
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: '#E5E7EB',
|
||||
paddingTop: 4,
|
||||
},
|
||||
tabBarActiveTintColor: '#2563EB',
|
||||
tabBarInactiveTintColor: '#6B7280',
|
||||
tabBarLabelStyle: {
|
||||
fontSize: 12,
|
||||
fontWeight: '500',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Tabs.Screen
|
||||
name="index"
|
||||
options={{
|
||||
title: 'Stopwatch',
|
||||
tabBarIcon: ({ color }) => <Timer color={color} size={24} />,
|
||||
}}
|
||||
/>
|
||||
<Tabs.Screen
|
||||
name="history"
|
||||
options={{
|
||||
title: 'Geschiedenis',
|
||||
tabBarIcon: ({ color }) => <History color={color} size={24} />,
|
||||
}}
|
||||
/>
|
||||
<Tabs.Screen
|
||||
name="tasks"
|
||||
options={{
|
||||
title: 'Instellingen',
|
||||
tabBarIcon: ({ color }) => <Settings color={color} size={24} />,
|
||||
}}
|
||||
/>
|
||||
</Tabs>
|
||||
);
|
||||
}
|
||||
233
apps/mobile/src/app/(tabs)/history.tsx
Normal file
233
apps/mobile/src/app/(tabs)/history.tsx
Normal file
@@ -0,0 +1,233 @@
|
||||
import React from 'react';
|
||||
import { View, Text, ScrollView, TouchableOpacity, Linking, Alert } from 'react-native';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
import { Download, Clock, Calendar, Layers } from 'lucide-react-native';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useFonts, Inter_400Regular, Inter_600SemiBold } from '@expo-google-fonts/inter';
|
||||
|
||||
export default function HistoryScreen() {
|
||||
const insets = useSafeAreaInsets();
|
||||
const [fontsLoaded, fontError] = useFonts({ Inter_400Regular, Inter_600SemiBold });
|
||||
|
||||
const { data: logs = [], isLoading } = useQuery({
|
||||
queryKey: ['logs'],
|
||||
queryFn: async () => {
|
||||
const res = await fetch(`${process.env.EXPO_PUBLIC_BASE_URL}/api/logs`);
|
||||
if (!res.ok) throw new Error('Failed to fetch logs');
|
||||
return res.json();
|
||||
},
|
||||
});
|
||||
|
||||
const handleExport = async () => {
|
||||
const exportUrl = `${process.env.EXPO_PUBLIC_BASE_URL}/api/export`;
|
||||
const supported = await Linking.canOpenURL(exportUrl);
|
||||
if (supported) {
|
||||
await Linking.openURL(exportUrl);
|
||||
} else {
|
||||
Alert.alert('Fout', 'Kan de export-URL niet openen');
|
||||
}
|
||||
};
|
||||
|
||||
const formatDuration = (seconds: number) => {
|
||||
const hrs = Math.floor(seconds / 3600);
|
||||
const mins = Math.floor((seconds % 3600) / 60);
|
||||
const secs = seconds % 60;
|
||||
if (hrs > 0) return `${hrs}h ${mins}m`;
|
||||
if (mins > 0) return `${mins}m ${secs}s`;
|
||||
return `${secs}s`;
|
||||
};
|
||||
|
||||
const formatDate = (dateStr: string) => {
|
||||
const date = new Date(dateStr);
|
||||
return date.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' });
|
||||
};
|
||||
|
||||
const formatTime = (dateStr: string) => {
|
||||
const date = new Date(dateStr);
|
||||
return date.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' });
|
||||
};
|
||||
|
||||
if (!fontsLoaded && !fontError) return null;
|
||||
|
||||
return (
|
||||
<View style={{ flex: 1, backgroundColor: '#ffffff', paddingTop: insets.top }}>
|
||||
<View
|
||||
style={{
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: 24,
|
||||
paddingVertical: 16,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: '#E5E7EB',
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 24,
|
||||
fontWeight: '600',
|
||||
color: '#111827',
|
||||
fontFamily: 'Inter_600SemiBold',
|
||||
}}
|
||||
>
|
||||
Geschiedenis
|
||||
</Text>
|
||||
<TouchableOpacity
|
||||
onPress={handleExport}
|
||||
style={{
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
backgroundColor: '#EFF6FF',
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 8,
|
||||
borderRadius: 999,
|
||||
gap: 6,
|
||||
}}
|
||||
>
|
||||
<Download color="#2563EB" size={16} />
|
||||
<Text
|
||||
style={{
|
||||
color: '#2563EB',
|
||||
fontWeight: '500',
|
||||
fontSize: 13,
|
||||
fontFamily: 'Inter_600SemiBold',
|
||||
}}
|
||||
>
|
||||
Exporteer CSV
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
<ScrollView contentContainerStyle={{ padding: 20 }}>
|
||||
{logs.length === 0 && !isLoading ? (
|
||||
<View style={{ alignItems: 'center', marginTop: 100 }}>
|
||||
<Text style={{ color: '#6B7280', fontSize: 16, fontFamily: 'Inter_400Regular' }}>
|
||||
Nog geen opgeslagen sessies.
|
||||
</Text>
|
||||
</View>
|
||||
) : (
|
||||
logs.map((log: any) => (
|
||||
<View
|
||||
key={log.id}
|
||||
style={{
|
||||
backgroundColor: '#ffffff',
|
||||
borderRadius: 12,
|
||||
borderWidth: 1,
|
||||
borderColor: '#E5E7EB',
|
||||
padding: 16,
|
||||
marginBottom: 12,
|
||||
}}
|
||||
>
|
||||
<View
|
||||
style={{
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'flex-start',
|
||||
}}
|
||||
>
|
||||
<View>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
color: '#111827',
|
||||
marginBottom: 4,
|
||||
fontFamily: 'Inter_600SemiBold',
|
||||
}}
|
||||
>
|
||||
{log.task_name}
|
||||
</Text>
|
||||
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 4 }}>
|
||||
<Calendar color="#6B7280" size={12} />
|
||||
<Text
|
||||
style={{ fontSize: 12, color: '#6B7280', fontFamily: 'Inter_400Regular' }}
|
||||
>
|
||||
{formatDate(log.start_time)} • {formatTime(log.start_time)}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 6 }}>
|
||||
{log.insole_type && (
|
||||
<View
|
||||
style={{
|
||||
backgroundColor: '#F3F4F6',
|
||||
borderWidth: 1,
|
||||
borderColor: '#E5E7EB',
|
||||
borderRadius: 999,
|
||||
paddingHorizontal: 10,
|
||||
paddingVertical: 4,
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 13,
|
||||
fontWeight: '600',
|
||||
color: '#374151',
|
||||
fontFamily: 'Inter_600SemiBold',
|
||||
}}
|
||||
>
|
||||
{log.insole_type}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
{log.pair_count != null && (
|
||||
<View
|
||||
style={{
|
||||
backgroundColor: '#EFF6FF',
|
||||
borderWidth: 1,
|
||||
borderColor: '#BFDBFE',
|
||||
borderRadius: 999,
|
||||
paddingHorizontal: 10,
|
||||
paddingVertical: 4,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 4,
|
||||
}}
|
||||
>
|
||||
<Layers color="#2563EB" size={12} />
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 13,
|
||||
fontWeight: '600',
|
||||
color: '#2563EB',
|
||||
fontFamily: 'Inter_600SemiBold',
|
||||
}}
|
||||
>
|
||||
{log.pair_count} {log.pair_count === 1 ? 'inlegzool' : 'inlegzolen'}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
<View
|
||||
style={{
|
||||
backgroundColor: '#F9FAFB',
|
||||
borderWidth: 1,
|
||||
borderColor: '#E5E7EB',
|
||||
borderRadius: 999,
|
||||
paddingHorizontal: 10,
|
||||
paddingVertical: 4,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 4,
|
||||
}}
|
||||
>
|
||||
<Clock color="#111827" size={12} />
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 13,
|
||||
fontWeight: '600',
|
||||
color: '#111827',
|
||||
fontFamily: 'Inter_600SemiBold',
|
||||
}}
|
||||
>
|
||||
{formatDuration(log.duration_seconds)}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
))
|
||||
)}
|
||||
</ScrollView>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
658
apps/mobile/src/app/(tabs)/index.tsx
Normal file
658
apps/mobile/src/app/(tabs)/index.tsx
Normal file
@@ -0,0 +1,658 @@
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
TouchableOpacity,
|
||||
ScrollView,
|
||||
TextInput,
|
||||
Modal,
|
||||
Animated,
|
||||
Pressable,
|
||||
Dimensions,
|
||||
Platform,
|
||||
} from 'react-native';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
import { Play, Square, ChevronDown, Check } from 'lucide-react-native';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useFonts, Inter_400Regular, Inter_600SemiBold } from '@expo-google-fonts/inter';
|
||||
|
||||
const BASE_URL = process.env.EXPO_PUBLIC_BASE_URL;
|
||||
const SCREEN_HEIGHT = Dimensions.get('window').height;
|
||||
const SHEET_HEIGHT = SCREEN_HEIGHT * 0.75;
|
||||
|
||||
const INSOLE_TYPES = ['Kurk', 'Berk', '3D'] as const;
|
||||
type InsoleType = (typeof INSOLE_TYPES)[number];
|
||||
|
||||
export default function TimerScreen() {
|
||||
const insets = useSafeAreaInsets();
|
||||
const queryClient = useQueryClient();
|
||||
// fontError: if fonts fail to load on Android we still render (no freeze)
|
||||
const [fontsLoaded, fontError] = useFonts({ Inter_400Regular, Inter_600SemiBold });
|
||||
|
||||
const [activeTaskId, setActiveTaskId] = useState<number | null>(null);
|
||||
const [insoleType, setInsoleType] = useState<InsoleType>('Kurk');
|
||||
const [isRunning, setIsRunning] = useState(false);
|
||||
const [isPaused, setIsPaused] = useState(false);
|
||||
const [startTime, setStartTime] = useState<Date | null>(null);
|
||||
const [elapsedTime, setElapsedTime] = useState(0);
|
||||
const [showPicker, setShowPicker] = useState(false);
|
||||
const [discardPending, setDiscardPending] = useState(false);
|
||||
const [insoleCount, setInsoleCount] = useState(2);
|
||||
const [insoleCountText, setInsoleCountText] = useState('2');
|
||||
|
||||
const timerRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const discardTimerRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const slideAnim = useRef(new Animated.Value(SHEET_HEIGHT)).current;
|
||||
|
||||
const { data: tasks = [] } = useQuery({
|
||||
queryKey: ['tasks'],
|
||||
queryFn: async () => {
|
||||
const res = await fetch(`${BASE_URL}/api/tasks`);
|
||||
if (!res.ok) throw new Error('Failed to fetch tasks');
|
||||
return res.json();
|
||||
},
|
||||
});
|
||||
|
||||
const saveLogMutation = useMutation({
|
||||
mutationFn: async (log: any) => {
|
||||
const res = await fetch(`${BASE_URL}/api/logs`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(log),
|
||||
});
|
||||
if (!res.ok) throw new Error('Failed to save log');
|
||||
return res.json();
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['logs'] });
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (isRunning && !isPaused) {
|
||||
timerRef.current = setInterval(() => setElapsedTime((prev) => prev + 1), 1000);
|
||||
} else {
|
||||
if (timerRef.current) clearInterval(timerRef.current);
|
||||
}
|
||||
return () => {
|
||||
if (timerRef.current) clearInterval(timerRef.current);
|
||||
};
|
||||
}, [isRunning, isPaused]);
|
||||
|
||||
const openPicker = () => {
|
||||
setShowPicker(true);
|
||||
Animated.timing(slideAnim, {
|
||||
toValue: 0,
|
||||
duration: 300,
|
||||
useNativeDriver: true,
|
||||
}).start();
|
||||
};
|
||||
|
||||
const closePicker = () => {
|
||||
Animated.timing(slideAnim, {
|
||||
toValue: SHEET_HEIGHT,
|
||||
duration: 250,
|
||||
useNativeDriver: true,
|
||||
}).start(() => setShowPicker(false));
|
||||
};
|
||||
|
||||
const handleStart = () => {
|
||||
if (!activeTaskId) return;
|
||||
setIsRunning(true);
|
||||
setIsPaused(false);
|
||||
setStartTime(new Date());
|
||||
};
|
||||
|
||||
const handlePause = () => setIsPaused(true);
|
||||
const handleResume = () => setIsPaused(false);
|
||||
|
||||
const handleStop = () => {
|
||||
if (!activeTaskId || !startTime) return;
|
||||
setIsRunning(false);
|
||||
setIsPaused(false);
|
||||
const endTime = new Date();
|
||||
saveLogMutation.mutate({
|
||||
task_id: activeTaskId,
|
||||
start_time: startTime.toISOString(),
|
||||
end_time: endTime.toISOString(),
|
||||
duration_seconds: elapsedTime,
|
||||
pair_count: insoleCount,
|
||||
insole_type: insoleType,
|
||||
});
|
||||
setStartTime(null);
|
||||
setElapsedTime(0);
|
||||
setDiscardPending(false);
|
||||
if (discardTimerRef.current) clearTimeout(discardTimerRef.current);
|
||||
};
|
||||
|
||||
const handleDiscard = () => {
|
||||
if (!discardPending) {
|
||||
setDiscardPending(true);
|
||||
discardTimerRef.current = setTimeout(() => setDiscardPending(false), 3000);
|
||||
} else {
|
||||
if (discardTimerRef.current) clearTimeout(discardTimerRef.current);
|
||||
setIsRunning(false);
|
||||
setIsPaused(false);
|
||||
setStartTime(null);
|
||||
setElapsedTime(0);
|
||||
setDiscardPending(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleInsoleCountChange = (text: string) => {
|
||||
setInsoleCountText(text);
|
||||
const parsed = parseInt(text, 10);
|
||||
if (!isNaN(parsed) && parsed > 0) setInsoleCount(parsed);
|
||||
};
|
||||
|
||||
const adjustInsoleCount = (delta: number) => {
|
||||
const next = Math.max(1, insoleCount + delta);
|
||||
setInsoleCount(next);
|
||||
setInsoleCountText(String(next));
|
||||
};
|
||||
|
||||
const formatTime = (seconds: number) => {
|
||||
const hrs = Math.floor(seconds / 3600);
|
||||
const mins = Math.floor((seconds % 3600) / 60);
|
||||
const secs = seconds % 60;
|
||||
return `${hrs.toString().padStart(2, '0')}:${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
|
||||
};
|
||||
|
||||
// Wait for fonts — but if font loading errored, render anyway (prevents Android freeze)
|
||||
if (!fontsLoaded && !fontError) return null;
|
||||
|
||||
const regular = fontError ? undefined : 'Inter_400Regular';
|
||||
const semibold = fontError ? undefined : 'Inter_600SemiBold';
|
||||
|
||||
const selectedTask = tasks.find((t: any) => t.id === activeTaskId);
|
||||
const canStart = !!activeTaskId;
|
||||
|
||||
const filteredTasks = tasks.filter((t: any) =>
|
||||
Array.isArray(t.insole_types) ? t.insole_types.includes(insoleType) : true
|
||||
);
|
||||
|
||||
return (
|
||||
<View style={{ flex: 1, backgroundColor: '#ffffff', paddingTop: insets.top }}>
|
||||
<ScrollView contentContainerStyle={{ padding: 24 }}>
|
||||
{/* 1. Type zool */}
|
||||
<View style={{ marginBottom: 24 }}>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 12,
|
||||
fontWeight: '500',
|
||||
color: '#6B7280',
|
||||
marginBottom: 8,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 0.5,
|
||||
fontFamily: semibold,
|
||||
}}
|
||||
>
|
||||
Type zool
|
||||
</Text>
|
||||
<View style={{ flexDirection: 'row' }}>
|
||||
{INSOLE_TYPES.map((type, i) => {
|
||||
const selected = insoleType === type;
|
||||
return (
|
||||
<TouchableOpacity
|
||||
key={type}
|
||||
onPress={() => {
|
||||
if (isRunning) return;
|
||||
setInsoleType(type);
|
||||
setActiveTaskId(null);
|
||||
}}
|
||||
disabled={isRunning}
|
||||
style={{
|
||||
flex: 1,
|
||||
paddingVertical: 14,
|
||||
borderRadius: 12,
|
||||
borderWidth: 2,
|
||||
borderColor: selected ? '#2563EB' : '#E5E7EB',
|
||||
backgroundColor: selected ? '#EFF6FF' : '#F9FAFB',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginRight: i < INSOLE_TYPES.length - 1 ? 10 : 0,
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
color: selected ? '#2563EB' : isRunning ? '#9CA3AF' : '#374151',
|
||||
fontFamily: semibold,
|
||||
}}
|
||||
>
|
||||
{type}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* 2. Type handeling */}
|
||||
<View style={{ marginBottom: 24 }}>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 12,
|
||||
fontWeight: '500',
|
||||
color: '#6B7280',
|
||||
marginBottom: 8,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 0.5,
|
||||
fontFamily: semibold,
|
||||
}}
|
||||
>
|
||||
Type handeling
|
||||
</Text>
|
||||
<TouchableOpacity
|
||||
onPress={() => !isRunning && openPicker()}
|
||||
disabled={isRunning}
|
||||
style={{
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 14,
|
||||
backgroundColor: isRunning ? '#F9FAFB' : '#ffffff',
|
||||
borderWidth: 1,
|
||||
borderColor: '#E5E7EB',
|
||||
borderRadius: 12,
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 16,
|
||||
color: activeTaskId ? '#111827' : '#9CA3AF',
|
||||
fontFamily: regular,
|
||||
}}
|
||||
>
|
||||
{selectedTask ? selectedTask.name : 'Kies een handeling...'}
|
||||
</Text>
|
||||
<ChevronDown color="#6B7280" size={20} />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
{/* 3. Aantal zolen */}
|
||||
<View style={{ marginBottom: 40 }}>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 12,
|
||||
fontWeight: '500',
|
||||
color: '#6B7280',
|
||||
marginBottom: 8,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 0.5,
|
||||
fontFamily: semibold,
|
||||
}}
|
||||
>
|
||||
Aantal zolen
|
||||
</Text>
|
||||
<View
|
||||
style={{
|
||||
flexDirection: 'row',
|
||||
alignItems: 'stretch',
|
||||
borderWidth: 1,
|
||||
borderColor: '#E5E7EB',
|
||||
borderRadius: 12,
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<TouchableOpacity
|
||||
onPress={() => adjustInsoleCount(-1)}
|
||||
disabled={isRunning || insoleCount <= 1}
|
||||
style={{
|
||||
width: 64,
|
||||
backgroundColor: '#F9FAFB',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
paddingVertical: 14,
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 28,
|
||||
lineHeight: 34,
|
||||
color: insoleCount <= 1 || isRunning ? '#D1D5DB' : '#111827',
|
||||
fontFamily: semibold,
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
−
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
<TextInput
|
||||
value={insoleCountText}
|
||||
onChangeText={handleInsoleCountChange}
|
||||
keyboardType="number-pad"
|
||||
editable={!isRunning}
|
||||
style={{
|
||||
flex: 1,
|
||||
textAlign: 'center',
|
||||
fontSize: 22,
|
||||
fontWeight: '600',
|
||||
color: isRunning ? '#9CA3AF' : '#111827',
|
||||
fontFamily: semibold,
|
||||
paddingVertical: Platform.OS === 'android' ? 10 : 14,
|
||||
paddingHorizontal: 0,
|
||||
}}
|
||||
/>
|
||||
<TouchableOpacity
|
||||
onPress={() => adjustInsoleCount(1)}
|
||||
disabled={isRunning}
|
||||
style={{
|
||||
width: 64,
|
||||
backgroundColor: '#F9FAFB',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
paddingVertical: 14,
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 28,
|
||||
lineHeight: 34,
|
||||
color: isRunning ? '#D1D5DB' : '#111827',
|
||||
fontFamily: semibold,
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
+
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* 4. Stopwatch display */}
|
||||
<TouchableOpacity
|
||||
onPress={() => {
|
||||
if (!isRunning && canStart) handleStart();
|
||||
else if (isRunning) {
|
||||
if (isPaused) handleResume();
|
||||
else handlePause();
|
||||
}
|
||||
}}
|
||||
activeOpacity={canStart || isRunning ? 0.75 : 1}
|
||||
style={{
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
paddingVertical: 60,
|
||||
backgroundColor: '#F9FAFB',
|
||||
borderRadius: 24,
|
||||
borderWidth: 1,
|
||||
borderColor: isPaused ? '#FDE68A' : '#E5E7EB',
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 64,
|
||||
fontWeight: '600',
|
||||
color: isRunning ? (isPaused ? '#D97706' : '#111827') : '#9CA3AF',
|
||||
fontFamily: semibold,
|
||||
letterSpacing: -2,
|
||||
}}
|
||||
>
|
||||
{formatTime(elapsedTime)}
|
||||
</Text>
|
||||
{isRunning ? (
|
||||
<View
|
||||
style={{
|
||||
marginTop: 16,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
backgroundColor: isPaused ? '#FFFBEB' : '#EFF6FF',
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 6,
|
||||
borderRadius: 999,
|
||||
}}
|
||||
>
|
||||
<View
|
||||
style={{
|
||||
width: 8,
|
||||
height: 8,
|
||||
borderRadius: 4,
|
||||
backgroundColor: isPaused ? '#F59E0B' : '#2563EB',
|
||||
marginRight: 8,
|
||||
}}
|
||||
/>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 12,
|
||||
color: isPaused ? '#D97706' : '#2563EB',
|
||||
fontWeight: '500',
|
||||
fontFamily: semibold,
|
||||
}}
|
||||
>
|
||||
{isPaused ? 'Gepauzeerd — tik om te hervatten' : 'Tik om te pauzeren'}
|
||||
</Text>
|
||||
</View>
|
||||
) : canStart ? (
|
||||
<View
|
||||
style={{
|
||||
marginTop: 16,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
backgroundColor: '#EFF6FF',
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 6,
|
||||
borderRadius: 999,
|
||||
}}
|
||||
>
|
||||
<View
|
||||
style={{
|
||||
width: 8,
|
||||
height: 8,
|
||||
borderRadius: 4,
|
||||
backgroundColor: '#2563EB',
|
||||
marginRight: 8,
|
||||
}}
|
||||
/>
|
||||
<Text
|
||||
style={{ fontSize: 12, color: '#2563EB', fontWeight: '500', fontFamily: semibold }}
|
||||
>
|
||||
Tik om te starten
|
||||
</Text>
|
||||
</View>
|
||||
) : null}
|
||||
</TouchableOpacity>
|
||||
|
||||
{/* 5. Knoppen */}
|
||||
<View style={{ marginTop: 40 }}>
|
||||
{!isRunning ? (
|
||||
<TouchableOpacity
|
||||
onPress={handleStart}
|
||||
disabled={!canStart}
|
||||
style={{
|
||||
backgroundColor: canStart ? '#2563EB' : '#E5E7EB',
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
paddingVertical: 18,
|
||||
borderRadius: 16,
|
||||
}}
|
||||
>
|
||||
<Play
|
||||
fill={canStart ? 'white' : '#9CA3AF'}
|
||||
color={canStart ? 'white' : '#9CA3AF'}
|
||||
size={24}
|
||||
style={{ marginRight: 8 }}
|
||||
/>
|
||||
<Text
|
||||
style={{
|
||||
color: canStart ? 'white' : '#9CA3AF',
|
||||
fontSize: 18,
|
||||
fontWeight: '600',
|
||||
fontFamily: semibold,
|
||||
}}
|
||||
>
|
||||
Start Stopwatch
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
) : (
|
||||
<>
|
||||
<TouchableOpacity
|
||||
onPress={handleStop}
|
||||
style={{
|
||||
backgroundColor: '#DC2626',
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
paddingVertical: 18,
|
||||
borderRadius: 16,
|
||||
marginBottom: 12,
|
||||
}}
|
||||
>
|
||||
<Square fill="white" color="white" size={22} style={{ marginRight: 8 }} />
|
||||
<Text
|
||||
style={{ color: 'white', fontSize: 18, fontWeight: '600', fontFamily: semibold }}
|
||||
>
|
||||
Stop & Opslaan
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
onPress={handleDiscard}
|
||||
style={{
|
||||
backgroundColor: discardPending ? '#374151' : '#F3F4F6',
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
paddingVertical: 18,
|
||||
borderRadius: 16,
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
style={{
|
||||
color: discardPending ? '#ffffff' : '#6B7280',
|
||||
fontSize: 18,
|
||||
fontWeight: '600',
|
||||
fontFamily: semibold,
|
||||
}}
|
||||
>
|
||||
{discardPending ? 'Nogmaals tikken ter bevestiging' : 'Annuleren'}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
</ScrollView>
|
||||
|
||||
{/* Bottom Sheet — uses Pressable instead of nested TouchableWithoutFeedback (Android fix) */}
|
||||
<Modal
|
||||
visible={showPicker}
|
||||
transparent
|
||||
animationType="none"
|
||||
onRequestClose={closePicker}
|
||||
statusBarTranslucent
|
||||
>
|
||||
<View style={{ flex: 1 }}>
|
||||
{/* Backdrop */}
|
||||
<Pressable
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
backgroundColor: 'rgba(0,0,0,0.45)',
|
||||
}}
|
||||
onPress={closePicker}
|
||||
/>
|
||||
{/* Sheet */}
|
||||
<Animated.View
|
||||
style={{
|
||||
position: 'absolute',
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: SHEET_HEIGHT,
|
||||
backgroundColor: '#ffffff',
|
||||
borderTopLeftRadius: 24,
|
||||
borderTopRightRadius: 24,
|
||||
transform: [{ translateY: slideAnim }],
|
||||
}}
|
||||
>
|
||||
{/* Drag handle */}
|
||||
<View style={{ alignItems: 'center', paddingTop: 12, paddingBottom: 4 }}>
|
||||
<View style={{ width: 40, height: 4, borderRadius: 2, backgroundColor: '#D1D5DB' }} />
|
||||
</View>
|
||||
|
||||
{/* Sheet header */}
|
||||
<View
|
||||
style={{
|
||||
paddingHorizontal: 24,
|
||||
paddingTop: 12,
|
||||
paddingBottom: 16,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: '#F3F4F6',
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
style={{ fontSize: 18, fontWeight: '600', color: '#111827', fontFamily: semibold }}
|
||||
>
|
||||
Type handeling
|
||||
</Text>
|
||||
<Text style={{ fontSize: 13, color: '#6B7280', marginTop: 2, fontFamily: regular }}>
|
||||
Kies een handeling
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{/* Task list */}
|
||||
<ScrollView
|
||||
contentContainerStyle={{ paddingVertical: 8, paddingBottom: insets.bottom + 32 }}
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
{filteredTasks.length === 0 ? (
|
||||
<View style={{ alignItems: 'center', paddingTop: 48, paddingHorizontal: 32 }}>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 15,
|
||||
color: '#9CA3AF',
|
||||
textAlign: 'center',
|
||||
fontFamily: regular,
|
||||
}}
|
||||
>
|
||||
Geen handelingen beschikbaar voor {insoleType} zolen. Voeg ze toe via
|
||||
Instellingen.
|
||||
</Text>
|
||||
</View>
|
||||
) : (
|
||||
filteredTasks.map((task: any) => {
|
||||
const selected = activeTaskId === task.id;
|
||||
return (
|
||||
<TouchableOpacity
|
||||
key={task.id}
|
||||
onPress={() => {
|
||||
setActiveTaskId(task.id);
|
||||
closePicker();
|
||||
}}
|
||||
style={{
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: 24,
|
||||
paddingVertical: 18,
|
||||
backgroundColor: selected ? '#F0F7FF' : '#ffffff',
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: '#F3F4F6',
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
style={{
|
||||
flex: 1,
|
||||
fontSize: 16,
|
||||
color: selected ? '#2563EB' : '#374151',
|
||||
fontFamily: selected ? semibold : regular,
|
||||
}}
|
||||
>
|
||||
{task.name}
|
||||
</Text>
|
||||
{selected && <Check size={20} color="#2563EB" />}
|
||||
</TouchableOpacity>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</ScrollView>
|
||||
</Animated.View>
|
||||
</View>
|
||||
</Modal>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
574
apps/mobile/src/app/(tabs)/tasks.tsx
Normal file
574
apps/mobile/src/app/(tabs)/tasks.tsx
Normal file
@@ -0,0 +1,574 @@
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
ScrollView,
|
||||
TouchableOpacity,
|
||||
TextInput,
|
||||
ActivityIndicator,
|
||||
KeyboardAvoidingView,
|
||||
Platform,
|
||||
Alert,
|
||||
} from 'react-native';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
import { Plus, Pencil, Trash2, Check, X } from 'lucide-react-native';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useFonts, Inter_400Regular, Inter_600SemiBold } from '@expo-google-fonts/inter';
|
||||
|
||||
const BASE_URL = process.env.EXPO_PUBLIC_BASE_URL;
|
||||
const ALL_TYPES = ['Kurk', 'Berk', '3D'] as const;
|
||||
type InsoleType = (typeof ALL_TYPES)[number];
|
||||
|
||||
const TYPE_COLORS: Record<InsoleType, { bg: string; border: string; text: string }> = {
|
||||
Kurk: { bg: '#FEF9C3', border: '#FDE047', text: '#854D0E' },
|
||||
Berk: { bg: '#DCFCE7', border: '#86EFAC', text: '#166534' },
|
||||
'3D': { bg: '#EDE9FE', border: '#C4B5FD', text: '#5B21B6' },
|
||||
};
|
||||
|
||||
function TypeToggle({
|
||||
type,
|
||||
selected,
|
||||
onPress,
|
||||
}: {
|
||||
type: InsoleType;
|
||||
selected: boolean;
|
||||
onPress: () => void;
|
||||
}) {
|
||||
const c = TYPE_COLORS[type];
|
||||
return (
|
||||
<TouchableOpacity
|
||||
onPress={onPress}
|
||||
style={{
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 7,
|
||||
borderRadius: 999,
|
||||
borderWidth: 2,
|
||||
borderColor: selected ? c.border : '#E5E7EB',
|
||||
backgroundColor: selected ? c.bg : '#F9FAFB',
|
||||
}}
|
||||
>
|
||||
{selected && <Check size={13} color={c.text} />}
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 13,
|
||||
fontWeight: '600',
|
||||
color: selected ? c.text : '#9CA3AF',
|
||||
fontFamily: 'Inter_600SemiBold',
|
||||
}}
|
||||
>
|
||||
{type}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
}
|
||||
|
||||
function TypeBadge({ type }: { type: InsoleType }) {
|
||||
const c = TYPE_COLORS[type];
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
paddingHorizontal: 8,
|
||||
paddingVertical: 3,
|
||||
borderRadius: 999,
|
||||
backgroundColor: c.bg,
|
||||
borderWidth: 1,
|
||||
borderColor: c.border,
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
style={{ fontSize: 11, fontWeight: '600', color: c.text, fontFamily: 'Inter_600SemiBold' }}
|
||||
>
|
||||
{type}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
export default function TasksScreen() {
|
||||
const insets = useSafeAreaInsets();
|
||||
const queryClient = useQueryClient();
|
||||
const [fontsLoaded, fontError] = useFonts({ Inter_400Regular, Inter_600SemiBold });
|
||||
|
||||
const [newTaskName, setNewTaskName] = useState('');
|
||||
const [newTaskTypes, setNewTaskTypes] = useState<InsoleType[]>(['Kurk', 'Berk', '3D']);
|
||||
const [editingId, setEditingId] = useState<number | null>(null);
|
||||
const [editingName, setEditingName] = useState('');
|
||||
const [editingTypes, setEditingTypes] = useState<InsoleType[]>([]);
|
||||
|
||||
const { data: tasks = [], isLoading } = useQuery({
|
||||
queryKey: ['tasks'],
|
||||
queryFn: async () => {
|
||||
const res = await fetch(`${BASE_URL}/api/tasks`);
|
||||
if (!res.ok) throw new Error('Failed to fetch tasks');
|
||||
return res.json();
|
||||
},
|
||||
});
|
||||
|
||||
const addTaskMutation = useMutation({
|
||||
mutationFn: async ({ name, insole_types }: { name: string; insole_types: string[] }) => {
|
||||
const res = await fetch(`${BASE_URL}/api/tasks`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name, insole_types }),
|
||||
});
|
||||
if (!res.ok) throw new Error('Failed to add task');
|
||||
return res.json();
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['tasks'] });
|
||||
setNewTaskName('');
|
||||
setNewTaskTypes(['Kurk', 'Berk', '3D']);
|
||||
},
|
||||
});
|
||||
|
||||
const updateTaskMutation = useMutation({
|
||||
mutationFn: async ({
|
||||
id,
|
||||
name,
|
||||
insole_types,
|
||||
}: {
|
||||
id: number;
|
||||
name: string;
|
||||
insole_types: string[];
|
||||
}) => {
|
||||
const res = await fetch(`${BASE_URL}/api/tasks/${id}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name, insole_types }),
|
||||
});
|
||||
if (!res.ok) throw new Error('Failed to update task');
|
||||
return res.json();
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['tasks'] });
|
||||
setEditingId(null);
|
||||
},
|
||||
});
|
||||
|
||||
const deleteTaskMutation = useMutation({
|
||||
mutationFn: async (id: number) => {
|
||||
const res = await fetch(`${BASE_URL}/api/tasks/${id}`, { method: 'DELETE' });
|
||||
if (!res.ok) throw new Error('Failed to delete task');
|
||||
return res.json();
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['tasks'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['logs'] });
|
||||
},
|
||||
});
|
||||
|
||||
const toggleNewType = (type: InsoleType) => {
|
||||
setNewTaskTypes((prev) =>
|
||||
prev.includes(type) ? prev.filter((t) => t !== type) : [...prev, type]
|
||||
);
|
||||
};
|
||||
|
||||
const toggleEditType = (type: InsoleType) => {
|
||||
setEditingTypes((prev) =>
|
||||
prev.includes(type) ? prev.filter((t) => t !== type) : [...prev, type]
|
||||
);
|
||||
};
|
||||
|
||||
const handleAddTask = () => {
|
||||
if (!newTaskName.trim() || newTaskTypes.length === 0) return;
|
||||
addTaskMutation.mutate({ name: newTaskName.trim(), insole_types: newTaskTypes });
|
||||
};
|
||||
|
||||
const handleStartEdit = (task: any) => {
|
||||
setEditingId(task.id);
|
||||
setEditingName(task.name);
|
||||
setEditingTypes(Array.isArray(task.insole_types) ? task.insole_types : ['Kurk', 'Berk', '3D']);
|
||||
};
|
||||
|
||||
const handleConfirmEdit = () => {
|
||||
if (!editingName.trim() || editingId === null || editingTypes.length === 0) return;
|
||||
updateTaskMutation.mutate({
|
||||
id: editingId,
|
||||
name: editingName.trim(),
|
||||
insole_types: editingTypes,
|
||||
});
|
||||
};
|
||||
|
||||
const handleCancelEdit = () => {
|
||||
setEditingId(null);
|
||||
setEditingName('');
|
||||
setEditingTypes([]);
|
||||
};
|
||||
|
||||
const handleDelete = (task: any) => {
|
||||
Alert.alert(
|
||||
'Taak verwijderen',
|
||||
`"${task.name}" verwijderen? Alle tijdsregistraties voor deze taak worden ook verwijderd.`,
|
||||
[
|
||||
{ text: 'Annuleren', style: 'cancel' },
|
||||
{
|
||||
text: 'Verwijderen',
|
||||
style: 'destructive',
|
||||
onPress: () => deleteTaskMutation.mutate(task.id),
|
||||
},
|
||||
]
|
||||
);
|
||||
};
|
||||
|
||||
if (!fontsLoaded && !fontError) return null;
|
||||
|
||||
return (
|
||||
<KeyboardAvoidingView
|
||||
style={{ flex: 1, backgroundColor: '#ffffff' }}
|
||||
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
|
||||
>
|
||||
<View style={{ paddingTop: insets.top, flex: 1 }}>
|
||||
{/* Header */}
|
||||
<View
|
||||
style={{
|
||||
paddingHorizontal: 24,
|
||||
paddingVertical: 16,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: '#E5E7EB',
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 24,
|
||||
fontWeight: '600',
|
||||
color: '#111827',
|
||||
fontFamily: 'Inter_600SemiBold',
|
||||
}}
|
||||
>
|
||||
Instellingen
|
||||
</Text>
|
||||
<Text
|
||||
style={{ fontSize: 14, color: '#6B7280', marginTop: 4, fontFamily: 'Inter_400Regular' }}
|
||||
>
|
||||
Beheer handelingen per zooltype
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<ScrollView
|
||||
contentContainerStyle={{ padding: 20, paddingBottom: 60 }}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
>
|
||||
{/* Add New Task */}
|
||||
<View
|
||||
style={{
|
||||
backgroundColor: '#F9FAFB',
|
||||
borderRadius: 16,
|
||||
padding: 16,
|
||||
borderWidth: 1,
|
||||
borderColor: '#E5E7EB',
|
||||
marginBottom: 28,
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 12,
|
||||
fontWeight: '600',
|
||||
color: '#6B7280',
|
||||
marginBottom: 12,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 0.5,
|
||||
fontFamily: 'Inter_600SemiBold',
|
||||
}}
|
||||
>
|
||||
Nieuwe handeling toevoegen
|
||||
</Text>
|
||||
|
||||
{/* Name input */}
|
||||
<TextInput
|
||||
value={newTaskName}
|
||||
onChangeText={setNewTaskName}
|
||||
placeholder="Naam van de stap, bijv. Leerrand"
|
||||
placeholderTextColor="#9CA3AF"
|
||||
returnKeyType="done"
|
||||
onSubmitEditing={handleAddTask}
|
||||
style={{
|
||||
backgroundColor: '#ffffff',
|
||||
borderWidth: 1,
|
||||
borderColor: '#E5E7EB',
|
||||
borderRadius: 10,
|
||||
paddingHorizontal: 14,
|
||||
paddingVertical: 11,
|
||||
fontSize: 15,
|
||||
color: '#111827',
|
||||
fontFamily: 'Inter_400Regular',
|
||||
marginBottom: 12,
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Insole type toggles */}
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 11,
|
||||
fontWeight: '600',
|
||||
color: '#9CA3AF',
|
||||
marginBottom: 8,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 0.4,
|
||||
fontFamily: 'Inter_600SemiBold',
|
||||
}}
|
||||
>
|
||||
Van toepassing op
|
||||
</Text>
|
||||
<View style={{ flexDirection: 'row', gap: 8, marginBottom: 14 }}>
|
||||
{ALL_TYPES.map((type) => (
|
||||
<TypeToggle
|
||||
key={type}
|
||||
type={type}
|
||||
selected={newTaskTypes.includes(type)}
|
||||
onPress={() => toggleNewType(type)}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
|
||||
<TouchableOpacity
|
||||
onPress={handleAddTask}
|
||||
disabled={
|
||||
addTaskMutation.isPending || !newTaskName.trim() || newTaskTypes.length === 0
|
||||
}
|
||||
style={{
|
||||
backgroundColor:
|
||||
newTaskName.trim() && newTaskTypes.length > 0 ? '#2563EB' : '#E5E7EB',
|
||||
borderRadius: 10,
|
||||
paddingVertical: 12,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
flexDirection: 'row',
|
||||
gap: 8,
|
||||
}}
|
||||
>
|
||||
{addTaskMutation.isPending ? (
|
||||
<ActivityIndicator color="white" size="small" />
|
||||
) : (
|
||||
<>
|
||||
<Plus
|
||||
color={newTaskName.trim() && newTaskTypes.length > 0 ? 'white' : '#9CA3AF'}
|
||||
size={18}
|
||||
/>
|
||||
<Text
|
||||
style={{
|
||||
color: newTaskName.trim() && newTaskTypes.length > 0 ? 'white' : '#9CA3AF',
|
||||
fontSize: 15,
|
||||
fontWeight: '600',
|
||||
fontFamily: 'Inter_600SemiBold',
|
||||
}}
|
||||
>
|
||||
Stap toevoegen
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
{/* Task List */}
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 12,
|
||||
fontWeight: '600',
|
||||
color: '#6B7280',
|
||||
marginBottom: 12,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 0.5,
|
||||
fontFamily: 'Inter_600SemiBold',
|
||||
}}
|
||||
>
|
||||
Huidige stappen ({tasks.length})
|
||||
</Text>
|
||||
|
||||
{isLoading ? (
|
||||
<ActivityIndicator color="#2563EB" style={{ marginTop: 40 }} />
|
||||
) : tasks.length === 0 ? (
|
||||
<View style={{ alignItems: 'center', marginTop: 40 }}>
|
||||
<Text style={{ color: '#9CA3AF', fontSize: 15, fontFamily: 'Inter_400Regular' }}>
|
||||
Nog geen stappen. Voeg er een toe hierboven.
|
||||
</Text>
|
||||
</View>
|
||||
) : (
|
||||
tasks.map((task: any) => {
|
||||
const types: InsoleType[] = Array.isArray(task.insole_types) ? task.insole_types : [];
|
||||
const isEditing = editingId === task.id;
|
||||
|
||||
return (
|
||||
<View
|
||||
key={task.id}
|
||||
style={{
|
||||
backgroundColor: '#ffffff',
|
||||
borderRadius: 12,
|
||||
borderWidth: 1,
|
||||
borderColor: isEditing ? '#2563EB' : '#E5E7EB',
|
||||
padding: 14,
|
||||
marginBottom: 10,
|
||||
}}
|
||||
>
|
||||
{isEditing ? (
|
||||
<>
|
||||
{/* Edit name */}
|
||||
<TextInput
|
||||
value={editingName}
|
||||
onChangeText={setEditingName}
|
||||
autoFocus
|
||||
returnKeyType="done"
|
||||
onSubmitEditing={handleConfirmEdit}
|
||||
style={{
|
||||
fontSize: 15,
|
||||
color: '#111827',
|
||||
fontFamily: 'Inter_400Regular',
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: '#E5E7EB',
|
||||
paddingBottom: 8,
|
||||
marginBottom: 12,
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Edit insole types */}
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 11,
|
||||
fontWeight: '600',
|
||||
color: '#9CA3AF',
|
||||
marginBottom: 8,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 0.4,
|
||||
fontFamily: 'Inter_600SemiBold',
|
||||
}}
|
||||
>
|
||||
Van toepassing op
|
||||
</Text>
|
||||
<View style={{ flexDirection: 'row', gap: 8, marginBottom: 14 }}>
|
||||
{ALL_TYPES.map((type) => (
|
||||
<TypeToggle
|
||||
key={type}
|
||||
type={type}
|
||||
selected={editingTypes.includes(type)}
|
||||
onPress={() => toggleEditType(type)}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
|
||||
{/* Confirm / Cancel */}
|
||||
<View style={{ flexDirection: 'row', gap: 8 }}>
|
||||
<TouchableOpacity
|
||||
onPress={handleConfirmEdit}
|
||||
disabled={updateTaskMutation.isPending || editingTypes.length === 0}
|
||||
style={{
|
||||
flex: 1,
|
||||
backgroundColor: '#DCFCE7',
|
||||
borderRadius: 8,
|
||||
paddingVertical: 10,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
flexDirection: 'row',
|
||||
gap: 6,
|
||||
}}
|
||||
>
|
||||
{updateTaskMutation.isPending ? (
|
||||
<ActivityIndicator color="#16A34A" size="small" />
|
||||
) : (
|
||||
<>
|
||||
<Check size={16} color="#16A34A" />
|
||||
<Text
|
||||
style={{
|
||||
color: '#16A34A',
|
||||
fontWeight: '600',
|
||||
fontFamily: 'Inter_600SemiBold',
|
||||
fontSize: 14,
|
||||
}}
|
||||
>
|
||||
Opslaan
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
onPress={handleCancelEdit}
|
||||
style={{
|
||||
flex: 1,
|
||||
backgroundColor: '#F3F4F6',
|
||||
borderRadius: 8,
|
||||
paddingVertical: 10,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
flexDirection: 'row',
|
||||
gap: 6,
|
||||
}}
|
||||
>
|
||||
<X size={16} color="#6B7280" />
|
||||
<Text
|
||||
style={{
|
||||
color: '#6B7280',
|
||||
fontWeight: '600',
|
||||
fontFamily: 'Inter_600SemiBold',
|
||||
fontSize: 14,
|
||||
}}
|
||||
>
|
||||
Annuleren
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{/* Task name + actions */}
|
||||
<View
|
||||
style={{ flexDirection: 'row', alignItems: 'center', marginBottom: 10 }}
|
||||
>
|
||||
<Text
|
||||
style={{
|
||||
flex: 1,
|
||||
fontSize: 15,
|
||||
color: '#374151',
|
||||
fontFamily: 'Inter_400Regular',
|
||||
}}
|
||||
>
|
||||
{task.name}
|
||||
</Text>
|
||||
<View style={{ flexDirection: 'row', gap: 8 }}>
|
||||
<TouchableOpacity
|
||||
onPress={() => handleStartEdit(task)}
|
||||
style={{
|
||||
backgroundColor: '#EFF6FF',
|
||||
borderRadius: 8,
|
||||
width: 36,
|
||||
height: 36,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
<Pencil color="#2563EB" size={16} />
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
onPress={() => handleDelete(task)}
|
||||
disabled={deleteTaskMutation.isPending}
|
||||
style={{
|
||||
backgroundColor: '#FEF2F2',
|
||||
borderRadius: 8,
|
||||
width: 36,
|
||||
height: 36,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
<Trash2 color="#DC2626" size={16} />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Insole type badges */}
|
||||
<View style={{ flexDirection: 'row', gap: 6 }}>
|
||||
{types.map((type) => (
|
||||
<TypeBadge key={type} type={type} />
|
||||
))}
|
||||
</View>
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</ScrollView>
|
||||
</View>
|
||||
</KeyboardAvoidingView>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user