diff --git a/mobile/src/navigation/Navigation.tsx b/mobile/src/navigation/Navigation.tsx
index 5a616f9..d19b375 100644
--- a/mobile/src/navigation/Navigation.tsx
+++ b/mobile/src/navigation/Navigation.tsx
@@ -21,6 +21,7 @@ import VisitsScreen from '@screens/VisitsScreen';
import MedicationsScreen from '@screens/MedicationsScreen';
import VaccinationsScreen from '../screens/VaccinationsScreen';
import WeightManagementScreen from '../screens/WeightManagementScreen';
+import CalendarScreen from '@screens/CalendarScreen';
// Added this root stack params list while trying to fix pet navigation issues
export type RootStackParamList = {
@@ -246,6 +247,30 @@ export default function Navigation() {
headerBackTitle: 'Takaisin',
}}
/>
+
+
+
>
) : (
<>
diff --git a/mobile/src/screens/CalendarScreen.tsx b/mobile/src/screens/CalendarScreen.tsx
new file mode 100644
index 0000000..49d102a
--- /dev/null
+++ b/mobile/src/screens/CalendarScreen.tsx
@@ -0,0 +1,663 @@
+import React, { useState, useEffect } from 'react';
+import { View, ScrollView, TouchableOpacity } from 'react-native';
+import { Text, Chip, ActivityIndicator, FAB, Portal, Modal, Button, TextInput } from 'react-native-paper';
+import { MaterialCommunityIcons } from '@expo/vector-icons';
+import DateTimePicker from '@react-native-community/datetimepicker';
+import { calendarStyles as styles } from '../styles/screenStyles';
+import { COLORS, SPACING } from '../styles/theme';
+import apiClient from '../services/api';
+import calendarService from '../services/calendarService';
+import { visitsService } from '../services/visitsService';
+import { Pet, CalendarEvent } from '../types';
+
+
+const EVENT_TYPE_ICONS: Record = {
+ vaccination: 'needle',
+ veterinary: 'hospital-box',
+ medication: 'pill',
+ grooming: 'content-cut',
+ other: 'calendar-star'
+};
+
+const EVENT_TYPE_COLORS = {
+ vaccination: '#4CAF50',
+ veterinary: '#2196F3',
+ medication: '#FF9800',
+ grooming: '#9C27B0',
+ other: '#607D8B'
+};
+
+const DAYS_OF_WEEK = ['Ma', 'Ti', 'Ke', 'To', 'Pe', 'La', 'Su'];
+const MONTHS = [
+ 'Tammikuu', 'Helmikuu', 'Maaliskuu', 'Huhtikuu', 'Toukokuu', 'Kesäkuu',
+ 'Heinäkuu', 'Elokuu', 'Syyskuu', 'Lokakuu', 'Marraskuu', 'Joulukuu'
+];
+
+export default function CalendarScreen() {
+ const [pets, setPets] = useState([]);
+ const [selectedPetId, setSelectedPetId] = useState(null);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState(null);
+ const [events, setEvents] = useState([]);
+ const [visitTypes, setVisitTypes] = useState([]);
+ const [selectedTypeId, setSelectedTypeId] = useState(null);
+
+ // Calendar navigation state
+ const currentDate = new Date();
+ const [selectedMonth, setSelectedMonth] = useState(currentDate.getMonth());
+ const [selectedYear, setSelectedYear] = useState(currentDate.getFullYear());
+
+ // Modal state
+ const [modalVisible, setModalVisible] = useState(false);
+ const [saving, setSaving] = useState(false);
+ const [showDatePicker, setShowDatePicker] = useState(false);
+
+ // Form state
+ const [eventTitle, setEventTitle] = useState('');
+ const [eventDescription, setEventDescription] = useState('');
+ const [eventDate, setEventDate] = useState(new Date());
+
+ // Fetch pets and events from the API
+ useEffect(() => {
+ const fetchData = async () => {
+ try {
+ setLoading(true);
+ setError(null);
+
+ const petsResponse = await apiClient.get('/api/pets');
+
+ if (petsResponse.data.success && petsResponse.data.data) {
+ const fetchedPets = petsResponse.data.data;
+ setPets(fetchedPets);
+
+ // Set the first pet as selected by default
+ if (fetchedPets.length > 0) {
+ setSelectedPetId(fetchedPets[0].id);
+ }
+ }
+
+ // Try to fetch events from API
+ try {
+ const fetchedEvents = await calendarService.getAllEvents();
+ setEvents(fetchedEvents);
+ } catch (eventsError) {
+ console.log('Events API not available yet, starting with empty list');
+ setEvents([]);
+ }
+
+ // Fetch visit types
+ try {
+ const types = await visitsService.getVisitTypes();
+ console.log('Visit types fetched:', types);
+ setVisitTypes(types);
+ } catch (typesError) {
+ console.error('Failed to fetch visit types:', typesError);
+ setVisitTypes([]);
+ }
+
+ } catch (err: any) {
+ console.error('Failed to fetch data:', err);
+ setError('Tietojen lataus epäonnistui. Yritä uudelleen.');
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ fetchData();
+ }, []);
+
+ // Get available years from events
+ const availableYears = Array.from(
+ new Set([
+ currentDate.getFullYear() - 1,
+ currentDate.getFullYear(),
+ currentDate.getFullYear() + 1,
+ ...events.map(event => new Date(event.date).getFullYear())
+ ])
+ ).sort((a, b) => a - b);
+
+ // Filter events for selected pet, month, and year
+ const filteredEvents = events.filter(event => {
+ if (event.petId !== selectedPetId) return false;
+ const eventDate = new Date(event.date);
+ return eventDate.getMonth() === selectedMonth && eventDate.getFullYear() === selectedYear;
+ });
+
+ // Get days in the current month
+ const getDaysInMonth = (month: number, year: number) => {
+ return new Date(year, month + 1, 0).getDate();
+ };
+
+ // Get the first day of the month (0 = Sunday, 1 = Monday, etc.)
+ const getFirstDayOfMonth = (month: number, year: number) => {
+ const firstDay = new Date(year, month, 1).getDay();
+ // Convert to Monday = 0, Sunday = 6
+ return firstDay === 0 ? 6 : firstDay - 1;
+ };
+
+ // Navigate months
+ const goToPreviousMonth = () => {
+ if (selectedMonth === 0) {
+ setSelectedMonth(11);
+ setSelectedYear(selectedYear - 1);
+ } else {
+ setSelectedMonth(selectedMonth - 1);
+ }
+ };
+
+ const goToNextMonth = () => {
+ if (selectedMonth === 11) {
+ setSelectedMonth(0);
+ setSelectedYear(selectedYear + 1);
+ } else {
+ setSelectedMonth(selectedMonth + 1);
+ }
+ };
+
+ // Check if a day has events
+ const getEventsForDay = (day: number) => {
+ return filteredEvents.filter(event => {
+ const eventDate = new Date(event.date);
+ return eventDate.getDate() === day;
+ });
+ };
+
+ // Check if day is today
+ const isToday = (day: number) => {
+ return day === currentDate.getDate() &&
+ selectedMonth === currentDate.getMonth() &&
+ selectedYear === currentDate.getFullYear();
+ };
+
+ // Modal handlers
+ const handleOpenModal = () => {
+ setEventTitle('');
+ setEventDescription('');
+ setEventDate(new Date());
+ setSelectedTypeId(visitTypes.length > 0 ? visitTypes[0].id : null);
+ setModalVisible(true);
+ };
+
+ const handleCloseModal = () => {
+ setModalVisible(false);
+ };
+
+ const handleSaveEvent = async () => {
+ if (!eventTitle.trim()) {
+ alert('Anna tapahtumalle otsikko');
+ return;
+ }
+
+ if (!selectedPetId) {
+ alert('Valitse lemmikki ensin');
+ return;
+ }
+
+ if (!selectedTypeId) {
+ alert('Valitse tapahtuman tyyppi');
+ return;
+ }
+
+ try {
+ setSaving(true);
+
+ const eventData = {
+ pet_id: selectedPetId,
+ visit_type_id: selectedTypeId,
+ title: eventTitle.trim(),
+ description: eventDescription.trim(),
+ date: eventDate.toISOString().split('T')[0],
+ completed: false,
+ };
+
+ // Try to save to API, fall back to local state if API is not available
+ try {
+ const newEvent = await calendarService.createEvent(eventData);
+ if (newEvent) {
+ // Refresh events from API
+ const refreshedEvents = await calendarService.getAllEvents();
+ setEvents(refreshedEvents);
+ }
+ } catch (apiError) {
+ console.log('API not available, saving to local state:', apiError);
+ // For now, add to local state with a temporary ID
+ const localEvent: CalendarEvent = {
+ id: Date.now(),
+ petId: selectedPetId,
+ title: eventTitle.trim(),
+ description: eventDescription.trim(),
+ date: eventDate.toISOString().split('T')[0],
+ eventType: 'other', // Default for local storage
+ completed: false,
+ };
+ setEvents([...events, localEvent]);
+ }
+
+ handleCloseModal();
+ } catch (err: any) {
+ console.error('Failed to save event:', err);
+ alert('Tapahtuman tallennus epäonnistui');
+ } finally {
+ setSaving(false);
+ }
+ };
+
+ const handleDateChange = (_event: any, selectedDate?: Date) => {
+ setShowDatePicker(false);
+ if (selectedDate) {
+ setEventDate(selectedDate);
+ }
+ };
+
+ const getEventTypeLabel = (type: CalendarEvent['eventType']) => {
+ const labels = {
+ vaccination: 'Rokotus',
+ veterinary: 'Eläinlääkäri',
+ medication: 'Lääkitys',
+ grooming: 'Trimmaus',
+ other: 'Muu'
+ };
+ return labels[type];
+ };
+
+ // Render calendar grid
+ const renderCalendar = () => {
+ const daysInMonth = getDaysInMonth(selectedMonth, selectedYear);
+ const firstDay = getFirstDayOfMonth(selectedMonth, selectedYear);
+ const days: (number | null)[] = [];
+
+ // Add empty cells for days before the first day of month
+ for (let i = 0; i < firstDay; i++) {
+ days.push(null);
+ }
+
+ // Add actual days
+ for (let i = 1; i <= daysInMonth; i++) {
+ days.push(i);
+ }
+
+ return (
+
+ {/* Day headers */}
+
+ {DAYS_OF_WEEK.map((day) => (
+
+ {day}
+
+ ))}
+
+
+ {/* Calendar days */}
+
+ {days.map((day, index) => {
+ if (day === null) {
+ return ;
+ }
+
+ const dayEvents = getEventsForDay(day);
+ const today = isToday(day);
+
+ return (
+ {
+ // TODO: Open day detail view or add event for that day
+ }}
+ >
+
+
+ {day}
+
+ {dayEvents.length > 0 && (
+
+ {dayEvents.slice(0, 3).map((event, idx) => (
+
+ ))}
+ {dayEvents.length > 3 && (
+ +{dayEvents.length - 3}
+ )}
+
+ )}
+
+
+ );
+ })}
+
+
+ );
+ };
+
+ // Render events list for the selected month
+ const renderEventsList = () => {
+ if (filteredEvents.length === 0) {
+ return (
+
+
+
+ Ei tapahtumia tässä kuussa
+
+
+ );
+ }
+
+ // Sort events by date
+ const sortedEvents = [...filteredEvents].sort((a, b) =>
+ new Date(a.date).getTime() - new Date(b.date).getTime()
+ );
+
+ return (
+
+
+ Tapahtumat
+
+ {sortedEvents.map(event => (
+
+
+
+
+
+ {event.title}
+
+
+ {new Date(event.date).getDate()}. {MONTHS[new Date(event.date).getMonth()]}
+
+
+
+ {getEventTypeLabel(event.eventType)}
+
+
+ {event.description && (
+
+ {event.description}
+
+ )}
+
+ ))}
+
+ );
+ };
+
+ if (loading) {
+ return (
+
+
+
+
+ Ladataan lemmikkejä...
+
+
+
+ );
+ }
+
+ if (error) {
+ return (
+
+
+
+
+ Virhe
+
+
+ {error}
+
+
+
+ );
+ }
+
+ if (pets.length === 0) {
+ return (
+
+
+
+
+ Ei lemmikkejä
+
+
+ Lisää ensin lemmikki
+
+
+
+ );
+ }
+
+ return (
+
+ {/* Pet Tabs */}
+
+ {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}
+
+ ))}
+
+
+
+ {/* Month Navigation */}
+
+
+
+
+
+
+
+ {MONTHS[selectedMonth]}
+
+
+ {selectedYear}
+
+
+
+
+
+
+
+
+ {/* Year Selection */}
+ {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}
+
+ ))}
+
+
+ )}
+
+ {/* Calendar Grid */}
+ {renderCalendar()}
+
+ {/* Events List */}
+ {renderEventsList()}
+
+
+ {/* Add Event FAB */}
+
+
+ {/* Add Event Modal */}
+
+
+
+
+ Lisää tapahtuma
+
+
+
+
+
+
+ setShowDatePicker(true)}>
+ }
+ placeholder="PP-KK-VVVV"
+ placeholderTextColor="rgba(0, 0, 0, 0.3)"
+ textColor={COLORS.onSurface}
+ theme={{ colors: { onSurfaceVariant: 'rgba(0, 0, 0, 0.4)' } }}
+ />
+
+
+ {showDatePicker && (
+
+ )}
+
+
+
+ Tapahtuman tyyppi
+
+
+ {visitTypes.length > 0 ? (
+ visitTypes.map((type) => (
+
+ ))
+ ) : (
+
+ )}
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/mobile/src/screens/HealthScreen.tsx b/mobile/src/screens/HealthScreen.tsx
index 523a468..5c0147b 100644
--- a/mobile/src/screens/HealthScreen.tsx
+++ b/mobile/src/screens/HealthScreen.tsx
@@ -37,41 +37,29 @@ export default function HealthScreen() {
navigation.navigate('Visits' as never)}>
-
- Käynnit
-
- Eläinlääkäri- ja klinikkakäynnit
-
+
+ Käynnit
navigation.navigate('Medications' as never)}>
-
- Lääkitykset
-
- Lääkkeet ja hoito-ohjelmat
-
+
+ Lääkitykset
navigation.navigate('Vaccinations' as never)}>
-
- Rokotukset
-
- Rokotushistoria ja muistutukset
-
+
+ Rokotukset
navigation.navigate('WeightManagement' as never)}>
-
- Painonhallinta
-
- Seuraa painoa ja kasvua
-
+
+ Paino
diff --git a/mobile/src/screens/HomeScreen.tsx b/mobile/src/screens/HomeScreen.tsx
index 7a12aa7..44134cd 100644
--- a/mobile/src/screens/HomeScreen.tsx
+++ b/mobile/src/screens/HomeScreen.tsx
@@ -1,6 +1,6 @@
import React, { useEffect, useRef } from 'react';
import { View, ScrollView, Animated } from 'react-native';
-import { Text, Card, IconButton } from 'react-native-paper';
+import { Text, Card } from 'react-native-paper';
import { MaterialCommunityIcons } from '@expo/vector-icons';
import { useNavigation } from '@react-navigation/native';
import { useAuth } from '../contexts/AuthContext';
@@ -52,7 +52,7 @@ export default function HomeScreen() {
- console.log('Lemmikki')}>
+ navigation.navigate('Pets' as never)}>
Lemmikki
@@ -72,7 +72,7 @@ export default function HomeScreen() {
- console.log('Kalenteri')}>
+ navigation.navigate('Calendar' as never)}>
Kalenteri
@@ -112,7 +112,7 @@ export default function HomeScreen() {
- console.log('Asetukset')}>
+ navigation.navigate('Settings' as never)}>
Asetukset
diff --git a/mobile/src/screens/MapScreen.tsx b/mobile/src/screens/MapScreen.tsx
index 4819493..9f9d0f0 100644
--- a/mobile/src/screens/MapScreen.tsx
+++ b/mobile/src/screens/MapScreen.tsx
@@ -33,8 +33,8 @@ export default function MapScreen() {
// TODO: Hae lemmikit backendistä
// Väliaikaisesti kovakoodatut lemmikit
setPets([
- { id: '1', name: 'Macho', breed: 'Akita', age: 12, weight: 44, dateOfBirth: '2013-10-01' },
- { id: '2', name: 'Mirri', breed: 'Sekarotuinen', age: 2, weight: 15, dateOfBirth: '2022-06-15' },
+ { id: 1, name: 'Macho', breed: 'Akita', age: 12, weight: 44, dateOfBirth: '2013-10-01' },
+ { id: 2, name: 'Mirri', breed: 'Sekarotuinen', age: 2, weight: 15, dateOfBirth: '2022-06-15' },
]);
// Hae käyttäjän sijainti heti
diff --git a/mobile/src/screens/MedicationsScreen.tsx b/mobile/src/screens/MedicationsScreen.tsx
index e88d657..0608ef3 100644
--- a/mobile/src/screens/MedicationsScreen.tsx
+++ b/mobile/src/screens/MedicationsScreen.tsx
@@ -1,12 +1,15 @@
import React, { useState, useEffect, useRef } from 'react';
-import { View, ScrollView, StyleSheet, TouchableOpacity } from 'react-native';
-import { Text, Card, FAB, Chip, Divider, ActivityIndicator, Portal, Modal, TextInput, Button } from 'react-native-paper';
+import { View, ScrollView, TouchableOpacity } from 'react-native';
+import { Text, Card, FAB, Chip, Divider, ActivityIndicator, Portal, Modal, TextInput, Button, Dialog } from 'react-native-paper';
import { MaterialCommunityIcons } from '@expo/vector-icons';
+import { medicationsStyles as styles } from '../styles/screenStyles';
import { COLORS, SPACING } from '../styles/theme';
import apiClient from '../services/api';
+import { medicationsService } from '../services/medicationsService';
import { Pet } from '../types';
import DateTimePicker from '@react-native-community/datetimepicker';
+
interface Medication {
id: number;
pet_id: number;
@@ -16,7 +19,7 @@ interface Medication {
costs?: string;
notes?: string;
}
-
+
export default function MedicationsScreen() {
const [pets, setPets] = useState([]);
const [medications, setMedications] = useState([]);
@@ -27,6 +30,10 @@ export default function MedicationsScreen() {
const [saving, setSaving] = useState(false);
const [showMedicationDatePicker, setShowMedicationDatePicker] = useState(false);
const [showExpireDatePicker, setShowExpireDatePicker] = useState(false);
+ const [editingMedicationId, setEditingMedicationId] = useState(null);
+ const [isEditMode, setIsEditMode] = useState(false);
+ const [deleteDialogVisible, setDeleteDialogVisible] = useState(false);
+ const [medicationToDelete, setMedicationToDelete] = useState(null);
const scrollViewRef = useRef(null);
@@ -44,9 +51,9 @@ export default function MedicationsScreen() {
setLoading(true);
setError(null);
- const [petsResponse, medicationsResponse] = await Promise.all([
+ const [petsResponse, fetchedMedications] = await Promise.all([
apiClient.get('/api/pets'),
- apiClient.get('/api/medications')
+ medicationsService.getAllMedications()
]);
if (petsResponse.data.success && petsResponse.data.data) {
@@ -58,27 +65,7 @@ export default function MedicationsScreen() {
}
}
- if (medicationsResponse.data.success && medicationsResponse.data.data) {
- const medicationsData = medicationsResponse.data.data;
-
- if (Array.isArray(medicationsData) && medicationsData.length > 0 && medicationsData[0].medications) {
- const flattenedMedications: Medication[] = [];
- medicationsData.forEach((petMedGroup: any) => {
- const petId = petMedGroup.pet_id;
- if (petMedGroup.medications && Array.isArray(petMedGroup.medications)) {
- petMedGroup.medications.forEach((med: any) => {
- flattenedMedications.push({
- ...med,
- pet_id: petId
- });
- });
- }
- });
- setMedications(flattenedMedications);
- } else {
- setMedications(medicationsData);
- }
- }
+ setMedications(fetchedMedications);
} catch (err: any) {
setError('Tietojen lataus epäonnistui. Yritä uudelleen.');
} finally {
@@ -94,6 +81,8 @@ export default function MedicationsScreen() {
.sort((a, b) => new Date(b.medication_date).getTime() - new Date(a.medication_date).getTime());
const handleOpenModal = () => {
+ setIsEditMode(false);
+ setEditingMedicationId(null);
setMedName('');
setMedicationDate(new Date().toISOString().split('T')[0]);
setExpireDate('');
@@ -104,53 +93,62 @@ export default function MedicationsScreen() {
const handleCloseModal = () => {
setModalVisible(false);
+ setIsEditMode(false);
+ setEditingMedicationId(null);
};
const handleSaveMedication = async () => {
- if (!selectedPetId || !medName) {
+ if (!medName) {
alert('Täytä kaikki pakolliset kentät');
return;
}
try {
setSaving(true);
+
+ if (isEditMode && editingMedicationId) {
+
+ const medicationData = {
+ med_name: medName,
+ medication_date: medicationDate,
+ expire_date: expireDate || undefined,
+ costs: costs ? parseFloat(costs) : undefined,
+ notes: notes || undefined
+ };
+
+ const updatedMedication = await medicationsService.updateMedication(editingMedicationId, medicationData);
+
+ if (updatedMedication) {
+ // Refresh medications
+ const refreshedMedications = await medicationsService.getAllMedications();
+ setMedications(refreshedMedications);
+
+ handleCloseModal();
+ }
+ } else {
+ if (!selectedPetId) {
+ alert('Valitse lemmikki ennen tallentamista.');
+ return;
+ }
- const medicationData = {
- pet_id: selectedPetId,
- med_name: medName,
- medication_date: medicationDate,
- expire_date: expireDate || undefined,
- costs: costs ? parseFloat(costs) : undefined,
- notes: notes || undefined
- };
-
- const response = await apiClient.post('/api/medications', medicationData);
-
- if (response.data.success) {
- const medicationsResponse = await apiClient.get('/api/medications');
- if (medicationsResponse.data.success && medicationsResponse.data.data) {
- const medicationsData = medicationsResponse.data.data;
+ const medicationData = {
+ pet_id: selectedPetId,
+ med_name: medName,
+ medication_date: medicationDate,
+ expire_date: expireDate || undefined,
+ costs: costs ? parseFloat(costs) : undefined,
+ notes: notes || undefined
+ };
+
+ const newMedication = await medicationsService.createMedication(medicationData);
+
+ if (newMedication) {
+ // Refresh medications
+ const refreshedMedications = await medicationsService.getAllMedications();
+ setMedications(refreshedMedications);
- if (Array.isArray(medicationsData) && medicationsData.length > 0 && medicationsData[0].medications) {
- const flattenedMedications: Medication[] = [];
- medicationsData.forEach((petMedGroup: any) => {
- const petId = petMedGroup.pet_id;
- if (petMedGroup.medications && Array.isArray(petMedGroup.medications)) {
- petMedGroup.medications.forEach((med: any) => {
- flattenedMedications.push({
- ...med,
- pet_id: petId
- });
- });
- }
- });
- setMedications(flattenedMedications);
- } else {
- setMedications(medicationsData);
- }
+ handleCloseModal();
}
-
- handleCloseModal();
}
} catch (err: any) {
alert('Lääkityksen tallentaminen epäonnistui. Yritä uudelleen.');
@@ -159,6 +157,47 @@ export default function MedicationsScreen() {
}
};
+ const handleEditMedication = (medication: Medication) => {
+
+ setIsEditMode(true);
+ setEditingMedicationId(medication.id);
+
+ const dateOnly = medication.medication_date.split('T')[0];
+ setMedName(medication.med_name);
+ setMedicationDate(dateOnly);
+ const expireDateOnly = medication.expire_date ? medication.expire_date.split('T')[0] : '';
+ setExpireDate(expireDateOnly);
+ setCosts(medication.costs || '');
+ setNotes(medication.notes || '');
+ setModalVisible(true);
+ };
+
+ const handleDeleteMedication = (medication: Medication) => {
+ setMedicationToDelete(medication);
+ setDeleteDialogVisible(true);
+ };
+
+ const confirmDeleteMedication = async () => {
+ if (!medicationToDelete) return;
+
+ try {
+ const success = await medicationsService.deleteMedication(medicationToDelete.id);
+
+ if (success) {
+ // Refresh medications
+ const refreshedMedications = await medicationsService.getAllMedications();
+ setMedications(refreshedMedications);
+ setDeleteDialogVisible(false);
+ setMedicationToDelete(null);
+ } else {
+ alert('Lääkityksen poistaminen epäonnistui. Yritä uudelleen.');
+ }
+ } catch (err: any) {
+ console.error("Failed to delete medication:", err);
+ alert('Lääkityksen poistaminen epäonnistui. Yritä uudelleen.');
+ }
+ };
+
const formatDate = (dateString: string) => {
const date = new Date(dateString);
return date.toLocaleDateString('fi-FI', {
@@ -211,17 +250,29 @@ export default function MedicationsScreen() {
)}
- {medication.notes && (
- <>
-
+
+
+
+ {medication.notes ? (
{medication.notes}
- >
- )}
+ ) : (
+
+ )}
+
+
+ handleEditMedication(medication)} style={styles.actionButton}>
+
+
+ handleDeleteMedication(medication)} style={styles.actionButton}>
+
+
+
+
);
@@ -346,7 +397,7 @@ export default function MedicationsScreen() {
contentContainerStyle={styles.scrollContentContainer}
>
- Lisää lääkitys
+ {isEditMode ? 'Muokkaa lääkitystä' : 'Lisää lääkitys'}
setShowMedicationDatePicker(true)}>
setShowExpireDatePicker(true)}>
+
);
}
-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: {
- 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,
- },
- dosageContainer: {
- flexDirection: 'row',
- alignItems: 'center',
- gap: SPACING.xs,
- marginBottom: SPACING.sm,
- },
- dosage: {
- fontWeight: '600',
- color: COLORS.onSurface,
- },
- divider: {
- marginVertical: SPACING.sm,
- },
- 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,
- },
- modalContainer: {
- backgroundColor: COLORS.surface,
- margin: SPACING.lg,
- padding: SPACING.lg,
- borderRadius: 12,
- maxHeight: '90%',
- },
- scrollContentContainer: {
- paddingBottom: 0,
- },
- modalTitle: {
- marginBottom: SPACING.lg,
- fontWeight: 'bold',
- color: COLORS.onSurface,
- },
- input: {
- marginBottom: SPACING.md,
- },
- modalButtons: {
- flexDirection: 'row',
- justifyContent: 'space-between',
- marginTop: SPACING.lg,
- gap: SPACING.md,
- },
- modalButton: {
- flex: 1,
- },
-});
diff --git a/mobile/src/screens/ProfileScreen.tsx b/mobile/src/screens/ProfileScreen.tsx
index 9d56870..e6f588b 100644
--- a/mobile/src/screens/ProfileScreen.tsx
+++ b/mobile/src/screens/ProfileScreen.tsx
@@ -630,6 +630,16 @@ export default function ProfileScreen() {
>
Hoitaja
+
diff --git a/mobile/src/screens/VaccinationsScreen.tsx b/mobile/src/screens/VaccinationsScreen.tsx
index b6ce6d5..819829c 100644
--- a/mobile/src/screens/VaccinationsScreen.tsx
+++ b/mobile/src/screens/VaccinationsScreen.tsx
@@ -1,9 +1,11 @@
import React, { useState, useEffect, useRef } from 'react';
-import { View, ScrollView, StyleSheet, TouchableOpacity } from 'react-native';
-import { Text, Card, FAB, Chip, Divider, ActivityIndicator, Portal, Modal, TextInput, Button } from 'react-native-paper';
+import { View, ScrollView, TouchableOpacity } from 'react-native';
+import { Text, Card, FAB, Chip, Divider, ActivityIndicator, Portal, Modal, TextInput, Button, Dialog } from 'react-native-paper';
import { MaterialCommunityIcons } from '@expo/vector-icons';
+import { vaccinationsStyles as styles } from '../styles/screenStyles';
import { COLORS, SPACING } from '../styles/theme';
import apiClient from '../services/api';
+import { vaccinationsService } from '../services/vaccinationsService';
import { Pet } from '../types';
import DateTimePicker from '@react-native-community/datetimepicker';
@@ -27,6 +29,10 @@ export default function VaccinationsScreen() {
const [saving, setSaving] = useState(false);
const [showVaccinationDatePicker, setShowVaccinationDatePicker] = useState(false);
const [showExpireDatePicker, setShowExpireDatePicker] = useState(false);
+ const [editingVaccinationId, setEditingVaccinationId] = useState(null);
+ const [isEditMode, setIsEditMode] = useState(false);
+ const [deleteDialogVisible, setDeleteDialogVisible] = useState(false);
+ const [vaccinationToDelete, setVaccinationToDelete] = useState(null);
const scrollViewRef = useRef(null);
@@ -44,9 +50,9 @@ export default function VaccinationsScreen() {
setLoading(true);
setError(null);
- const [petsResponse, vaccinationsResponse] = await Promise.all([
+ const [petsResponse, fetchedVaccinations] = await Promise.all([
apiClient.get('/api/pets'),
- apiClient.get('/api/vaccinations')
+ vaccinationsService.getAllVaccinations()
]);
if (petsResponse.data.success && petsResponse.data.data) {
@@ -58,27 +64,7 @@ export default function VaccinationsScreen() {
}
}
- if (vaccinationsResponse.data.success && vaccinationsResponse.data.data) {
- const vaccinationsData = vaccinationsResponse.data.data;
-
- if (Array.isArray(vaccinationsData) && vaccinationsData.length > 0 && vaccinationsData[0].vaccinations) {
- const flattenedVaccinations: Vaccination[] = [];
- vaccinationsData.forEach((petVacGroup: any) => {
- const petId = petVacGroup.pet_id;
- if (petVacGroup.vaccinations && Array.isArray(petVacGroup.vaccinations)) {
- petVacGroup.vaccinations.forEach((vac: any) => {
- flattenedVaccinations.push({
- ...vac,
- pet_id: petId
- });
- });
- }
- });
- setVaccinations(flattenedVaccinations);
- } else {
- setVaccinations(vaccinationsData);
- }
- }
+ setVaccinations(fetchedVaccinations);
} catch (err: any) {
setError('Tietojen lataus epäonnistui. Yritä uudelleen.');
} finally {
@@ -94,6 +80,9 @@ export default function VaccinationsScreen() {
.sort((a, b) => new Date(b.vaccination_date).getTime() - new Date(a.vaccination_date).getTime());
const handleOpenModal = () => {
+ // Reset form for create mode
+ setIsEditMode(false);
+ setEditingVaccinationId(null);
setVacName('');
setVaccinationDate(new Date().toISOString().split('T')[0]);
setExpireDate('');
@@ -104,53 +93,63 @@ export default function VaccinationsScreen() {
const handleCloseModal = () => {
setModalVisible(false);
+ setIsEditMode(false);
+ setEditingVaccinationId(null);
};
const handleSaveVaccination = async () => {
- if (!selectedPetId || !vacName) {
+ if (!vacName) {
alert('Täytä kaikki pakolliset kentät');
return;
}
try {
setSaving(true);
+
+ if (isEditMode && editingVaccinationId) {
- const vaccinationData = {
- pet_id: selectedPetId,
- vac_name: vacName,
- vaccination_date: vaccinationDate,
- expire_date: expireDate || undefined,
- costs: costs ? parseFloat(costs) : undefined,
- notes: notes || undefined
- };
-
- const response = await apiClient.post('/api/vaccinations', vaccinationData);
-
- if (response.data.success) {
- const vaccinationsResponse = await apiClient.get('/api/vaccinations');
- if (vaccinationsResponse.data.success && vaccinationsResponse.data.data) {
- const vaccinationsData = vaccinationsResponse.data.data;
+ const vaccinationData = {
+ pet_id: selectedPetId,
+ vac_name: vacName,
+ vaccination_date: vaccinationDate,
+ expire_date: expireDate || undefined,
+ costs: costs ? parseFloat(costs) : undefined,
+ notes: notes || undefined
+ };
+
+ const updatedVaccination = await vaccinationsService.updateVaccination(editingVaccinationId, vaccinationData);
+
+ if (updatedVaccination) {
+ // Refresh vaccinations
+ const refreshedVaccinations = await vaccinationsService.getAllVaccinations();
+ setVaccinations(refreshedVaccinations);
- if (Array.isArray(vaccinationsData) && vaccinationsData.length > 0 && vaccinationsData[0].vaccinations) {
- const flattenedVaccinations: Vaccination[] = [];
- vaccinationsData.forEach((petVacGroup: any) => {
- const petId = petVacGroup.pet_id;
- if (petVacGroup.vaccinations && Array.isArray(petVacGroup.vaccinations)) {
- petVacGroup.vaccinations.forEach((vac: any) => {
- flattenedVaccinations.push({
- ...vac,
- pet_id: petId
- });
- });
- }
- });
- setVaccinations(flattenedVaccinations);
- } else {
- setVaccinations(vaccinationsData);
- }
+ handleCloseModal();
+ }
+ } else {
+ if (!selectedPetId) {
+ alert('Valitse lemmikki ennen tallentamista.');
+ return;
+ }
+
+ const vaccinationData = {
+ pet_id: selectedPetId,
+ vac_name: vacName,
+ vaccination_date: vaccinationDate,
+ expire_date: expireDate || undefined,
+ costs: costs ? parseFloat(costs) : undefined,
+ notes: notes || undefined
+ };
+
+ const newVaccination = await vaccinationsService.createVaccination(vaccinationData);
+
+ if (newVaccination) {
+ // Refresh vaccinations
+ const refreshedVaccinations = await vaccinationsService.getAllVaccinations();
+ setVaccinations(refreshedVaccinations);
+
+ handleCloseModal();
}
-
- handleCloseModal();
}
} catch (err: any) {
alert('Rokotuksen tallentaminen epäonnistui. Yritä uudelleen.');
@@ -159,6 +158,46 @@ export default function VaccinationsScreen() {
}
};
+ const handleEditVaccination = (vaccination: Vaccination) => {
+ setIsEditMode(true);
+ setEditingVaccinationId(vaccination.id);
+
+ const dateOnly = vaccination.vaccination_date.split('T')[0];
+ setVacName(vaccination.vac_name);
+ setVaccinationDate(dateOnly);
+ const expireDateOnly = vaccination.expire_date ? vaccination.expire_date.split('T')[0] : '';
+ setExpireDate(expireDateOnly);
+ setCosts(vaccination.costs ? vaccination.costs.toString() : '');
+ setNotes(vaccination.notes || '');
+ setModalVisible(true);
+ };
+
+ const handleDeleteVaccination = async (vaccination: Vaccination) => {
+ setVaccinationToDelete(vaccination);
+ setDeleteDialogVisible(true);
+ };
+
+ const confirmDeleteVaccination = async () => {
+ if (!vaccinationToDelete) return;
+
+ try {
+ const success = await vaccinationsService.deleteVaccination(vaccinationToDelete.id);
+
+ if (success) {
+ // Refresh vaccinations
+ const refreshedVaccinations = await vaccinationsService.getAllVaccinations();
+ setVaccinations(refreshedVaccinations);
+ setDeleteDialogVisible(false);
+ setVaccinationToDelete(null);
+ } else {
+ alert('Rokotuksen poistaminen epäonnistui. Yritä uudelleen.');
+ }
+ } catch (err: any) {
+ console.error("Failed to delete vaccination:", err);
+ alert('Rokotuksen poistaminen epäonnistui. Yritä uudelleen.');
+ }
+ };
+
const formatDate = (dateString: string) => {
const date = new Date(dateString);
return date.toLocaleDateString('fi-FI', {
@@ -211,17 +250,29 @@ export default function VaccinationsScreen() {
)}
- {vaccination.notes && (
- <>
-
+
+
+
+ {vaccination.notes ? (
{vaccination.notes}
- >
- )}
+ ) : (
+
+ )}
+
+
+ handleEditVaccination(vaccination)} style={styles.actionButton}>
+
+
+ handleDeleteVaccination(vaccination)} style={styles.actionButton}>
+
+
+
+
);
@@ -346,7 +397,7 @@ export default function VaccinationsScreen() {
contentContainerStyle={styles.scrollContentContainer}
>
- Lisää rokotus
+ {isEditMode ? 'Muokkaa rokotusta' : 'Lisää rokotus'}
setShowVaccinationDatePicker(true)}>
setShowExpireDatePicker(true)}>
+
);
}
-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: {
- 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,
- },
- dateContainer: {
- flexDirection: 'row',
- alignItems: 'center',
- gap: SPACING.xs,
- marginBottom: SPACING.sm,
- },
- dateText: {
- fontWeight: '600',
- color: COLORS.onSurface,
- },
- divider: {
- marginVertical: SPACING.sm,
- },
- 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,
- },
- modalContainer: {
- backgroundColor: COLORS.surface,
- margin: SPACING.lg,
- padding: SPACING.lg,
- borderRadius: 12,
- maxHeight: '90%',
- },
- scrollContentContainer: {
- paddingBottom: 0,
- },
- modalTitle: {
- marginBottom: SPACING.lg,
- fontWeight: 'bold',
- color: COLORS.onSurface,
- },
- input: {
- marginBottom: SPACING.md,
- },
- modalButtons: {
- flexDirection: 'row',
- justifyContent: 'space-between',
- marginTop: SPACING.lg,
- gap: SPACING.md,
- },
- modalButton: {
- flex: 1,
- },
-});
diff --git a/mobile/src/screens/VisitsScreen.tsx b/mobile/src/screens/VisitsScreen.tsx
index 145a1d5..d5ba6db 100644
--- a/mobile/src/screens/VisitsScreen.tsx
+++ b/mobile/src/screens/VisitsScreen.tsx
@@ -1,9 +1,11 @@
import React, { useState, useEffect, useRef } from 'react';
-import { View, ScrollView, StyleSheet, TouchableOpacity, Keyboard } from 'react-native';
-import { Text, Card, FAB, Chip, Divider, ActivityIndicator, Portal, Modal, TextInput, Button } from 'react-native-paper';
+import { View, ScrollView, TouchableOpacity } from 'react-native';
+import { Text, Card, FAB, Chip, Divider, ActivityIndicator, Portal, Modal, TextInput, Button, Dialog } from 'react-native-paper';
import { MaterialCommunityIcons } from '@expo/vector-icons';
+import { visitsStyles as styles } from '../styles/screenStyles';
import { COLORS, SPACING } from '../styles/theme';
import apiClient from '../services/api';
+import { visitsService } from '../services/visitsService';
import { Pet } from '../types';
import DateTimePicker from '@react-native-community/datetimepicker';
@@ -33,32 +35,14 @@ export default function VisitsScreen() {
const [modalVisible, setModalVisible] = useState(false);
const [saving, setSaving] = useState(false);
const [showDatePicker, setShowDatePicker] = useState(false);
- const [keyboardHeight, setKeyboardHeight] = useState(0);
+ const [editingVisitId, setEditingVisitId] = useState(null);
+ const [isEditMode, setIsEditMode] = useState(false);
+ const [deleteDialogVisible, setDeleteDialogVisible] = useState(false);
+ const [visitToDelete, setVisitToDelete] = useState(null);
const scrollViewRef = useRef(null);
const costsInputRef = useRef(null);
const notesInputRef = useRef(null);
-
- // Handle keyboard events
- useEffect(() => {
- const keyboardDidShowListener = Keyboard.addListener(
- 'keyboardDidShow',
- (e) => {
- setKeyboardHeight(e.endCoordinates.height);
- }
- );
- const keyboardDidHideListener = Keyboard.addListener(
- 'keyboardDidHide',
- () => {
- setKeyboardHeight(0);
- }
- );
-
- return () => {
- keyboardDidShowListener.remove();
- keyboardDidHideListener.remove();
- };
- }, []);
// Form state
const [visitDate, setVisitDate] = useState(new Date().toISOString().split('T')[0]);
@@ -76,9 +60,9 @@ export default function VisitsScreen() {
setError(null);
// Fetch pets and visits in parallel
- const [petsResponse, visitsResponse] = await Promise.all([
+ const [petsResponse, fetchedVisits] = await Promise.all([
apiClient.get('/api/pets'),
- apiClient.get('/api/vet-visits')
+ visitsService.getAllVisits()
]);
if (petsResponse.data.success && petsResponse.data.data) {
@@ -91,22 +75,7 @@ export default function VisitsScreen() {
}
}
- if (visitsResponse.data.success && visitsResponse.data.data) {
- // Flatten the nested structure: each pet has a vet_visits array
- const flattenedVisits: Visit[] = [];
- visitsResponse.data.data.forEach((petVisitGroup: any) => {
- const petId = petVisitGroup.pet_id;
- if (petVisitGroup.vet_visits && Array.isArray(petVisitGroup.vet_visits)) {
- petVisitGroup.vet_visits.forEach((visit: any) => {
- flattenedVisits.push({
- ...visit,
- pet_id: petId
- });
- });
- }
- });
- setVisits(flattenedVisits);
- }
+ setVisits(fetchedVisits);
} catch (err: any) {
console.error('Failed to fetch data:', err);
setError('Tietojen lataus epäonnistui. Yritä uudelleen.');
@@ -123,7 +92,9 @@ export default function VisitsScreen() {
.sort((a, b) => new Date(b.visit_date).getTime() - new Date(a.visit_date).getTime());
const handleOpenModal = async () => {
- // Reset form
+ // Reset form for create mode
+ setIsEditMode(false);
+ setEditingVisitId(null);
setVisitDate(new Date().toISOString().split('T')[0]);
setVetName('');
setLocation('');
@@ -135,11 +106,9 @@ export default function VisitsScreen() {
// Fetch visit types if not already loaded
if (visitTypes.length === 0) {
try {
- const typesResponse = await apiClient.get('/api/vet-visits/types');
- if (typesResponse.data.success && typesResponse.data.data) {
- console.log('Visit types response:', typesResponse.data.data);
- setVisitTypes(typesResponse.data.data);
- }
+ const types = await visitsService.getVisitTypes();
+ console.log('Visit types response:', types);
+ setVisitTypes(types);
} catch (err: any) {
console.error('Failed to fetch visit types:', err);
}
@@ -148,10 +117,12 @@ export default function VisitsScreen() {
const handleCloseModal = () => {
setModalVisible(false);
+ setIsEditMode(false);
+ setEditingVisitId(null);
};
const handleSaveVisit = async () => {
- if (!selectedPetId || !vetName || !location || !selectedTypeId) {
+ if (!vetName || !location || !selectedTypeId) {
alert('Täytä kaikki pakolliset kentät');
return;
}
@@ -159,38 +130,50 @@ export default function VisitsScreen() {
try {
setSaving(true);
- const visitData = {
- pet_id: selectedPetId,
- visit_date: visitDate,
- vet_name: vetName,
- location: location,
- type_id: selectedTypeId,
- notes: notes || undefined,
- costs: costs ? parseFloat(costs) : undefined
- };
-
- const response = await apiClient.post('/api/vet-visits', visitData);
-
- if (response.data.success) {
- // Refresh visits
- const visitsResponse = await apiClient.get('/api/vet-visits');
- if (visitsResponse.data.success && visitsResponse.data.data) {
- const flattenedVisits: Visit[] = [];
- visitsResponse.data.data.forEach((petVisitGroup: any) => {
- const petId = petVisitGroup.pet_id;
- if (petVisitGroup.vet_visits && Array.isArray(petVisitGroup.vet_visits)) {
- petVisitGroup.vet_visits.forEach((visit: any) => {
- flattenedVisits.push({
- ...visit,
- pet_id: petId
- });
- });
- }
- });
- setVisits(flattenedVisits);
+ if (isEditMode && editingVisitId) {
+
+ const visitData = {
+ visit_date: visitDate,
+ vet_name: vetName,
+ location: location,
+ type_id: selectedTypeId,
+ notes: notes || undefined,
+ costs: costs ? parseFloat(costs) : undefined
+ };
+
+ const updatedVisit = await visitsService.updateVisit(editingVisitId, visitData);
+
+ if (updatedVisit) {
+ // Refresh visits
+ const refreshedVisits = await visitsService.getAllVisits();
+ setVisits(refreshedVisits);
+ handleCloseModal();
+ }
+ } else {
+ // Create new visit
+ if (!selectedPetId) {
+ alert('Valitse lemmikki');
+ return;
}
- handleCloseModal();
+ const visitData = {
+ pet_id: selectedPetId,
+ visit_date: visitDate,
+ vet_name: vetName,
+ location: location,
+ type_id: selectedTypeId,
+ notes: notes || undefined,
+ costs: costs ? parseFloat(costs) : undefined
+ };
+
+ const newVisit = await visitsService.createVisit(visitData);
+
+ if (newVisit) {
+ // Refresh visits
+ const refreshedVisits = await visitsService.getAllVisits();
+ setVisits(refreshedVisits);
+ handleCloseModal();
+ }
}
} catch (err: any) {
console.error('Failed to save visit:', err);
@@ -200,6 +183,7 @@ export default function VisitsScreen() {
}
};
+
const formatDate = (dateString: string) => {
const date = new Date(dateString);
return date.toLocaleDateString('fi-FI', {
@@ -209,6 +193,67 @@ export default function VisitsScreen() {
});
};
+ const handleEditVisit = async (visit: Visit) => {
+ // Set edit mode
+ setIsEditMode(true);
+ setEditingVisitId(visit.id);
+
+ // Fetch visit types first if not already loaded
+ let types = visitTypes;
+ if (types.length === 0) {
+ try {
+ types = await visitsService.getVisitTypes();
+ setVisitTypes(types);
+ } catch (err: any) {
+ console.error('Failed to fetch visit types:', err);
+ }
+ }
+
+ // Pre-fill form with visit data
+ // Extract date only (remove timestamp if present)
+ const dateOnly = visit.visit_date.split('T')[0];
+ setVisitDate(dateOnly);
+ setVetName(visit.vet_name);
+ setLocation(visit.location);
+
+ // Find the type by name (since type_id stores the name, not ID)
+ const matchingType = types.find(t => t.name.toLowerCase() === visit.type_id.toLowerCase());
+ if (matchingType) {
+ setSelectedTypeId(matchingType.id);
+ }
+
+ setNotes(visit.notes || '');
+ setCosts(visit.costs || '');
+
+ setModalVisible(true);
+ };
+
+ const handleDeleteVisit = (visit: Visit) => {
+ setVisitToDelete(visit);
+ setDeleteDialogVisible(true);
+ };
+
+ const confirmDeleteVisit = async () => {
+ if (!visitToDelete) return;
+
+ try {
+ const success = await visitsService.deleteVisit(visitToDelete.id);
+
+ if (success) {
+ // Refresh visits
+ const refreshedVisits = await visitsService.getAllVisits();
+ setVisits(refreshedVisits);
+ setDeleteDialogVisible(false);
+ setVisitToDelete(null);
+ } else {
+ alert('Käynnin poistaminen epäonnistui.');
+ }
+ } catch (err: any) {
+ console.error('Failed to delete visit:', err);
+ alert('Käynnin poistaminen epäonnistui. Yritä uudelleen.');
+ }
+ };
+
const renderVisitCard = (visit: Visit) => (
@@ -249,17 +294,29 @@ export default function VisitsScreen() {
- {visit.notes && (
- <>
-
+
+
+
+ {visit.notes ? (
{visit.notes}
- >
- )}
+ ) : (
+
+ )}
+
+
+ handleEditVisit(visit)} style={styles.actionButton}>
+
+
+ handleDeleteVisit(visit)} style={styles.actionButton}>
+
+
+
+
);
@@ -383,13 +440,13 @@ export default function VisitsScreen() {
contentContainerStyle={styles.scrollContentContainer}
>
- Lisää käynti
+ {isEditMode ? 'Muokkaa käyntiä' : 'Lisää käynti'}
setShowDatePicker(true)}>
- Käynnin tyyppi *
+ Käynnin tyyppi
+
+
);
}
-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,
- },
- modalContainer: {
- backgroundColor: COLORS.surface,
- margin: SPACING.lg,
- padding: SPACING.lg,
- borderRadius: 12,
- maxHeight: '90%',
- },
- keyboardAvoid: {
- width: '100%',
- },
- scrollContentContainer: {
- paddingBottom: 0,
- },
- modalTitle: {
- marginBottom: SPACING.lg,
- fontWeight: 'bold',
- color: COLORS.onSurface,
- },
- input: {
- marginBottom: SPACING.md,
- },
- modalButtons: {
- flexDirection: 'row',
- justifyContent: 'space-between',
- marginTop: SPACING.lg,
- gap: SPACING.md,
- },
- modalButton: {
- flex: 1,
- },
-});
diff --git a/mobile/src/screens/WeightManagementScreen.tsx b/mobile/src/screens/WeightManagementScreen.tsx
index 6b46af9..f956f8b 100644
--- a/mobile/src/screens/WeightManagementScreen.tsx
+++ b/mobile/src/screens/WeightManagementScreen.tsx
@@ -1,11 +1,13 @@
import React, { useState, useEffect, useRef } from 'react';
-import { View, ScrollView, StyleSheet, Dimensions, TouchableOpacity } from 'react-native';
-import { Text, Card, FAB, Chip, Divider, ActivityIndicator, Portal, Modal, Button, TextInput } from 'react-native-paper';
+import { View, ScrollView, Dimensions, TouchableOpacity } from 'react-native';
+import { Text, Card, FAB, Chip, Divider, ActivityIndicator, Portal, Modal, Button, TextInput, Dialog } from 'react-native-paper';
import { MaterialCommunityIcons } from '@expo/vector-icons';
import Svg, { Line, Circle } from 'react-native-svg';
import DateTimePicker from '@react-native-community/datetimepicker';
+import { weightsStyles as styles } from '../styles/screenStyles';
import { COLORS, SPACING } from '../styles/theme';
import apiClient from '../services/api';
+import { weightsService } from '../services/weightsService';
import { Pet } from '../types';
interface WeightRecord {
@@ -28,6 +30,10 @@ export default function WeightManagementScreen() {
const [modalVisible, setModalVisible] = useState(false);
const [saving, setSaving] = useState(false);
const [showDatePicker, setShowDatePicker] = useState(false);
+ const [editingWeightId, setEditingWeightId] = useState(null);
+ const [isEditMode, setIsEditMode] = useState(false);
+ const [deleteDialogVisible, setDeleteDialogVisible] = useState(false);
+ const [weightToDelete, setWeightToDelete] = useState(null);
const scrollViewRef = useRef(null);
@@ -43,9 +49,9 @@ export default function WeightManagementScreen() {
setError(null);
// Fetch both pets and weights in parallel
- const [petsResponse, weightsResponse] = await Promise.all([
+ const [petsResponse, fetchedWeights] = await Promise.all([
apiClient.get('/api/pets'),
- apiClient.get('/api/weights')
+ weightsService.getAllWeights()
]);
if (petsResponse.data.success && petsResponse.data.data) {
@@ -58,30 +64,7 @@ export default function WeightManagementScreen() {
}
}
- if (weightsResponse.data.success && weightsResponse.data.data) {
- const weightsData = weightsResponse.data.data;
-
- // If nested structure (array of pet weight groups)
- if (Array.isArray(weightsData) && weightsData.length > 0 && weightsData[0].weights) {
- const flattenedWeights: WeightRecord[] = [];
- weightsData.forEach((petWeightGroup: any) => {
- const petId = petWeightGroup.pet_id;
- if (petWeightGroup.weights && Array.isArray(petWeightGroup.weights)) {
- petWeightGroup.weights.forEach((weight: any) => {
- flattenedWeights.push({
- ...weight,
- petId: petId,
- weight: parseFloat(weight.weight) // Convert string to number
- });
- });
- }
- });
- setWeightRecords(flattenedWeights);
- } else {
- // If flat structure
- setWeightRecords(weightsData);
- }
- }
+ setWeightRecords(fetchedWeights);
} catch (err: any) {
console.error('Failed to fetch data:', err);
setError('Tietojen lataus epäonnistui. Yritä uudelleen.');
@@ -107,6 +90,8 @@ export default function WeightManagementScreen() {
);
const handleOpenModal = () => {
+ setIsEditMode(false);
+ setEditingWeightId(null);
setWeight('');
setDate(new Date().toISOString().split('T')[0]);
setModalVisible(true);
@@ -114,51 +99,57 @@ export default function WeightManagementScreen() {
const handleCloseModal = () => {
setModalVisible(false);
+ setIsEditMode(false);
+ setEditingWeightId(null);
};
const handleSaveWeight = async () => {
- if (!selectedPetId || !weight) {
+ if (!weight) {
alert('Täytä kaikki pakolliset kentät');
return;
}
try {
setSaving(true);
-
- const weightData = {
- pet_id: selectedPetId,
- weight: parseFloat(weight),
- date: date
- };
- const response = await apiClient.post('/api/weights', weightData);
+ if (isEditMode && editingWeightId) {
- if (response.data.success) {
- const weightsResponse = await apiClient.get('/api/weights');
- if (weightsResponse.data.success && weightsResponse.data.data) {
- const weightsData = weightsResponse.data.data;
+ const weightData = {
+ pet_id: selectedPetId,
+ weight: parseFloat(weight),
+ date: date
+ };
+
+ const updatedWeight = await weightsService.updateWeight(editingWeightId, weightData);
+
+ if (updatedWeight) {
+ // Refresh weights
+ const refreshedWeights = await weightsService.getAllWeights();
+ setWeightRecords(refreshedWeights);
+
+ handleCloseModal();
+ }
+ } else {
+ if (!selectedPetId) {
+ alert('Valitse lemmikki ennen tallentamista.');
+ return;
+ }
+
+ const weightData = {
+ pet_id: selectedPetId,
+ weight: parseFloat(weight),
+ date: date
+ };
+
+ const newWeight = await weightsService.createWeight(weightData);
+
+ if (newWeight) {
+ // Refresh weights
+ const refreshedWeights = await weightsService.getAllWeights();
+ setWeightRecords(refreshedWeights);
- if (Array.isArray(weightsData) && weightsData.length > 0 && weightsData[0].weights) {
- const flattenedWeights: WeightRecord[] = [];
- weightsData.forEach((petWeightGroup: any) => {
- const petId = petWeightGroup.pet_id;
- if (petWeightGroup.weights && Array.isArray(petWeightGroup.weights)) {
- petWeightGroup.weights.forEach((weight: any) => {
- flattenedWeights.push({
- ...weight,
- petId: petId,
- weight: parseFloat(weight.weight)
- });
- });
- }
- });
- setWeightRecords(flattenedWeights);
- } else {
- setWeightRecords(weightsData);
- }
+ handleCloseModal();
}
-
- handleCloseModal();
}
} catch (err: any) {
alert('Painon tallentaminen epäonnistui. Yritä uudelleen.');
@@ -167,6 +158,43 @@ export default function WeightManagementScreen() {
}
};
+ const handleEditWeightRecord = (weightRecord: WeightRecord) => {
+ setIsEditMode(true);
+ setEditingWeightId(weightRecord.id);
+
+ // Pre-fill form with weight data
+ const dateOnly = weightRecord.date.split('T')[0];
+ setWeight(weightRecord.weight.toString());
+ setDate(dateOnly);
+ setModalVisible(true);
+ };
+
+ const handleDeleteWeightRecord = async (weightRecord: WeightRecord) => {
+ setWeightToDelete(weightRecord);
+ setDeleteDialogVisible(true);
+ };
+
+ const confirmDeleteWeightRecord = async () => {
+ if (!weightToDelete) return;
+
+ try {
+ const success = await weightsService.deleteWeight(weightToDelete.id);
+
+ if (success) {
+ // Refresh weights
+ const refreshedWeights = await weightsService.getAllWeights();
+ setWeightRecords(refreshedWeights);
+ setDeleteDialogVisible(false);
+ setWeightToDelete(null);
+ } else {
+ alert('Painomittauksen poistaminen epäonnistui. Yritä uudelleen.');
+ }
+ } catch (err: any) {
+ console.error("Failed to delete weight record:", err);
+ alert('Painomittauksen poistaminen epäonnistui. Yritä uudelleen.');
+ }
+ };
+
const formatDate = (dateString: string) => {
const date = new Date(dateString);
return date.toLocaleDateString('fi-FI', {
@@ -223,36 +251,29 @@ export default function WeightManagementScreen() {
)}
-
-
-
- {formatDate(record.date)}
-
-
+
- {record.measuredBy && (
- <>
-
-
-
-
- {record.measuredBy}
+
+ {record.date ? (
+
+
+
+ {formatDate(record.date)}
- >
- )}
-
- {record.notes && (
- <>
-
-
-
-
- {record.notes}
-
+ ) : (
+
+ )}
+
+
+ handleEditWeightRecord(record)} style={styles.actionButton}>
+
+
+ handleDeleteWeightRecord(record)} style={styles.actionButton}>
+
+
- >
- )}
+
);
@@ -646,7 +667,7 @@ export default function WeightManagementScreen() {
contentContainerStyle={styles.scrollContentContainer}
>
- Lisää painomittaus
+ {isEditMode ? 'Muokkaa painomittausta' : 'Lisää painomittaus'}
setShowDatePicker(true)}>
+
);
}
-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%',
- },
- modalContainer: {
- backgroundColor: COLORS.surface,
- margin: SPACING.lg,
- padding: SPACING.lg,
- borderRadius: 12,
- maxHeight: '90%',
- },
- scrollContentContainer: {
- paddingBottom: 0,
- },
- modalTitle: {
- marginBottom: SPACING.lg,
- fontWeight: 'bold',
- color: COLORS.onSurface,
- },
- input: {
- marginBottom: SPACING.md,
- },
- modalButtons: {
- flexDirection: 'row',
- justifyContent: 'space-between',
- marginTop: SPACING.lg,
- gap: SPACING.md,
- },
- modalButton: {
- flex: 1,
- },
-});
diff --git a/mobile/src/services/calendarService.ts b/mobile/src/services/calendarService.ts
new file mode 100644
index 0000000..b2eb479
--- /dev/null
+++ b/mobile/src/services/calendarService.ts
@@ -0,0 +1,232 @@
+// Calendar service for managing calendar events
+import apiClient from './api';
+
+export interface CalendarEvent {
+ id: number;
+ petId: number;
+ title: string;
+ description?: string;
+ date: string;
+ eventType: 'vaccination' | 'veterinary' | 'medication' | 'grooming' | 'other';
+ completed: boolean;
+ notificationEnabled?: boolean;
+ notificationTime?: string; // Time before event to notify (e.g., '1 day', '1 hour')
+}
+
+export interface CreateCalendarEventData {
+ pet_id: number;
+ title: string;
+ description?: string;
+ date: string;
+ event_type?: CalendarEvent['eventType'];
+ visit_type_id?: number;
+ completed?: boolean;
+ notification_enabled?: boolean;
+ notification_time?: string;
+}
+
+export interface UpdateCalendarEventData {
+ pet_id?: number;
+ title?: string;
+ description?: string;
+ date?: string;
+ event_type?: CalendarEvent['eventType'];
+ completed?: boolean;
+ notification_enabled?: boolean;
+ notification_time?: string;
+}
+
+export const calendarService = {
+ /**
+ * Get all calendar events
+ */
+ async getAllEvents(): Promise {
+ try {
+ const response = await apiClient.get('/api/calendar-events');
+
+ if (response.data.success && response.data.data) {
+ return response.data.data.map((event: any) => ({
+ id: event.id,
+ petId: event.pet_id,
+ title: event.title,
+ description: event.description,
+ date: event.date,
+ eventType: event.event_type,
+ completed: event.completed || false,
+ notificationEnabled: event.notification_enabled,
+ notificationTime: event.notification_time,
+ }));
+ }
+
+ return [];
+ } catch (error: any) {
+ console.error('Failed to fetch calendar events:', error);
+ throw error;
+ }
+ },
+
+ /**
+ * Get events for a specific pet
+ */
+ async getEventsByPetId(petId: number): Promise {
+ try {
+ const response = await apiClient.get(`/api/calendar-events/pet/${petId}`);
+
+ if (response.data.success && response.data.data) {
+ return response.data.data.map((event: any) => ({
+ id: event.id,
+ petId: event.pet_id,
+ title: event.title,
+ description: event.description,
+ date: event.date,
+ eventType: event.event_type,
+ completed: event.completed || false,
+ notificationEnabled: event.notification_enabled,
+ notificationTime: event.notification_time,
+ }));
+ }
+
+ return [];
+ } catch (error: any) {
+ console.error(`Failed to fetch events for pet ${petId}:`, error);
+ throw error;
+ }
+ },
+
+ /**
+ * Get a single event by ID
+ */
+ async getEventById(eventId: number): Promise {
+ try {
+ const response = await apiClient.get(`/api/calendar-events/${eventId}`);
+
+ if (response.data.success && response.data.data) {
+ const event = response.data.data;
+ return {
+ id: event.id,
+ petId: event.pet_id,
+ title: event.title,
+ description: event.description,
+ date: event.date,
+ eventType: event.event_type,
+ completed: event.completed || false,
+ notificationEnabled: event.notification_enabled,
+ notificationTime: event.notification_time,
+ };
+ }
+
+ return null;
+ } catch (error: any) {
+ console.error(`Failed to fetch event ${eventId}:`, error);
+ throw error;
+ }
+ },
+
+ /**
+ * Create a new calendar event
+ */
+ async createEvent(eventData: CreateCalendarEventData): Promise {
+ try {
+ const response = await apiClient.post('/api/calendar-events', eventData);
+
+ if (response.data.success && response.data.data) {
+ const event = response.data.data;
+ return {
+ id: event.id,
+ petId: event.pet_id,
+ title: event.title,
+ description: event.description,
+ date: event.date,
+ eventType: event.event_type,
+ completed: event.completed || false,
+ notificationEnabled: event.notification_enabled,
+ notificationTime: event.notification_time,
+ };
+ }
+
+ return null;
+ } catch (error: any) {
+ console.error('Failed to create event:', error);
+ throw error;
+ }
+ },
+
+ /**
+ * Update an existing calendar event
+ */
+ async updateEvent(eventId: number, eventData: UpdateCalendarEventData): Promise {
+ try {
+ const response = await apiClient.put(`/api/calendar-events/${eventId}`, eventData);
+
+ if (response.data.success && response.data.data) {
+ const event = response.data.data;
+ return {
+ id: event.id,
+ petId: event.pet_id,
+ title: event.title,
+ description: event.description,
+ date: event.date,
+ eventType: event.event_type,
+ completed: event.completed || false,
+ notificationEnabled: event.notification_enabled,
+ notificationTime: event.notification_time,
+ };
+ }
+
+ return null;
+ } catch (error: any) {
+ console.error(`Failed to update event ${eventId}:`, error);
+ throw error;
+ }
+ },
+
+ /**
+ * Delete a calendar event
+ */
+ async deleteEvent(eventId: number): Promise {
+ try {
+ const response = await apiClient.delete(`/api/calendar-events/${eventId}`);
+ return response.data.success;
+ } catch (error: any) {
+ console.error(`Failed to delete event ${eventId}:`, error);
+ throw error;
+ }
+ },
+
+ /**
+ * Mark an event as completed
+ */
+ async markEventCompleted(eventId: number): Promise {
+ return this.updateEvent(eventId, { completed: true });
+ },
+
+ /**
+ * Mark an event as incomplete
+ */
+ async markEventIncomplete(eventId: number): Promise {
+ return this.updateEvent(eventId, { completed: false });
+ },
+
+ /**
+ * Get upcoming events (events in the future)
+ */
+ async getUpcomingEvents(petId?: number): Promise {
+ try {
+ const events = petId
+ ? await this.getEventsByPetId(petId)
+ : await this.getAllEvents();
+
+ const today = new Date();
+ today.setHours(0, 0, 0, 0);
+
+ return events
+ .filter(event => new Date(event.date) >= today && !event.completed)
+ .sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime());
+ } catch (error: any) {
+ console.error('Failed to fetch upcoming events:', error);
+ throw error;
+ }
+ },
+};
+
+export default calendarService;
diff --git a/mobile/src/services/medicationsService.ts b/mobile/src/services/medicationsService.ts
new file mode 100644
index 0000000..7defda7
--- /dev/null
+++ b/mobile/src/services/medicationsService.ts
@@ -0,0 +1,87 @@
+import apiClient from "./api";
+
+interface Medication {
+ id: number;
+ pet_id: number;
+ med_name: string;
+ medication_date: string;
+ expire_date?: string;
+ notes?: string;
+ costs?: string;
+}
+
+interface CreateMedicationData {
+ pet_id: number;
+ med_name: string;
+ medication_date: string;
+ expire_date?: string;
+ notes?: string;
+ costs?: number;
+}
+
+interface UpdateMedicationData {
+ med_name?: string;
+ medication_date?: string;
+ expire_date?: string;
+ notes?: string;
+ costs?: number;
+}
+
+export const medicationsService = {
+ // Get all medications
+ getAllMedications: async (): Promise => {
+ const response = await apiClient.get('/api/medications');
+
+ if (response.data.success && response.data.data) {
+ // Flatten the nested structure: each pet has a medications array
+ const flattenedMedications: Medication[] = [];
+ response.data.data.forEach((petMedicationGroup: any) => {
+ const petId = petMedicationGroup.pet_id;
+ if (petMedicationGroup.medications && Array.isArray(petMedicationGroup.medications)) {
+ petMedicationGroup.medications.forEach((medication: any) => {
+ flattenedMedications.push({
+ ...medication,
+ pet_id: petId
+ });
+ });
+ }
+ });
+ return flattenedMedications;
+ }
+
+ return [];
+ },
+
+ // get medications for a specific pet
+ getMedicationsByPetId: async (petId: number): Promise => {
+ const allMedications = await medicationsService.getAllMedications();
+ return allMedications.filter(medication => medication.pet_id === petId);
+ },
+
+ // Create a new medication
+ createMedication: async (medicationData: CreateMedicationData): Promise => {
+ const response = await apiClient.post('/api/medications', medicationData);
+
+ if (response.data.success && response.data.data) {
+ return response.data.data;
+ }
+ return null;
+ },
+
+ // Update an existing medication
+ updateMedication: async (medicationId: number, updatedData: UpdateMedicationData): Promise => {
+ const response = await apiClient.put(`/api/medications/${medicationId}`, updatedData);
+
+ if (response.data.success && response.data.data) {
+ return response.data.data;
+ }
+
+ return null;
+ },
+
+ // Delete a medication
+ deleteMedication: async (medicationId: number): Promise => {
+ const response = await apiClient.delete(`/api/medications/${medicationId}`);
+ return response.data.success;
+ },
+};
\ No newline at end of file
diff --git a/mobile/src/services/vaccinationsService.ts b/mobile/src/services/vaccinationsService.ts
new file mode 100644
index 0000000..cbbfd4d
--- /dev/null
+++ b/mobile/src/services/vaccinationsService.ts
@@ -0,0 +1,88 @@
+import apiClient from './api';
+
+interface Vaccination {
+ id: number;
+ pet_id: number;
+ vac_name: string;
+ vaccination_date: string;
+ expire_date?: string;
+ costs?: string;
+ notes?: string;
+}
+
+interface CreateVaccinationData {
+ pet_id: number;
+ vac_name: string;
+ vaccination_date: string;
+ expire_date?: string;
+ notes?: string;
+ costs?: number;
+}
+
+interface UpdateVaccinationData {
+ vac_name?: string;
+ vaccination_date?: string;
+ expire_date?: string;
+ notes?: string;
+ costs?: number;
+}
+
+export const vaccinationsService = {
+ // Get all vaccinations
+ getAllVaccinations: async (): Promise => {
+ const response = await apiClient.get('/api/vaccinations');
+
+ if (response.data.success && response.data.data) {
+ // Flatten the nested structure: each pet has a vaccinations array
+ const flattenedVaccinations: Vaccination[] = [];
+ response.data.data.forEach((petVaccinationGroup: any) => {
+ const petId = petVaccinationGroup.pet_id;
+ if (petVaccinationGroup.vaccinations && Array.isArray(petVaccinationGroup.vaccinations)) {
+ petVaccinationGroup.vaccinations.forEach((vaccination: any) => {
+ flattenedVaccinations.push({
+ ...vaccination,
+ pet_id: petId
+ });
+ });
+ }
+ });
+ return flattenedVaccinations;
+ }
+
+ return [];
+ },
+
+ // Get vaccinations for a specific pet
+ getVaccinationsByPetId: async (petId: number): Promise => {
+ const allVaccinations = await vaccinationsService.getAllVaccinations();
+ return allVaccinations.filter(vaccination => vaccination.pet_id === petId);
+ },
+
+ // Create a new vaccination
+ createVaccination: async (vaccinationData: CreateVaccinationData): Promise => {
+ const response = await apiClient.post('/api/vaccinations', vaccinationData);
+
+ if (response.data.success) {
+ return response.data.data;
+ }
+
+ return null;
+ },
+
+ // Update a vaccination
+ updateVaccination: async (vaccinationId: number, vaccinationData: UpdateVaccinationData): Promise => {
+ const response = await apiClient.put(`/api/vaccinations/${vaccinationId}`, vaccinationData);
+
+ if (response.data.success) {
+ return response.data.data;
+ }
+
+ return null;
+ },
+
+ // Delete a vaccination
+ deleteVaccination: async (vaccinationId: number): Promise => {
+ const response = await apiClient.delete(`/api/vaccinations/${vaccinationId}`);
+ return response.data.success;
+ },
+};
diff --git a/mobile/src/services/visitsService.ts b/mobile/src/services/visitsService.ts
new file mode 100644
index 0000000..dcdbad7
--- /dev/null
+++ b/mobile/src/services/visitsService.ts
@@ -0,0 +1,107 @@
+import apiClient from './api';
+
+interface Visit {
+ id: number;
+ pet_id: number;
+ visit_date: string;
+ location: string;
+ vet_name: string;
+ type_id: string;
+ notes?: string;
+ costs?: string;
+}
+
+interface VisitType {
+ id: number;
+ name: string;
+}
+
+interface CreateVisitData {
+ pet_id: number;
+ visit_date: string;
+ vet_name: string;
+ location: string;
+ type_id: number;
+ notes?: string;
+ costs?: number;
+}
+
+interface UpdateVisitData {
+ visit_date?: string;
+ vet_name?: string;
+ location?: string;
+ type_id?: number;
+ notes?: string;
+ costs?: number;
+}
+
+export const visitsService = {
+ // Get all visits
+ getAllVisits: async (): Promise => {
+ const response = await apiClient.get('/api/vet-visits');
+
+ if (response.data.success && response.data.data) {
+ // Flatten the nested structure: each pet has a vet_visits array
+ const flattenedVisits: Visit[] = [];
+ response.data.data.forEach((petVisitGroup: any) => {
+ const petId = petVisitGroup.pet_id;
+ if (petVisitGroup.vet_visits && Array.isArray(petVisitGroup.vet_visits)) {
+ petVisitGroup.vet_visits.forEach((visit: any) => {
+ flattenedVisits.push({
+ ...visit,
+ pet_id: petId
+ });
+ });
+ }
+ });
+ return flattenedVisits;
+ }
+
+ return [];
+ },
+
+ // Get visits for a specific pet
+ getVisitsByPetId: async (petId: number): Promise => {
+ const allVisits = await visitsService.getAllVisits();
+ return allVisits.filter(visit => visit.pet_id === petId);
+ },
+
+ // Get visit types
+ getVisitTypes: async (): Promise => {
+ const response = await apiClient.get('/api/vet-visits/types');
+
+ if (response.data.success && response.data.data) {
+ return response.data.data;
+ }
+
+ return [];
+ },
+
+ // Create a new visit
+ createVisit: async (visitData: CreateVisitData): Promise => {
+ const response = await apiClient.post('/api/vet-visits', visitData);
+
+ if (response.data.success) {
+ return response.data.data;
+ }
+
+ return null;
+ },
+
+ // Update a visit
+ updateVisit: async (visitId: number, visitData: UpdateVisitData): Promise => {
+ const response = await apiClient.put(`/api/vet-visits/${visitId}`, visitData);
+
+ if (response.data.success) {
+ return response.data.data;
+ }
+
+ return null;
+ },
+
+ // Delete a visit
+ deleteVisit: async (visitId: number): Promise => {
+ const response = await apiClient.delete(`/api/vet-visits/${visitId}`);
+ return response.data.success;
+ },
+};
diff --git a/mobile/src/services/weightsService.ts b/mobile/src/services/weightsService.ts
new file mode 100644
index 0000000..6a50537
--- /dev/null
+++ b/mobile/src/services/weightsService.ts
@@ -0,0 +1,94 @@
+import apiClient from './api';
+
+interface WeightRecord {
+ id: number;
+ petId: number;
+ date: string;
+ weight: number;
+ created_at: string;
+ notes?: string;
+ measuredBy?: string;
+}
+
+interface CreateWeightData {
+ pet_id: number;
+ weight: number;
+ date: string;
+ notes?: string;
+ measuredBy?: string;
+}
+
+interface UpdateWeightData {
+ weight?: number;
+ date?: string;
+ notes?: string;
+ measuredBy?: string;
+}
+
+export const weightsService = {
+ // Get all weight records
+ getAllWeights: async (): Promise => {
+ const response = await apiClient.get('/api/weights');
+
+ if (response.data.success && response.data.data) {
+ const weightsData = response.data.data;
+
+ // If nested structure (array of pet weight groups)
+ if (Array.isArray(weightsData) && weightsData.length > 0 && weightsData[0].weights) {
+ const flattenedWeights: WeightRecord[] = [];
+ weightsData.forEach((petWeightGroup: any) => {
+ const petId = petWeightGroup.pet_id;
+ if (petWeightGroup.weights && Array.isArray(petWeightGroup.weights)) {
+ petWeightGroup.weights.forEach((weight: any) => {
+ flattenedWeights.push({
+ ...weight,
+ petId: petId,
+ weight: parseFloat(weight.weight) // Convert string to number
+ });
+ });
+ }
+ });
+ return flattenedWeights;
+ } else {
+ // If flat structure
+ return weightsData;
+ }
+ }
+
+ return [];
+ },
+
+ // Get weight records for a specific pet
+ getWeightsByPetId: async (petId: number): Promise => {
+ const allWeights = await weightsService.getAllWeights();
+ return allWeights.filter(weight => weight.petId === petId);
+ },
+
+ // Create a new weight record
+ createWeight: async (weightData: CreateWeightData): Promise => {
+ const response = await apiClient.post('/api/weights', weightData);
+
+ if (response.data.success) {
+ return response.data.data;
+ }
+
+ return null;
+ },
+
+ // Update a weight record
+ updateWeight: async (weightId: number, weightData: UpdateWeightData): Promise => {
+ const response = await apiClient.put(`/api/weights/${weightId}`, weightData);
+
+ if (response.data.success) {
+ return response.data.data;
+ }
+
+ return null;
+ },
+
+ // Delete a weight record
+ deleteWeight: async (weightId: number): Promise => {
+ const response = await apiClient.delete(`/api/weights/${weightId}`);
+ return response.data.success;
+ },
+};
diff --git a/mobile/src/styles/authStyles.ts b/mobile/src/styles/authStyles.ts
index 1f1317c..569fd2a 100644
--- a/mobile/src/styles/authStyles.ts
+++ b/mobile/src/styles/authStyles.ts
@@ -78,6 +78,7 @@ export const authStyles = StyleSheet.create({
...COMMON_STYLES.buttonContent,
},
+
// Links
forgotPassword: {
...TYPOGRAPHY.bodyMedium,
diff --git a/mobile/src/styles/screenStyles.ts b/mobile/src/styles/screenStyles.ts
index 1915a9c..8f3c75a 100644
--- a/mobile/src/styles/screenStyles.ts
+++ b/mobile/src/styles/screenStyles.ts
@@ -234,6 +234,1337 @@ export const profileStyles = StyleSheet.create({
},
logoutButton: {
- borderRadius: COMMON_STYLES.button.borderRadius,
+ marginBottom: 0,
},
});
+
+// ============================================
+// VISITS SCREEN STYLES
+// ============================================
+export const visitsStyles = 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,
+ },
+
+ bottomSection: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ justifyContent: 'space-between',
+ gap: SPACING.sm,
+ },
+
+ notesContainer: {
+ flexDirection: 'row',
+ gap: SPACING.sm,
+ flex: 1,
+ alignItems: 'flex-start',
+ },
+
+ notesText: {
+ flex: 1,
+ color: COLORS.onSurfaceVariant,
+ fontStyle: 'italic',
+ },
+
+ cardActions: {
+ marginTop: SPACING.md,
+ alignItems: 'flex-end',
+ },
+
+ actionButtons: {
+ flexDirection: 'row',
+ gap: SPACING.sm,
+ },
+
+ actionButton: {
+ padding: SPACING.xs,
+ borderRadius: 8,
+ backgroundColor: COLORS.surfaceVariant,
+ justifyContent: 'center' as const,
+ alignItems: 'center' as const,
+ },
+
+ 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',
+ margin: SPACING.lg,
+ right: 0,
+ bottom: 0,
+ backgroundColor: COLORS.primaryContainer,
+ },
+
+ modalContainer: {
+ backgroundColor: COLORS.surface,
+ margin: SPACING.lg,
+ padding: SPACING.lg,
+ borderRadius: 12,
+ maxHeight: '90%',
+ },
+
+ keyboardAvoid: {
+ width: '100%',
+ },
+
+ scrollContentContainer: {
+ paddingBottom: 0,
+ },
+
+ modalTitle: {
+ marginBottom: SPACING.lg,
+ fontWeight: 'bold',
+ color: COLORS.onSurface,
+ },
+
+ input: {
+ marginBottom: SPACING.md,
+ },
+
+ modalButtons: {
+ flexDirection: 'row',
+ justifyContent: 'space-between',
+ marginTop: SPACING.lg,
+ gap: SPACING.md,
+ },
+
+ modalButton: {
+ flex: 1,
+ },
+});
+
+// ============================================
+// MEDICATIONS SCREEN STYLES
+// ============================================
+export const medicationsStyles = 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: {
+ 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,
+ },
+
+ dosageContainer: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ gap: SPACING.xs,
+ marginBottom: SPACING.sm,
+ },
+
+ dosage: {
+ fontWeight: '600',
+ color: COLORS.onSurface,
+ },
+
+ divider: {
+ marginVertical: SPACING.sm,
+ },
+
+ bottomSection: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ justifyContent: 'space-between',
+ gap: SPACING.sm,
+ },
+
+ notesContainer: {
+ flexDirection: 'row',
+ gap: SPACING.sm,
+ flex: 1,
+ alignItems: 'flex-start',
+ },
+
+ notesText: {
+ flex: 1,
+ color: COLORS.onSurfaceVariant,
+ fontStyle: 'italic',
+ },
+
+ cardActions: {
+ marginTop: SPACING.md,
+ alignItems: 'flex-end',
+ },
+
+ actionButtons: {
+ flexDirection: 'row',
+ gap: SPACING.sm,
+ },
+
+ actionButton: {
+ padding: SPACING.xs,
+ borderRadius: 8,
+ backgroundColor: COLORS.surfaceVariant,
+ justifyContent: 'center' as const,
+ alignItems: 'center' as const,
+ },
+
+ 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',
+ margin: SPACING.lg,
+ right: 0,
+ bottom: 0,
+ backgroundColor: COLORS.primaryContainer,
+ },
+
+ modalContainer: {
+ backgroundColor: COLORS.surface,
+ margin: SPACING.lg,
+ padding: SPACING.lg,
+ borderRadius: 12,
+ maxHeight: '90%',
+ },
+
+ scrollContentContainer: {
+ paddingBottom: 0,
+ },
+
+ modalTitle: {
+ marginBottom: SPACING.lg,
+ fontWeight: 'bold',
+ color: COLORS.onSurface,
+ },
+
+ input: {
+ marginBottom: SPACING.md,
+ },
+
+ modalButtons: {
+ flexDirection: 'row',
+ justifyContent: 'space-between',
+ marginTop: SPACING.lg,
+ gap: SPACING.md,
+ },
+
+ modalButton: {
+ flex: 1,
+ },
+});
+
+// ============================================
+// VACCINATIONS SCREEN STYLES
+// ============================================
+export const vaccinationsStyles = 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: {
+ 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,
+ },
+
+ dateContainer: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ gap: SPACING.xs,
+ marginBottom: SPACING.sm,
+ },
+
+ dateText: {
+ fontWeight: '600',
+ color: COLORS.onSurface,
+ },
+
+ divider: {
+ marginVertical: SPACING.sm,
+ },
+
+ bottomSection: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ justifyContent: 'space-between',
+ gap: SPACING.sm,
+ },
+
+ notesContainer: {
+ flexDirection: 'row',
+ gap: SPACING.sm,
+ flex: 1,
+ alignItems: 'flex-start',
+ },
+
+ notesText: {
+ flex: 1,
+ color: COLORS.onSurfaceVariant,
+ fontStyle: 'italic',
+ },
+
+ cardActions: {
+ marginTop: SPACING.md,
+ alignItems: 'flex-end',
+ },
+
+ actionButtons: {
+ flexDirection: 'row',
+ gap: SPACING.sm,
+ },
+
+ actionButton: {
+ padding: SPACING.xs,
+ borderRadius: 8,
+ backgroundColor: COLORS.surfaceVariant,
+ justifyContent: 'center' as const,
+ alignItems: 'center' as const,
+ },
+
+ 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',
+ margin: SPACING.lg,
+ right: 0,
+ bottom: 0,
+ backgroundColor: COLORS.primaryContainer,
+ },
+
+ modalContainer: {
+ backgroundColor: COLORS.surface,
+ margin: SPACING.lg,
+ padding: SPACING.lg,
+ borderRadius: 12,
+ maxHeight: '90%',
+ },
+
+ scrollContentContainer: {
+ paddingBottom: 0,
+ },
+
+ modalTitle: {
+ marginBottom: SPACING.lg,
+ fontWeight: 'bold',
+ color: COLORS.onSurface,
+ },
+
+ input: {
+ marginBottom: SPACING.md,
+ },
+
+ modalButtons: {
+ flexDirection: 'row',
+ justifyContent: 'space-between',
+ marginTop: SPACING.lg,
+ gap: SPACING.md,
+ },
+
+ modalButton: {
+ flex: 1,
+ },
+});
+
+// ============================================
+// WEIGHTS SCREEN STYLES
+// ============================================
+export const weightsStyles = 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',
+ flex: 1,
+ alignItems: 'flex-start',
+ 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,
+ },
+
+ bottomSection: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ justifyContent: 'space-between',
+ gap: SPACING.sm,
+ },
+
+ notesContainer: {
+ flexDirection: 'row',
+ gap: SPACING.sm,
+ flex: 1,
+ alignItems: 'flex-start',
+ },
+
+ notesText: {
+ flex: 1,
+ color: COLORS.onSurfaceVariant,
+ fontStyle: 'italic',
+ },
+
+ cardActions: {
+ marginTop: SPACING.md,
+ alignItems: 'flex-end',
+ },
+
+ actionButtons: {
+ flexDirection: 'row',
+ gap: SPACING.sm,
+ },
+
+ actionButton: {
+ padding: SPACING.xs,
+ borderRadius: 8,
+ backgroundColor: COLORS.surfaceVariant,
+ justifyContent: 'center' as const,
+ alignItems: 'center' as const,
+ },
+
+ 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',
+ margin: SPACING.lg,
+ right: 0,
+ bottom: 0,
+ backgroundColor: COLORS.primaryContainer,
+ },
+
+ graphScrollView: {
+ width: '100%',
+ },
+
+ modalContainer: {
+ backgroundColor: COLORS.surface,
+ margin: SPACING.lg,
+ padding: SPACING.lg,
+ borderRadius: 12,
+ maxHeight: '90%',
+ },
+
+ scrollContentContainer: {
+ paddingBottom: 0,
+ },
+
+ modalTitle: {
+ marginBottom: SPACING.lg,
+ fontWeight: 'bold',
+ color: COLORS.onSurface,
+ },
+
+ input: {
+ marginBottom: SPACING.md,
+ },
+
+ modalButtons: {
+ flexDirection: 'row',
+ justifyContent: 'space-between',
+ marginTop: SPACING.lg,
+ gap: SPACING.md,
+ },
+
+ modalButton: {
+ flex: 1,
+ },
+});
+
+// ============================================
+// CALENDAR SCREEN STYLES
+// ============================================
+export const calendarStyles = StyleSheet.create({
+ container: {
+ flex: 1,
+ backgroundColor: COLORS.background,
+ },
+
+ 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',
+ margin: SPACING.lg,
+ right: 0,
+ bottom: 0,
+ backgroundColor: COLORS.primaryContainer,
+ },
+
+ 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,
+ paddingBottom: 100,
+ },
+
+ // Month Navigation
+ monthNavigation: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ justifyContent: 'space-between',
+ marginBottom: SPACING.md,
+ paddingHorizontal: SPACING.sm,
+ },
+
+ navButton: {
+ padding: SPACING.xs,
+ borderRadius: 8,
+ },
+
+ monthYearDisplay: {
+ alignItems: 'center',
+ },
+
+ monthText: {
+ fontWeight: '600',
+ color: COLORS.onSurface,
+ },
+
+ yearText: {
+ color: COLORS.onSurfaceVariant,
+ marginTop: 2,
+ },
+
+ // Year Selection
+ yearTabsContainer: {
+ marginBottom: SPACING.md,
+ },
+
+ yearTabsContent: {
+ paddingHorizontal: SPACING.xs,
+ gap: SPACING.xs,
+ },
+
+ yearTab: {
+ backgroundColor: COLORS.surfaceVariant,
+ },
+
+ selectedYearTab: {
+ backgroundColor: COLORS.primary,
+ },
+
+ selectedYearTabText: {
+ color: '#FFFFFF',
+ fontWeight: '600',
+ },
+
+ unselectedYearTabText: {
+ color: COLORS.onSurfaceVariant,
+ },
+
+ // Calendar Grid
+ calendarGrid: {
+ backgroundColor: COLORS.surface,
+ borderRadius: 12,
+ padding: SPACING.sm,
+ marginBottom: SPACING.lg,
+ elevation: 2,
+ },
+
+ weekRow: {
+ flexDirection: 'row',
+ marginBottom: SPACING.xs,
+ },
+
+ dayHeader: {
+ flex: 1,
+ alignItems: 'center',
+ paddingVertical: SPACING.xs,
+ },
+
+ dayHeaderText: {
+ color: COLORS.onSurfaceVariant,
+ fontWeight: '600',
+ },
+
+ daysContainer: {
+ flexDirection: 'row',
+ flexWrap: 'wrap',
+ },
+
+ dayCell: {
+ width: `${100 / 7}%`,
+ aspectRatio: 1,
+ padding: 4,
+ },
+
+ todayCell: {
+ backgroundColor: COLORS.primaryContainer + '30',
+ borderRadius: 8,
+ },
+
+ dayCellContent: {
+ flex: 1,
+ alignItems: 'center',
+ justifyContent: 'flex-start',
+ paddingTop: 4,
+ },
+
+ dayNumber: {
+ color: COLORS.onSurface,
+ fontWeight: '500',
+ },
+
+ todayNumber: {
+ color: COLORS.primary,
+ fontWeight: '700',
+ },
+
+ eventIndicators: {
+ flexDirection: 'row',
+ flexWrap: 'wrap',
+ marginTop: 4,
+ alignItems: 'center',
+ justifyContent: 'center',
+ gap: 2,
+ },
+
+ eventDot: {
+ width: 6,
+ height: 6,
+ borderRadius: 3,
+ },
+
+ moreEventsText: {
+ fontSize: 9,
+ color: COLORS.onSurfaceVariant,
+ marginLeft: 2,
+ },
+
+ // Events List
+ eventsList: {
+ marginTop: SPACING.md,
+ },
+
+ eventsListTitle: {
+ marginBottom: SPACING.md,
+ fontWeight: '600',
+ color: COLORS.onSurface,
+ },
+
+ emptyEventsContainer: {
+ alignItems: 'center',
+ paddingVertical: SPACING.xl,
+ },
+
+ emptyEventsText: {
+ marginTop: SPACING.sm,
+ color: COLORS.onSurfaceVariant,
+ },
+
+ eventCard: {
+ backgroundColor: COLORS.surface,
+ borderRadius: 12,
+ padding: SPACING.md,
+ marginBottom: SPACING.sm,
+ elevation: 1,
+ },
+
+ eventCardHeader: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ gap: SPACING.sm,
+ },
+
+ eventCardContent: {
+ flex: 1,
+ },
+
+ eventTitle: {
+ fontWeight: '600',
+ color: COLORS.onSurface,
+ },
+
+ eventDate: {
+ color: COLORS.onSurfaceVariant,
+ marginTop: 2,
+ },
+
+ eventTypeChip: {
+ height: 28,
+ },
+
+ eventDescription: {
+ marginTop: SPACING.sm,
+ color: COLORS.onSurfaceVariant,
+ lineHeight: 20,
+ },
+
+ // Modal
+ modal: {
+ backgroundColor: COLORS.surface,
+ margin: SPACING.lg,
+ padding: SPACING.lg,
+ borderRadius: 16,
+ maxHeight: '80%',
+ },
+
+ modalTitle: {
+ marginBottom: SPACING.lg,
+ fontWeight: '600',
+ color: COLORS.onSurface,
+ },
+
+ input: {
+ marginBottom: SPACING.md,
+ },
+
+ datePickerButton: {
+ marginBottom: SPACING.md,
+ padding: SPACING.md,
+ backgroundColor: COLORS.surfaceVariant,
+ borderRadius: 8,
+ },
+
+ datePickerLabel: {
+ color: COLORS.onSurfaceVariant,
+ marginBottom: SPACING.xs,
+ },
+
+ datePickerValue: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ gap: SPACING.sm,
+ },
+
+ datePickerText: {
+ color: COLORS.onSurface,
+ fontWeight: '500',
+ },
+
+ typePickerButton: {
+ marginBottom: SPACING.md,
+ padding: SPACING.md,
+ backgroundColor: COLORS.surfaceVariant,
+ borderRadius: 8,
+ },
+
+ typePickerLabel: {
+ color: COLORS.onSurfaceVariant,
+ marginBottom: SPACING.xs,
+ },
+
+ typePickerValue: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ gap: SPACING.sm,
+ },
+
+ typePickerText: {
+ flex: 1,
+ color: COLORS.onSurface,
+ fontWeight: '500',
+ },
+
+ modalActions: {
+ flexDirection: 'row',
+ justifyContent: 'flex-end',
+ gap: SPACING.sm,
+ marginTop: SPACING.lg,
+ },
+
+ modalButton: {
+ minWidth: 100,
+ },
+});
\ No newline at end of file
diff --git a/mobile/src/styles/theme.ts b/mobile/src/styles/theme.ts
index dcfee10..97dd53a 100644
--- a/mobile/src/styles/theme.ts
+++ b/mobile/src/styles/theme.ts
@@ -285,8 +285,8 @@ export const COMMON_STYLES = {
logoContainer: {
width: 120,
height: 120,
- justifyContent: 'center',
- alignItems: 'center',
+ justifyContent: 'center' as const,
+ alignItems: 'center' as const,
backgroundColor: COLORS.surfaceVariant,
borderRadius: BORDER_RADIUS.large,
},
diff --git a/mobile/src/types/index.ts b/mobile/src/types/index.ts
index 64ae941..82a83c3 100644
--- a/mobile/src/types/index.ts
+++ b/mobile/src/types/index.ts
@@ -91,3 +91,16 @@ export interface WalkSettings {
autoStartOnMovement: boolean;
trackSteps: boolean;
}
+
+// Calendar Event types
+export interface CalendarEvent {
+ id: number;
+ petId: number;
+ title: string;
+ description?: string;
+ date: string;
+ eventType: 'vaccination' | 'veterinary' | 'medication' | 'grooming' | 'other';
+ completed: boolean;
+ notificationEnabled?: boolean;
+ notificationTime?: string;
+}