From 0d6c17cb2c48e89a69b3ba6afab8712dae7ca49c Mon Sep 17 00:00:00 2001 From: Samu Date: Mon, 26 Jan 2026 20:15:00 +0200 Subject: [PATCH] The base views for all sub screens in healthscreen/mock data removed --- mobile/package.json | 1 + mobile/src/config/.gitkeep | 0 mobile/src/navigation/Navigation.tsx | 45 ++ mobile/src/screens/HealthScreen.tsx | 80 ++ mobile/src/screens/HomeScreen.tsx | 2 +- mobile/src/screens/MedicationsScreen.tsx | 347 ++++++++ mobile/src/screens/VaccinationsScreen.tsx | 369 +++++++++ mobile/src/screens/VisitsScreen.tsx | 299 +++++++ mobile/src/screens/WeightManagementScreen.tsx | 749 ++++++++++++++++++ mobile/src/services/.gitkeep | 0 mobile/src/types/.gitkeep | 0 mobile/src/utils/.gitkeep | 0 package-lock.json | 144 ++++ 13 files changed, 2035 insertions(+), 1 deletion(-) delete mode 100644 mobile/src/config/.gitkeep create mode 100644 mobile/src/screens/HealthScreen.tsx create mode 100644 mobile/src/screens/MedicationsScreen.tsx create mode 100644 mobile/src/screens/VaccinationsScreen.tsx create mode 100644 mobile/src/screens/VisitsScreen.tsx create mode 100644 mobile/src/screens/WeightManagementScreen.tsx delete mode 100644 mobile/src/services/.gitkeep delete mode 100644 mobile/src/types/.gitkeep delete mode 100644 mobile/src/utils/.gitkeep diff --git a/mobile/package.json b/mobile/package.json index db3dde9..c2d6001 100644 --- a/mobile/package.json +++ b/mobile/package.json @@ -30,6 +30,7 @@ "react-native-reanimated": "~4.1.1", "react-native-safe-area-context": "~5.6.0", "react-native-screens": "~4.16.0", + "react-native-svg": "^15.15.1", "react-native-worklets": "^0.5.1" }, "devDependencies": { diff --git a/mobile/src/config/.gitkeep b/mobile/src/config/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/mobile/src/navigation/Navigation.tsx b/mobile/src/navigation/Navigation.tsx index 9eb758f..a4dbe45 100644 --- a/mobile/src/navigation/Navigation.tsx +++ b/mobile/src/navigation/Navigation.tsx @@ -15,6 +15,11 @@ import RegisterScreen from '@screens/RegisterScreen'; import MapScreen from '@screens/MapScreen'; import WalkHistoryScreen from '@screens/WalkHistoryScreen'; import WalkDetailScreen from '@screens/WalkDetailScreen'; +import HealthScreen from '@screens/HealthScreen'; +import VisitsScreen from '@screens/VisitsScreen'; +import MedicationsScreen from '@screens/MedicationsScreen'; +import VaccinationsScreen from '@screens/VaccinationsScreen'; +import WeightManagementScreen from '@screens/WeightManagementScreen'; const Stack = createNativeStackNavigator(); const Tab = createBottomTabNavigator(); @@ -175,6 +180,46 @@ export default function Navigation() { headerBackTitle: 'Takaisin', }} /> + + + + + ) : ( <> diff --git a/mobile/src/screens/HealthScreen.tsx b/mobile/src/screens/HealthScreen.tsx new file mode 100644 index 0000000..523a468 --- /dev/null +++ b/mobile/src/screens/HealthScreen.tsx @@ -0,0 +1,80 @@ +import React, { useEffect, useRef } from 'react'; +import { View, ScrollView, Animated } from 'react-native'; +import { Text, Card } from 'react-native-paper'; +import { MaterialCommunityIcons } from '@expo/vector-icons'; +import { useNavigation } from '@react-navigation/native'; +import { homeStyles as styles } from '../styles/screenStyles'; +import { COLORS, SPACING } from '../styles/theme'; + +export default function HealthScreen() { + const navigation = useNavigation(); + const pulse = useRef(new Animated.Value(1)).current; + + useEffect(() => { + const animatePulse = () => { + Animated.sequence([ + Animated.timing(pulse, { toValue: 1.2, duration: 800, useNativeDriver: true }), + Animated.timing(pulse, { toValue: 1, duration: 800, useNativeDriver: true }), + Animated.delay(500), + ]).start(() => animatePulse()); + }; + animatePulse(); + }, []); + + return ( + + + + + + + + + Pidä huolta lemmikin terveydestä + + + + + navigation.navigate('Visits' as never)}> + + + Käynnit + + Eläinlääkäri- ja klinikkakäynnit + + + + + navigation.navigate('Medications' as never)}> + + + Lääkitykset + + Lääkkeet ja hoito-ohjelmat + + + + + navigation.navigate('Vaccinations' as never)}> + + + Rokotukset + + Rokotushistoria ja muistutukset + + + + + navigation.navigate('WeightManagement' as never)}> + + + Painonhallinta + + Seuraa painoa ja kasvua + + + + + + ); +} diff --git a/mobile/src/screens/HomeScreen.tsx b/mobile/src/screens/HomeScreen.tsx index 3fecc87..7a12aa7 100644 --- a/mobile/src/screens/HomeScreen.tsx +++ b/mobile/src/screens/HomeScreen.tsx @@ -82,7 +82,7 @@ export default function HomeScreen() { - console.log('Terveys')}> + navigation.navigate('Health' as never)}> Terveys diff --git a/mobile/src/screens/MedicationsScreen.tsx b/mobile/src/screens/MedicationsScreen.tsx new file mode 100644 index 0000000..d3713a7 --- /dev/null +++ b/mobile/src/screens/MedicationsScreen.tsx @@ -0,0 +1,347 @@ +import React, { useState } from 'react'; +import { View, ScrollView, StyleSheet } from 'react-native'; +import { Text, Card, FAB, Chip, Divider } from 'react-native-paper'; +import { MaterialCommunityIcons } from '@expo/vector-icons'; +import { COLORS, SPACING } from '../styles/theme'; + +interface Pet { + id: string; + name: string; + breed: string; + age: number; +} + +interface Medication { + id: string; + petId: string; + name: string; + dosage: string; + frequency: string; + startDate: string; + endDate?: string; + purpose: string; + prescribedBy: string; + notes?: string; + isActive: boolean; +} + +export default function MedicationsScreen() { + // Mock data - replace with actual data from context/API + const [pets] = useState([]); + + const [medications] = useState([]); + + const [selectedPetId, setSelectedPetId] = useState(pets[0]?.id || ''); + + const selectedPetMedications = medications + .filter(med => med.petId === selectedPetId) + .sort((a, b) => { + if (a.isActive === b.isActive) { + return new Date(b.startDate).getTime() - new Date(a.startDate).getTime(); + } + return a.isActive ? -1 : 1; + }); + + const formatDate = (dateString: string) => { + const date = new Date(dateString); + return date.toLocaleDateString('fi-FI', { + day: 'numeric', + month: 'long', + year: 'numeric', + }); + }; + + const renderMedicationCard = (medication: Medication) => ( + + + + + {medication.name} + + + {medication.isActive ? 'Aktiivinen' : 'Päättynyt'} + + + + + + + {medication.dosage} - {medication.frequency} + + + + + + + + + {medication.purpose} + + + + + + + {medication.prescribedBy} + + + + + + + Aloitettu: {formatDate(medication.startDate)} + + + + {medication.endDate && ( + + + + Päättynyt: {formatDate(medication.endDate)} + + + )} + + {medication.notes && ( + <> + + + + + {medication.notes} + + + + )} + + + ); + + const renderEmptyState = () => ( + + + + Ei lääkityksiä + + + Lisää ensimmäinen lääkitys + + + ); + + if (pets.length === 0) { + return ( + + + + + Ei lemmikkejä + + + Lisää ensin lemmikki + + + + ); + } + + return ( + + + {pets.map((pet) => ( + setSelectedPetId(pet.id)} + style={[ + styles.tab, + selectedPetId === pet.id && styles.selectedTab + ]} + textStyle={selectedPetId === pet.id ? styles.selectedTabText : styles.unselectedTabText} + icon={() => ( + + )} + > + {pet.name} + + ))} + + + + {selectedPetMedications.length === 0 ? ( + renderEmptyState() + ) : ( + selectedPetMedications.map(renderMedicationCard) + )} + + + console.log('Lisää lääkitys')} + label="Lisää lääkitys" + /> + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: COLORS.background, + }, + header: { + paddingHorizontal: SPACING.lg, + paddingTop: SPACING.lg, + paddingBottom: SPACING.md, + }, + title: { + fontWeight: 'bold', + color: COLORS.onBackground, + }, + tabsContainer: { + maxHeight: 70, + backgroundColor: COLORS.surface, + borderBottomWidth: 1, + borderBottomColor: COLORS.surfaceVariant, + }, + tabsContent: { + paddingHorizontal: SPACING.md, + paddingVertical: SPACING.md, + gap: SPACING.sm, + alignItems: 'center', + justifyContent: 'center', + flexGrow: 1, + }, + tab: { + marginHorizontal: SPACING.xs, + paddingHorizontal: SPACING.md, + height: 40, + justifyContent: 'center', + alignItems: 'center', + }, + selectedTab: { + backgroundColor: COLORS.primary, + elevation: 3, + }, + selectedTabText: { + color: '#FFFFFF', + fontWeight: '700', + fontSize: 15, + lineHeight: 20, + }, + unselectedTabText: { + fontSize: 15, + lineHeight: 20, + }, + content: { + flex: 1, + }, + scrollContent: { + padding: SPACING.md, + }, + medicationCard: { + marginBottom: SPACING.md, + elevation: 2, + }, + medicationHeader: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + marginBottom: SPACING.sm, + }, + medicationName: { + fontWeight: '700', + color: COLORS.primary, + flex: 1, + }, + statusChip: { + marginLeft: SPACING.sm, + }, + activeChip: { + backgroundColor: '#E8F5E9', + }, + inactiveChip: { + backgroundColor: COLORS.surfaceVariant, + }, + activeChipText: { + color: '#2E7D32', + }, + inactiveChipText: { + color: COLORS.onSurfaceVariant, + }, + dosageContainer: { + flexDirection: 'row', + alignItems: 'center', + gap: SPACING.xs, + marginBottom: SPACING.sm, + }, + dosage: { + fontWeight: '600', + color: COLORS.onSurface, + }, + divider: { + marginVertical: SPACING.sm, + }, + medicationDetail: { + flexDirection: 'row', + alignItems: 'center', + gap: SPACING.sm, + marginBottom: SPACING.xs, + }, + detailText: { + flex: 1, + color: COLORS.onSurface, + }, + notesContainer: { + flexDirection: 'row', + gap: SPACING.sm, + marginTop: SPACING.xs, + }, + notesText: { + flex: 1, + color: COLORS.onSurfaceVariant, + fontStyle: 'italic', + }, + emptyContainer: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + paddingVertical: SPACING.xl * 2, + }, + emptyTitle: { + marginTop: SPACING.md, + color: COLORS.onSurfaceVariant, + }, + emptyText: { + marginTop: SPACING.xs, + color: COLORS.onSurfaceVariant, + textAlign: 'center', + }, + fab: { + position: 'absolute', + right: SPACING.md, + bottom: SPACING.md, + backgroundColor: COLORS.primary, + }, +}); diff --git a/mobile/src/screens/VaccinationsScreen.tsx b/mobile/src/screens/VaccinationsScreen.tsx new file mode 100644 index 0000000..b6c3863 --- /dev/null +++ b/mobile/src/screens/VaccinationsScreen.tsx @@ -0,0 +1,369 @@ +import React, { useState } from 'react'; +import { View, ScrollView, StyleSheet } from 'react-native'; +import { Text, Card, FAB, Chip, Divider } from 'react-native-paper'; +import { MaterialCommunityIcons } from '@expo/vector-icons'; +import { COLORS, SPACING } from '../styles/theme'; + +interface Pet { + id: string; + name: string; + breed: string; + age: number; +} + +interface Vaccination { + id: string; + petId: string; + name: string; + date: string; + nextDueDate?: string; + batchNumber?: string; + veterinarian: string; + clinic: string; + notes?: string; + isUpToDate: boolean; +} + +export default function VaccinationsScreen() { + // Mock data - replace with actual data from context/API + const [pets] = useState([]); + + const [vaccinations] = useState([]); + + const [selectedPetId, setSelectedPetId] = useState(pets[0]?.id || ''); + + const selectedPetVaccinations = vaccinations + .filter(vac => vac.petId === selectedPetId) + .sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime()); + + const formatDate = (dateString: string) => { + const date = new Date(dateString); + return date.toLocaleDateString('fi-FI', { + day: 'numeric', + month: 'long', + year: 'numeric', + }); + }; + + const isOverdue = (nextDueDate?: string) => { + if (!nextDueDate) return false; + return new Date(nextDueDate) < new Date(); + }; + + const renderVaccinationCard = (vaccination: Vaccination) => { + const overdue = isOverdue(vaccination.nextDueDate); + + return ( + + + + + {vaccination.name} + + + {overdue ? 'Myöhässä' : vaccination.isUpToDate ? 'Voimassa' : 'Odottaa'} + + + + + + + Annettu: {formatDate(vaccination.date)} + + + + {vaccination.nextDueDate && ( + + + + Voimassa: {formatDate(vaccination.nextDueDate)} + + + )} + + + + + + + {vaccination.clinic} + + + + + + + {vaccination.veterinarian} + + + + {vaccination.notes && ( + <> + + + + + {vaccination.notes} + + + + )} + + + ); + }; + + const renderEmptyState = () => ( + + + + Ei rokotuksia + + + Lisää ensimmäinen rokotus + + + ); + + if (pets.length === 0) { + return ( + + + + + Ei lemmikkejä + + + Lisää ensin lemmikki + + + + ); + } + + return ( + + + {pets.map((pet) => ( + setSelectedPetId(pet.id)} + style={[ + styles.tab, + selectedPetId === pet.id && styles.selectedTab + ]} + textStyle={selectedPetId === pet.id ? styles.selectedTabText : styles.unselectedTabText} + icon={() => ( + + )} + > + {pet.name} + + ))} + + + + {selectedPetVaccinations.length === 0 ? ( + renderEmptyState() + ) : ( + selectedPetVaccinations.map(renderVaccinationCard) + )} + + + console.log('Lisää rokotus')} + label="Lisää rokotus" + /> + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: COLORS.background, + }, + header: { + paddingHorizontal: SPACING.lg, + paddingTop: SPACING.lg, + paddingBottom: SPACING.md, + }, + title: { + fontWeight: 'bold', + color: COLORS.onBackground, + }, + tabsContainer: { + maxHeight: 70, + backgroundColor: COLORS.surface, + borderBottomWidth: 1, + borderBottomColor: COLORS.surfaceVariant, + }, + tabsContent: { + paddingHorizontal: SPACING.md, + paddingVertical: SPACING.md, + gap: SPACING.sm, + alignItems: 'center', + justifyContent: 'center', + flexGrow: 1, + }, + tab: { + marginHorizontal: SPACING.xs, + paddingHorizontal: SPACING.md, + height: 40, + justifyContent: 'center', + alignItems: 'center', + }, + selectedTab: { + backgroundColor: COLORS.primary, + elevation: 3, + }, + selectedTabText: { + color: '#FFFFFF', + fontWeight: '700', + fontSize: 15, + lineHeight: 20, + }, + unselectedTabText: { + fontSize: 15, + lineHeight: 20, + }, + content: { + flex: 1, + }, + scrollContent: { + padding: SPACING.md, + }, + vaccinationCard: { + marginBottom: SPACING.md, + elevation: 2, + }, + vaccinationHeader: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + marginBottom: SPACING.sm, + }, + vaccinationName: { + fontWeight: '700', + color: COLORS.primary, + flex: 1, + }, + statusChip: { + marginLeft: SPACING.sm, + }, + upToDateChip: { + backgroundColor: '#E8F5E9', + }, + pendingChip: { + backgroundColor: '#FFF8E1', + }, + overdueChip: { + backgroundColor: '#FFEBEE', + }, + upToDateChipText: { + color: '#2E7D32', + }, + pendingChipText: { + color: '#F57C00', + }, + overdueChipText: { + color: '#D32F2F', + }, + dateContainer: { + flexDirection: 'row', + alignItems: 'center', + gap: SPACING.xs, + }, + dateText: { + fontWeight: '600', + color: COLORS.onSurface, + }, + nextDueText: { + color: COLORS.onSurfaceVariant, + }, + overdueText: { + color: '#D32F2F', + fontWeight: '600', + }, + divider: { + marginVertical: SPACING.sm, + }, + vaccinationDetail: { + flexDirection: 'row', + alignItems: 'center', + gap: SPACING.sm, + marginBottom: SPACING.xs, + }, + detailText: { + flex: 1, + color: COLORS.onSurface, + }, + notesContainer: { + flexDirection: 'row', + gap: SPACING.sm, + marginTop: SPACING.xs, + }, + notesText: { + flex: 1, + color: COLORS.onSurfaceVariant, + fontStyle: 'italic', + }, + emptyContainer: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + paddingVertical: SPACING.xl * 2, + }, + emptyTitle: { + marginTop: SPACING.md, + color: COLORS.onSurfaceVariant, + }, + emptyText: { + marginTop: SPACING.xs, + color: COLORS.onSurfaceVariant, + textAlign: 'center', + }, + fab: { + position: 'absolute', + right: SPACING.md, + bottom: SPACING.md, + backgroundColor: COLORS.primary, + }, +}); diff --git a/mobile/src/screens/VisitsScreen.tsx b/mobile/src/screens/VisitsScreen.tsx new file mode 100644 index 0000000..ffc7547 --- /dev/null +++ b/mobile/src/screens/VisitsScreen.tsx @@ -0,0 +1,299 @@ +import React, { useState } from 'react'; +import { View, ScrollView, StyleSheet } from 'react-native'; +import { Text, Card, FAB, Chip, Divider } from 'react-native-paper'; +import { MaterialCommunityIcons } from '@expo/vector-icons'; +import { COLORS, SPACING } from '../styles/theme'; + +interface Pet { + id: string; + name: string; + breed: string; + age: number; +} + +interface Visit { + id: string; + petId: string; + date: string; + clinic: string; + veterinarian: string; + reason: string; + notes?: string; + cost?: number; +} + +export default function VisitsScreen() { + // Mock data - replace with actual data from context/API + const [pets] = useState([]); + + const [visits] = useState([]); + + const [selectedPetId, setSelectedPetId] = useState(pets[0]?.id || ''); + + const selectedPetVisits = visits + .filter(visit => visit.petId === selectedPetId) + .sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime()); + + const formatDate = (dateString: string) => { + const date = new Date(dateString); + return date.toLocaleDateString('fi-FI', { + day: 'numeric', + month: 'long', + year: 'numeric', + }); + }; + + const renderVisitCard = (visit: Visit) => ( + + + + + + + {formatDate(visit.date)} + + + {visit.cost && ( + + {visit.cost} € + + )} + + + + + + + + {visit.clinic} + + + + + + + {visit.veterinarian} + + + + + + + {visit.reason} + + + + {visit.notes && ( + <> + + + + + {visit.notes} + + + + )} + + + ); + + const renderEmptyState = () => ( + + + + Ei käyntejä + + + Lisää ensimmäinen eläinlääkärikäynti + + + ); + + if (pets.length === 0) { + return ( + + + + + Ei lemmikkejä + + + Lisää ensin lemmikki + + + + ); + } + + return ( + + + {pets.map((pet) => ( + setSelectedPetId(pet.id)} + style={[ + styles.tab, + selectedPetId === pet.id && styles.selectedTab + ]} + textStyle={selectedPetId === pet.id ? styles.selectedTabText : styles.unselectedTabText} + icon={() => ( + + )} + > + {pet.name} + + ))} + + + + {selectedPetVisits.length === 0 ? ( + renderEmptyState() + ) : ( + selectedPetVisits.map(renderVisitCard) + )} + + + console.log('Lisää käynti')} + label="Lisää käynti" + /> + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: COLORS.background, + }, + header: { + paddingHorizontal: SPACING.lg, + paddingTop: SPACING.lg, + paddingBottom: SPACING.md, + }, + title: { + fontWeight: 'bold', + color: COLORS.onBackground, + }, + tabsContainer: { + maxHeight: 70, + backgroundColor: COLORS.surface, + borderBottomWidth: 1, + borderBottomColor: COLORS.surfaceVariant, + }, + tabsContent: { + paddingHorizontal: SPACING.md, + paddingVertical: SPACING.md, + gap: SPACING.sm, + alignItems: 'center', + justifyContent: 'center', + flexGrow: 1, + }, + tab: { + marginHorizontal: SPACING.xs, + paddingHorizontal: SPACING.md, + height: 40, + justifyContent: 'center', + alignItems: 'center', + }, + selectedTab: { + backgroundColor: COLORS.primary, + elevation: 3, + }, + selectedTabText: { + color: '#FFFFFF', + fontWeight: '700', + fontSize: 15, + lineHeight: 20, + }, + unselectedTabText: { + fontSize: 15, + lineHeight: 20, + }, + content: { + flex: 1, + }, + scrollContent: { + padding: SPACING.md, + }, + visitCard: { + marginBottom: SPACING.md, + elevation: 2, + }, + visitHeader: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + marginBottom: SPACING.sm, + }, + dateContainer: { + flexDirection: 'row', + alignItems: 'center', + gap: SPACING.xs, + }, + dateText: { + fontWeight: '600', + color: COLORS.primary, + }, + divider: { + marginVertical: SPACING.sm, + }, + visitDetail: { + flexDirection: 'row', + alignItems: 'center', + gap: SPACING.sm, + marginBottom: SPACING.xs, + }, + detailText: { + flex: 1, + color: COLORS.onSurface, + }, + notesContainer: { + flexDirection: 'row', + gap: SPACING.sm, + marginTop: SPACING.xs, + }, + notesText: { + flex: 1, + color: COLORS.onSurfaceVariant, + fontStyle: 'italic', + }, + emptyContainer: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + paddingVertical: SPACING.xl * 2, + }, + emptyTitle: { + marginTop: SPACING.md, + color: COLORS.onSurfaceVariant, + }, + emptyText: { + marginTop: SPACING.xs, + color: COLORS.onSurfaceVariant, + textAlign: 'center', + }, + fab: { + position: 'absolute', + right: SPACING.md, + bottom: SPACING.md, + backgroundColor: COLORS.primary, + }, +}); diff --git a/mobile/src/screens/WeightManagementScreen.tsx b/mobile/src/screens/WeightManagementScreen.tsx new file mode 100644 index 0000000..c912bec --- /dev/null +++ b/mobile/src/screens/WeightManagementScreen.tsx @@ -0,0 +1,749 @@ +import React, { useState } from 'react'; +import { View, ScrollView, StyleSheet, Dimensions } from 'react-native'; +import { Text, Card, FAB, Chip, Divider } from 'react-native-paper'; +import { MaterialCommunityIcons } from '@expo/vector-icons'; +import Svg, { Line, Circle } from 'react-native-svg'; +import { COLORS, SPACING } from '../styles/theme'; + +interface Pet { + id: string; + name: string; + breed: string; + age: number; +} + +interface WeightRecord { + id: string; + petId: string; + date: string; + weight: number; + unit: 'kg' | 'g'; + notes?: string; + measuredBy?: string; +} + +export default function WeightManagementScreen() { + // Mock data - replace with actual data from context/API + const [pets] = useState([]); + + const [weightRecords] = useState([]); + + const [selectedPetId, setSelectedPetId] = useState(pets[0]?.id || ''); + const [selectedYear, setSelectedYear] = useState(new Date().getFullYear()); + const selectedPetWeights = weightRecords + .filter(record => record.petId === selectedPetId) + .sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime()); + + // Get unique years from selected pet's weights + const availableYears = Array.from( + new Set(selectedPetWeights.map(record => new Date(record.date).getFullYear())) + ).sort((a, b) => a - b); + + // Filter weights by selected year for graph + const yearFilteredWeights = selectedPetWeights.filter( + record => new Date(record.date).getFullYear() === selectedYear + ); + + const formatDate = (dateString: string) => { + const date = new Date(dateString); + return date.toLocaleDateString('fi-FI', { + day: 'numeric', + month: 'long', + year: 'numeric', + }); + }; + + const calculateWeightChange = (index: number) => { + if (index >= selectedPetWeights.length - 1) return null; + + const current = selectedPetWeights[index].weight; + const previous = selectedPetWeights[index + 1].weight; + const change = current - previous; + const percentChange = ((change / previous) * 100).toFixed(1); + + return { change: change.toFixed(1), percentChange, isIncrease: change > 0 }; + }; + + const renderWeightCard = (record: WeightRecord, index: number) => { + const weightChange = calculateWeightChange(index); + + return ( + + + + + + {record.weight} + + + {record.unit} + + + {weightChange && ( + + {weightChange.isIncrease ? '+' : ''}{weightChange.change} kg ({weightChange.percentChange}%) + + )} + + + + + + {formatDate(record.date)} + + + + {record.measuredBy && ( + <> + + + + + {record.measuredBy} + + + + )} + + {record.notes && ( + <> + + + + + {record.notes} + + + + )} + + + ); + }; + + const renderEmptyState = () => ( + + + + Ei painomittauksia + + + Lisää ensimmäinen painomittaus + + + ); + + if (pets.length === 0) { + return ( + + + + + Ei lemmikkejä + + + Lisää ensin lemmikki + + + + ); + } + + const renderGraph = () => { + if (yearFilteredWeights.length === 0) return null; + + // Group weights by month and calculate averages + const monthlyAverages = new Map(); + + yearFilteredWeights.forEach(record => { + const date = new Date(record.date); + const monthKey = `${date.getFullYear()}-${date.getMonth()}`; // e.g., "2025-0" for Jan 2025 + + if (!monthlyAverages.has(monthKey)) { + monthlyAverages.set(monthKey, { weights: [], dates: [] }); + } + + const monthData = monthlyAverages.get(monthKey)!; + monthData.weights.push(record.weight); + monthData.dates.push(record.date); + }); + + // Create averaged weight records (one per month) + const averagedWeights = Array.from(monthlyAverages.entries()).map(([monthKey, data]) => { + const [year, month] = monthKey.split('-').map(Number); + const avgWeight = data.weights.reduce((sum, w) => sum + w, 0) / data.weights.length; + + return { + id: monthKey, + date: new Date(year, month, 15), // Use middle of month + weight: avgWeight, + }; + }).sort((a, b) => a.date.getTime() - b.date.getTime()); + + // Get sorted weights (oldest to newest for graph) + const sortedWeights = averagedWeights; + const weights = sortedWeights.map(r => r.weight); + const maxWeight = Math.max(...weights); + const minWeight = Math.min(...weights); + const weightRange = maxWeight - minWeight || 1; + // Add 10% padding to top and bottom for better visibility + const paddedMax = maxWeight + (weightRange * 0.1); + const paddedMin = minWeight - (weightRange * 0.1); + const paddedRange = paddedMax - paddedMin; + const svgHeight = 220; + // Make graph slightly wider for better readability + const screenWidth = Dimensions.get('window').width; + const svgWidth = (screenWidth * 1.5) - 120; // 1.5x width for horizontal scrolling + // Add horizontal margins + const marginX = 20; + + // Round y-axis labels to nice 5kg intervals + const roundToNice = (num: number, roundUp: boolean) => { + if (roundUp) { + return Math.ceil(num / 5) * 5; + } else { + return Math.floor(num / 5) * 5; + } + }; + + // Round the padded values to get nice labels at 5kg intervals + let maxLabel = roundToNice(paddedMax, true); + let minLabel = roundToNice(paddedMin, false); + + // Ensure we have at least 10kg range for 3 labels (min, mid, max) + if (maxLabel - minLabel < 10) { + maxLabel = minLabel + 10; + } + + // Calculate proper middle value - halfway between min and max + const midLabel = (minLabel + maxLabel) / 2; + + // Setup time range for entire year (Jan 1 to Dec 31 of selected year) + const timeStart = new Date(selectedYear, 0, 1); // Jan 1 + const timeEnd = new Date(selectedYear, 11, 31, 23, 59, 59); // Dec 31 + const timeRange = timeEnd.getTime() - timeStart.getTime(); + + // Month labels - first letter of each Finnish month + const monthLabels = ['T', 'H', 'M', 'H', 'T', 'K', 'H', 'E', 'S', 'L', 'M', 'J']; + + // Create x-axis labels for all 12 months + const xAxisLabels = monthLabels.map((label, idx) => { + // Position at middle of each month + const monthDate = new Date(selectedYear, idx, 15); // 15th of each month + return { date: monthDate, label }; + }); + + return ( + + + Painokehitys + + + + + {maxLabel} + + kg + {midLabel} + + {minLabel} + + + + + + + {/* Draw horizontal grid lines at label positions */} + + + + + {/* Draw vertical month divider lines */} + {[...Array(11)].map((_, idx) => { + const monthEndDate = new Date(selectedYear, idx + 1, 1); // First day of next month + const x = marginX + ((monthEndDate.getTime() - timeStart.getTime()) / timeRange) * (svgWidth - 2 * marginX); + return ( + + ); + })} + + {/* Draw lines connecting points */} + {sortedWeights.map((record, index) => { + if (index >= sortedWeights.length - 1) return null; + + const nextRecord = sortedWeights[index + 1]; + const recordTime = record.date.getTime(); + const nextRecordTime = nextRecord.date.getTime(); + + const x1 = marginX + ((recordTime - timeStart.getTime()) / timeRange) * (svgWidth - 2 * marginX); + const x2 = marginX + ((nextRecordTime - timeStart.getTime()) / timeRange) * (svgWidth - 2 * marginX); + // Use label values for positioning to match grid lines + const y1 = svgHeight - ((record.weight - minLabel) / (maxLabel - minLabel)) * svgHeight; + const y2 = svgHeight - ((nextRecord.weight - minLabel) / (maxLabel - minLabel)) * svgHeight; + + return ( + + ); + })} + + {/* Draw data point circles */} + {sortedWeights.map((record) => { + const recordTime = record.date.getTime(); + const x = marginX + ((recordTime - timeStart.getTime()) / timeRange) * (svgWidth - 2 * marginX); + // Use label values for positioning to match grid lines + const y = svgHeight - ((record.weight - minLabel) / (maxLabel - minLabel)) * svgHeight; + + return ( + + {/* White fill to hide line inside circle */} + + {/* Colored stroke circle */} + + + ); + })} + + + {xAxisLabels.map((label, idx) => { + const labelTime = label.date.getTime(); + const xPos = marginX + ((labelTime - timeStart.getTime()) / timeRange) * (svgWidth - 2 * marginX); + + return ( + + {label.label} + + ); + })} + + + + pvm + + + + {/* Year selection tabs */} + {availableYears.length > 1 && ( + + + {availableYears.map((year) => ( + setSelectedYear(year)} + showSelectedCheck={false} + style={[ + styles.yearTab, + selectedYear === year && styles.selectedYearTab + ]} + textStyle={selectedYear === year ? styles.selectedYearTabText : styles.unselectedYearTabText} + > + {year} + + ))} + + + )} + + ); + }; + + return ( + + + + {pets.map((pet) => ( + setSelectedPetId(pet.id)} + style={[ + styles.tab, + selectedPetId === pet.id && styles.selectedTab + ]} + textStyle={selectedPetId === pet.id ? styles.selectedTabText : styles.unselectedTabText} + icon={() => ( + + )} + > + {pet.name} + + ))} + + + + + {selectedPetWeights.length > 0 && renderGraph()} + + + + + Mittaushistoria + + + + {selectedPetWeights.length === 0 ? ( + renderEmptyState() + ) : ( + selectedPetWeights.map((record, index) => renderWeightCard(record, index)) + )} + + + console.log('Lisää painomittaus')} + label="Lisää mittaus" + /> + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: COLORS.background, + }, + tabsContainer: { + maxHeight: 70, + backgroundColor: COLORS.surface, + borderBottomWidth: 1, + borderBottomColor: COLORS.surfaceVariant, + }, + tabsContent: { + paddingHorizontal: SPACING.md, + paddingVertical: SPACING.md, + gap: SPACING.sm, + alignItems: 'center', + justifyContent: 'center', + flexGrow: 1, + }, + tab: { + marginHorizontal: SPACING.xs, + paddingHorizontal: SPACING.md, + height: 40, + justifyContent: 'center', + alignItems: 'center', + }, + selectedTab: { + backgroundColor: COLORS.primary, + elevation: 3, + }, + selectedTabText: { + color: '#FFFFFF', + fontWeight: '700', + fontSize: 15, + lineHeight: 20, + }, + unselectedTabText: { + fontSize: 15, + lineHeight: 20, + }, + content: { + flex: 1, + }, + scrollContent: { + paddingBottom: 80, + }, + graphContainer: { + backgroundColor: COLORS.surface, + padding: SPACING.md, + paddingBottom: SPACING.xl, + marginHorizontal: SPACING.md, + marginTop: SPACING.md, + marginBottom: SPACING.md, + borderRadius: 12, + elevation: 2, + }, + graphTitle: { + fontWeight: '600', + color: COLORS.onSurface, + marginBottom: SPACING.md, + }, + graphContent: { + flexDirection: 'row', + }, + yAxisContainer: { + flexDirection: 'row', + alignItems: 'center', + }, + axisTitle: { + fontSize: 9, + color: COLORS.onSurfaceVariant, + fontWeight: '600', + }, + yAxisLabels: { + width: 50, + height: 220, + justifyContent: 'space-between', + alignItems: 'flex-end', + paddingRight: SPACING.xs, + marginTop: -50, + }, + yAxisMiddle: { + flexDirection: 'row', + alignItems: 'center', + gap: 4, + }, + axisLabel: { + color: COLORS.onSurfaceVariant, + textAlign: 'right', + lineHeight: 12, + }, + graphArea: { + flex: 1, + position: 'relative', + }, + gridLines: { + position: 'absolute', + top: 0, + left: 0, + right: 0, + height: 220, + justifyContent: 'space-between', + }, + gridLine: { + height: 1, + backgroundColor: COLORS.surfaceVariant, + opacity: 0.5, + }, + svgGraph: { + marginBottom: SPACING.xs, + }, + xAxisLabelsContainer: { + flexDirection: 'row', + justifyContent: 'space-around', + paddingBottom: SPACING.sm, + position: 'relative', + height: 20, + }, + xAxisLabel: { + fontSize: 9, + color: COLORS.onSurfaceVariant, + textAlign: 'center', + flex: 1, + flexShrink: 1, + }, + xAxisLabelShown: { + fontSize: 10, + color: COLORS.onSurfaceVariant, + textAlign: 'center', + flex: 1, + }, + xAxisTitle: { + fontSize: 10, + color: COLORS.onSurfaceVariant, + fontWeight: '600', + textAlign: 'center', + marginTop: SPACING.xs, + }, + yearTabsContainer: { + marginTop: SPACING.md, + paddingTop: SPACING.sm, + borderTopWidth: 1, + borderTopColor: COLORS.surfaceVariant, + }, + yearTabsContent: { + gap: SPACING.xs, + paddingHorizontal: 4, + }, + yearTab: { + backgroundColor: COLORS.surfaceVariant, + }, + selectedYearTab: { + backgroundColor: COLORS.primary, + }, + selectedYearTabText: { + color: '#FFFFFF', + fontWeight: '600', + }, + unselectedYearTabText: { + color: COLORS.onSurfaceVariant, + }, + dividerSection: { + paddingHorizontal: SPACING.md, + }, + sectionTitle: { + fontWeight: '600', + color: COLORS.onSurface, + marginTop: SPACING.md, + marginBottom: SPACING.xs, + }, + weightCard: { + marginHorizontal: SPACING.md, + marginBottom: SPACING.md, + elevation: 2, + }, + weightHeader: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + marginBottom: SPACING.sm, + }, + weightMainInfo: { + flexDirection: 'row', + alignItems: 'baseline', + gap: SPACING.xs, + }, + weightValue: { + fontWeight: '700', + color: COLORS.primary, + }, + weightUnit: { + color: COLORS.onSurfaceVariant, + fontWeight: '600', + }, + changeChip: { + marginLeft: SPACING.sm, + }, + increaseChip: { + backgroundColor: '#FFEBEE', + }, + decreaseChip: { + backgroundColor: '#E8F5E9', + }, + increaseChipText: { + color: '#D32F2F', + }, + decreaseChipText: { + color: '#2E7D32', + }, + dateContainer: { + flexDirection: 'row', + alignItems: 'center', + gap: SPACING.xs, + marginTop: SPACING.xs, + }, + dateText: { + color: COLORS.onSurfaceVariant, + }, + divider: { + marginVertical: SPACING.sm, + }, + weightDetail: { + flexDirection: 'row', + alignItems: 'center', + gap: SPACING.sm, + marginBottom: SPACING.xs, + }, + detailText: { + flex: 1, + color: COLORS.onSurface, + }, + notesContainer: { + flexDirection: 'row', + gap: SPACING.sm, + marginTop: SPACING.xs, + }, + notesText: { + flex: 1, + color: COLORS.onSurfaceVariant, + fontStyle: 'italic', + }, + emptyContainer: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + paddingVertical: SPACING.xl * 2, + }, + emptyTitle: { + marginTop: SPACING.md, + color: COLORS.onSurfaceVariant, + }, + emptyText: { + marginTop: SPACING.xs, + color: COLORS.onSurfaceVariant, + textAlign: 'center', + }, + fab: { + position: 'absolute', + right: SPACING.md, + bottom: SPACING.md, + backgroundColor: COLORS.primary, + }, + graphScrollView: { + width: '100%', + }, +}); diff --git a/mobile/src/services/.gitkeep b/mobile/src/services/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/mobile/src/types/.gitkeep b/mobile/src/types/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/mobile/src/utils/.gitkeep b/mobile/src/utils/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/package-lock.json b/package-lock.json index 38da06f..0a810e7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -32,6 +32,7 @@ "react-native-reanimated": "~4.1.1", "react-native-safe-area-context": "~5.6.0", "react-native-screens": "~4.16.0", + "react-native-svg": "^15.15.1", "react-native-worklets": "^0.5.1" }, "devDependencies": { @@ -5608,6 +5609,11 @@ "node": ">=0.6" } }, + "node_modules/boolbase": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", + "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==" + }, "node_modules/bplist-creator": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/bplist-creator/-/bplist-creator-0.1.0.tgz", @@ -6148,6 +6154,52 @@ "node": ">=8" } }, + "node_modules/css-select": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/css-select/-/css-select-5.2.2.tgz", + "integrity": "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==", + "dependencies": { + "boolbase": "^1.0.0", + "css-what": "^6.1.0", + "domhandler": "^5.0.2", + "domutils": "^3.0.1", + "nth-check": "^2.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/css-tree": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-1.1.3.tgz", + "integrity": "sha512-tRpdppF7TRazZrjJ6v3stzv93qxRcSsFmW6cX0Zm2NVKpxE1WV1HblnghVv9TreireHkqI/VDEsfolRF1p6y7Q==", + "dependencies": { + "mdn-data": "2.0.14", + "source-map": "^0.6.1" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/css-tree/node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/css-what": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/css-what/-/css-what-6.2.2.tgz", + "integrity": "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==", + "engines": { + "node": ">= 6" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, "node_modules/csstype": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", @@ -6367,6 +6419,57 @@ "node": ">=8" } }, + "node_modules/dom-serializer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", + "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.2", + "entities": "^4.2.0" + }, + "funding": { + "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" + } + }, + "node_modules/domelementtype": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", + "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ] + }, + "node_modules/domhandler": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", + "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", + "dependencies": { + "domelementtype": "^2.3.0" + }, + "engines": { + "node": ">= 4" + }, + "funding": { + "url": "https://github.com/fb55/domhandler?sponsor=1" + } + }, + "node_modules/domutils": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz", + "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==", + "dependencies": { + "dom-serializer": "^2.0.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3" + }, + "funding": { + "url": "https://github.com/fb55/domutils?sponsor=1" + } + }, "node_modules/dotenv": { "version": "16.4.7", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.4.7.tgz", @@ -6435,6 +6538,17 @@ "node": ">= 0.8" } }, + "node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/env-editor": { "version": "0.4.2", "resolved": "https://registry.npmjs.org/env-editor/-/env-editor-0.4.2.tgz", @@ -9050,6 +9164,11 @@ "node": ">= 0.4" } }, + "node_modules/mdn-data": { + "version": "2.0.14", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.14.tgz", + "integrity": "sha512-dn6wd0uw5GsdswPFfsgMp5NSB0/aDe6fK94YJV/AJDYXL6HVLWBsxeq7js7Ad+mU2K9LAlwpk6kN2D5mwCPVow==" + }, "node_modules/memoize-one": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-5.2.1.tgz", @@ -9634,6 +9753,17 @@ "node": "^16.14.0 || >=18.0.0" } }, + "node_modules/nth-check": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz", + "integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==", + "dependencies": { + "boolbase": "^1.0.0" + }, + "funding": { + "url": "https://github.com/fb55/nth-check?sponsor=1" + } + }, "node_modules/nullthrows": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/nullthrows/-/nullthrows-1.1.1.tgz", @@ -10542,6 +10672,20 @@ "react-native": "*" } }, + "node_modules/react-native-svg": { + "version": "15.15.1", + "resolved": "https://registry.npmjs.org/react-native-svg/-/react-native-svg-15.15.1.tgz", + "integrity": "sha512-ZUD1xwc3Hwo4cOmOLumjJVoc7lEf9oQFlHnLmgccLC19fNm6LVEdtB+Cnip6gEi0PG3wfvVzskViEtrySQP8Fw==", + "dependencies": { + "css-select": "^5.1.0", + "css-tree": "^1.1.3", + "warn-once": "0.1.1" + }, + "peerDependencies": { + "react": "*", + "react-native": "*" + } + }, "node_modules/react-native-worklets": { "version": "0.5.1", "resolved": "https://registry.npmjs.org/react-native-worklets/-/react-native-worklets-0.5.1.tgz",