Compare commits

...

23 Commits

Author SHA1 Message Date
7b275c4e39 Little fix due to better mark 2025-06-17 19:09:27 +02:00
5e2926b9ef fix... 2025-05-24 18:41:38 +02:00
f1198a8281 fix of tests 2025-05-24 18:41:15 +02:00
7873321be0 few cosmetic fixes 2025-05-24 09:00:48 +02:00
443d4b0366 fix of api url 2025-05-23 21:31:36 +02:00
9d253c4c86 Naprawiono poprawne wyświetlanie zdjęcia od razu po dodaniu.
NORMALIZACJA ZDJECIA JEST NAJWAŻNIESZA
2025-05-23 21:19:11 +02:00
f22de8150d Pobieranie i wysyłanie zdjęć i GPS 2025-05-23 19:36:51 +02:00
0ee2b8d702 Wysyłanie zdjęć na backend i ich odbieranie 2025-05-23 15:13:31 +02:00
c6fbbe9222 show image 2025-05-22 10:21:13 +02:00
613a8753fa images from camera WIP 2025-05-22 07:33:27 +02:00
9733a9f4eb fix of babel... 2025-05-20 19:08:24 +02:00
26bbce92ca testy jednostkowe 2025-05-16 12:49:35 +02:00
Patryk
1a8144aee4 add Zustand and del unnecessary files 2025-05-15 00:06:38 +02:00
c0b8df55a6 prod backend url 2025-05-14 14:53:29 +02:00
02e3576988 proper keyboard 2025-05-14 13:45:02 +02:00
094c66f298 Use of api instead of json file for the creating, deletion and edition 2025-05-14 13:42:35 +02:00
395d68a31a get list of location and get location by id 2025-05-13 15:55:20 +02:00
c242f92745 update to new sdk 2025-05-13 09:26:45 +02:00
Patryk
3147e27913 fix add 2025-04-25 18:55:23 +02:00
Patryk
1b48686d2c change js/tsx to jsx 2025-04-22 19:13:59 +02:00
Patryk
8f5b151d87 init State Mangement and add del function 2025-04-22 18:35:39 +02:00
Patryk
5e44b30a49 init edit 2025-04-15 21:54:54 +02:00
Patryk
6e8a235ed4 init addScreen and change locations 2025-04-13 09:04:31 +02:00
19 changed files with 1207 additions and 12033 deletions

20
App.js
View File

@@ -1,20 +0,0 @@
// import { StatusBar } from 'expo-status-bar';
// import { StyleSheet, Text, View } from 'react-native';
// export default function App() {
// return (
// <View style={styles.container}>
// <Text>Open up App.js to start working on your app!</Text>
// <StatusBar style="auto" />
// </View>
// );
// }
// const styles = StyleSheet.create({
// container: {
// flex: 1,
// backgroundColor: '#fff',
// alignItems: 'center',
// justifyContent: 'center',
// },
// });

View File

@@ -0,0 +1,213 @@
import axios from 'axios';
import {
listLocations,
getLocation,
addLocation,
updateLocation,
deleteLocation
} from '../api/locations';
jest.mock('axios');
describe('Location API Functions', () => {
afterEach(() => {
jest.clearAllMocks();
});
describe('listLocations', () => {
test('should fetch all locations successfully', async () => {
const mockLocations = [
{
"id": 1,
"name": "Warszawa",
"description": "Stolica Polski, położona w centralnej części kraju. Warszawa jest największym miastem w Polsce, znanym z bogatej historii, kultury i architektury. Warto zobaczyć Zamek Królewski, Stare Miasto oraz Muzeum Powstania Warszawskiego. Miasto oferuje wiele atrakcji turystycznych, w tym parki, muzea i teatry. Warszawa jest również ważnym ośrodkiem gospodarczym i kulturalnym.",
"image": "https://www.niesamowitapolska.eu/images/mazowieckie/warszawa/38607734_m.jpg",
"imageSource": {
"uri": "https://www.niesamowitapolska.eu/images/mazowieckie/warszawa/38607734_m.jpg",
},
"area": 517.24,
"population": 1790658,
"longitude": 52.232887,
"latitude": 20.896273
},
{
"id": 2,
"name": "Kielce",
"description": "Stolica województwa świętokrzyskiego, położona w centralnej Polsce. Kielce to miasto w centralnej Polsce, znane z pięknych krajobrazów i bogatej historii. Warto odwiedzić Kielecki Park Etnograficzny, Muzeum Zabawek oraz Katedrę Wniebowzięcia Najświętszej Maryi Panny. Kielce są również znane z licznych festiwali i wydarzeń kulturalnych. Miasto oferuje wiele atrakcji turystycznych, w tym parki, muzea i teatry.",
"image": "https://as2.ftcdn.net/jpg/05/42/90/67/1000_F_542906717_cf5i6HeCJsPluuH5tqq5MbsSdfpopmtT.webp",
"imageSource": {
"uri": "https://as2.ftcdn.net/jpg/05/42/90/67/1000_F_542906717_cf5i6HeCJsPluuH5tqq5MbsSdfpopmtT.webp",
},
"area": 109.4,
"population": 196000,
"longitude": 50.85416,
"latitude": 20.533003
}
];
axios.get.mockResolvedValueOnce({data: mockLocations});
const result = await listLocations();
console.log(result);
expect(axios.get).toHaveBeenCalledWith('https://hopp.zikor.pl/locations/all');
expect(result).toEqual(mockLocations);
});
test('should handle error when fetching locations fails', async () => {
axios.get.mockRejectedValueOnce(new Error('Network error'));
await expect(listLocations()).rejects.toThrow('Network error');
expect(axios.get).toHaveBeenCalledWith('https://hopp.zikor.pl/locations/all');
});
})
describe('getLocation', () => {
test('should fetch a location by id successfully', async () => {
const mockLocation = [
{
id: 1,
name: 'Warszawa',
description: 'Stolica Polski, położona w centralnej części kraju. Warszawa jest największym miastem w Polsce, znanym z bogatej historii, kultury i architektury. Warto zobaczyć Zamek Królewski, Stare Miasto oraz Muzeum Powstania Warszawskiego. Miasto oferuje wiele atrakcji turystycznych, w tym parki, muzea i teatry. Warszawa jest również ważnym ośrodkiem gospodarczym i kulturalnym.',
image: 'https://www.niesamowitapolska.eu/images/mazowieckie/warszawa/38607734_m.jpg',
imageSource: {
uri: 'https://www.niesamowitapolska.eu/images/mazowieckie/warszawa/38607734_m.jpg'
},
area: 517.24,
population: 1790658,
longitude: 52.232887,
latitude: 20.896273
}
];
const toCheck = [
{
id: 1,
name: 'Warszawa',
description: 'Stolica Polski, położona w centralnej części kraju. Warszawa jest największym miastem w Polsce, znanym z bogatej historii, kultury i architektury. Warto zobaczyć Zamek Królewski, Stare Miasto oraz Muzeum Powstania Warszawskiego. Miasto oferuje wiele atrakcji turystycznych, w tym parki, muzea i teatry. Warszawa jest również ważnym ośrodkiem gospodarczym i kulturalnym.',
image: 'https://www.niesamowitapolska.eu/images/mazowieckie/warszawa/38607734_m.jpg',
imageSource: {
uri: 'https://www.niesamowitapolska.eu/images/mazowieckie/warszawa/38607734_m.jpg'
},
area: 517.24,
population: 1790658,
longitude: 52.232887,
latitude: 20.896273
},
{"image" : null}
];
axios.get.mockResolvedValueOnce({data: mockLocation});
const result = await getLocation(1);
console.log(result);
expect(axios.get).toHaveBeenCalledWith('https://hopp.zikor.pl/locations/1');
expect(result).toEqual(toCheck);
});
test('should handle error when fetching a location fails', async () => {
axios.get.mockRejectedValueOnce(new Error('Location not found'));
await expect(getLocation(999)).rejects.toThrow('Location not found');
expect(axios.get).toHaveBeenCalledWith('https://hopp.zikor.pl/locations/999');
});
});
describe('addLocation', () => {
test('should add a location successfully', async () => {
const newLocation = {
"name": "Gdańsk",
"description": "Miasto portowe na północy Polski.",
"image": "https://example.com/gdansk.jpg",
"area": 262.0,
"population": 470907
};
const responseLocation = {
"id": 3,
...newLocation
};
axios.post.mockResolvedValueOnce({data: responseLocation});
const result = await addLocation(newLocation);
expect(axios.post).toHaveBeenCalledWith(
'https://hopp.zikor.pl/locations/add',
newLocation,
{headers: {"Content-Type": "application/json"}}
);
expect(result).toEqual(responseLocation);
});
});
describe('updateLocation', () => {
test('should update a location successfully', async () => {
const locationId = 2;
const updatedDetails = {
"name": "Kielce",
"description": "Updated description for Kielce",
"population": 200000
};
const responseLocation = {
"id": locationId,
"name": "Kielce",
"description": "Updated description for Kielce",
"image": "https://as2.ftcdn.net/jpg/05/42/90/67/1000_F_542906717_cf5i6HeCJsPluuH5tqq5MbsSdfpopmtT.webp",
"area": 109.2,
"population": 200000
};
axios.put.mockResolvedValueOnce({data: responseLocation});
const result = await updateLocation(locationId, updatedDetails);
expect(axios.put).toHaveBeenCalledWith(
`https://hopp.zikor.pl/locations/${locationId}`,
updatedDetails,
{headers: {"Content-Type": "application/json"}}
);
expect(result).toEqual(responseLocation);
});
test('should handle error when updating a location fails', async () => {
const locationId = 999;
const updatedDetails = {population: 300000};
axios.put.mockRejectedValueOnce(new Error('Failed to add location'));
await expect(updateLocation(locationId, updatedDetails)).rejects.toThrow('Failed to add location');
expect(axios.put).toHaveBeenCalledWith(
`https://hopp.zikor.pl/locations/${locationId}`,
updatedDetails,
{headers: {"Content-Type": "application/json"}}
);
});
});
describe('deleteLocation', () => {
test('should delete a location successfully', async () => {
const locationId = 2;
const responseData = {success: true, message: "Location deleted successfully"};
axios.delete.mockResolvedValueOnce({data: responseData});
const result = await deleteLocation(locationId);
expect(axios.delete).toHaveBeenCalledWith(`https://hopp.zikor.pl/locations/${locationId}`);
expect(result).toEqual(responseData);
});
test('should handle error when deleting a location fails', async () => {
const locationId = 999;
axios.delete.mockRejectedValueOnce(new Error('Failed to delete location'));
await expect(deleteLocation(locationId)).rejects.toThrow('Failed to delete location');
expect(axios.delete).toHaveBeenCalledWith(`https://hopp.zikor.pl/locations/${locationId}`);
});
});
})

106
api/locations.jsx Normal file
View File

@@ -0,0 +1,106 @@
import axios from "axios";
const API_URL = "http://192.168.0.130:9010";
export async function listLocations() {
try {
const response = await axios.get(`${API_URL}/locations/all`);
if (!response) {
return "No locations found";
}
console.log("Locations fetched successfully");
return response.data.map(location => ({
...location,
imageSource: normalizeImageSource(location.image)
}));
} catch (error) {
console.error("Error fetching locations:", error);
throw error;
}
}
export const normalizeImageSource = (image) => {
if (!image) return null;
if (typeof image === 'string') {
if (image.startsWith('http://') || image.startsWith('https://')) {
return { uri: image };
}
if (image.startsWith('data:image')) {
return { uri: image };
} else if (image.length > 100) {
return { uri: `data:image/jpeg;base64,${image}` };
}
}
return null;
}
export async function getLocation(id) {
try {
const location = await axios.get(`${API_URL}/locations/${id}`);
if (!location) {
console.error("Could not find location");
return("Location not found");
}
return [...location.data, {image: normalizeImageSource(location.image)}];
} catch (error) {
console.error("Error fetching location:", error);
throw error;
}
}
export async function addLocation(location) {
try {
const response = await axios.post(`${API_URL}/locations/add`, location, {
headers: {
"Content-Type": "application/json",
},
});
if (!response) {
console.log("Error adding location");
return("Failed to add location");
}
console.log("Location added successfully:", response.data);
return response.data;
} catch (error) {
console.error("Error adding location:", error);
throw error;
}
}
export async function updateLocation(id, location) {
try {
const response = await axios.put(`${API_URL}/locations/${id}`, location, {
headers: { "Content-Type": "application/json" },
});
if (!response) {
console.log("Failed to update location");
return("Failed to update location");
}
console.log("Location updated successfully:", response.data);
return response.data;
} catch (error) {
console.error("Error while updating location:", error);
throw error;
}
}
export async function deleteLocation(id) {
try {
const response = await axios.delete(`${API_URL}/locations/${id}`);
if (!response) {
console.log("Error deleting location");
return("Failed to delete location");
}
console.log("Location deleted successfully:", response.data);
return response.data;
} catch (error) {
console.error("Error while deleting location:", error);
throw error;
}
}

View File

@@ -27,7 +27,19 @@
"bundler": "metro"
},
"plugins": [
"expo-router"
"expo-router",
[
"expo-image-picker",
{
"photosPermission": "The app accesses your photos to let you share them with your friends."
}
],
[
"expo-location",
{
"locationAlwaysAndWhenInUsePermission": "Allow $(PRODUCT_NAME) to use your location."
}
]
]
}
}

View File

@@ -5,6 +5,7 @@ import { useTheme } from 'react-native-paper';
export default function TabLayout() {
const theme = useTheme();
return (
<Tabs screenOptions ={{
tabBarInactiveTintColor: theme.colors.primary,
@@ -20,10 +21,11 @@ export default function TabLayout() {
headerTintColor: theme.colors.primary,
tabBarItemStyle: { flex: 1 },
}}>
<Tabs.Screen name="index" options={{ title: 'Home',tabBarIcon: ({ color, focused }) => (
<Ionicons name={focused ? 'home-sharp' : 'home-outline'} color={color} size={24} />
), }} />
<Tabs.Screen name="formScreen" options={{title: 'Create/Edit' ,tabBarIcon: ({ color, focused }) => (
<Tabs.Screen name="add" options={{title: 'Create' ,tabBarIcon: ({ color, focused }) => (
<Ionicons name={focused ? 'add-circle-sharp' : 'add-circle-outline'} color={color} size={24} />
),}} />
</Tabs>

284
app/(tabs)/add.jsx Normal file
View File

@@ -0,0 +1,284 @@
import {useState} from "react";
import {
StyleSheet,
Platform,
KeyboardAvoidingView,
ScrollView,
Image,
View,
Alert,
} from "react-native";
import {TextInput, Button, Snackbar, Text, useTheme} from "react-native-paper";
import {requestCameraPermissionsAsync, launchCameraAsync} from 'expo-image-picker';
import * as Location from 'expo-location';
import useLocationStore from "@/locationStore";
export default function FormScreen() {
const [formData, setFormData] = useState({
name: "",
description: "",
image: "",
area: 0,
population: 0,
latitude: 0,
longitude: 0,
});
const [message, setMessage] = useState("");
const [visible, setVisible] = useState(false);
const [picture, setPicture] = useState(null);
const [imageMethod, setImageMethod] = useState(null);
const [location, setLocation] = useState < Location.LocationObject | null > (null);
const theme = useTheme();
const addLocation = useLocationStore((state) => state.addLocation);
const handleAddLocation = async () => {
if (
formData.name &&
formData.description &&
formData.image &&
formData.area &&
formData.population &&
formData.latitude &&
formData.longitude
) {
const newLocation = {
id: Date.now(),
name: formData.name,
description: formData.description,
image: formData.image,
area: parseFloat(formData.area),
population: parseInt(formData.population),
latitude: parseFloat(formData.latitude),
longitude: parseFloat(formData.longitude),
};
const added = await addLocation(newLocation);
setFormData({
name: "",
description: "",
image: "",
area: 0,
population: 0,
latitude: 0,
longitude: 0
});
setPicture(null);
setImageMethod(null);
if (added != null) {
setMessage("Lokalizacja została dodana!");
setVisible(true);
} else {
setMessage("Wystąpił błąd podczas dodawania lokalizacji!");
setVisible(true);
}
} else {
setMessage("Wypełnij wszystkie pola!");
console.log(formData);
setVisible(true);
}
};
const takePicture = async () => {
const {status} = await requestCameraPermissionsAsync();
if (status !== 'granted') {
setMessage("Brak uprawnień do kamery!");
setVisible(true);
return;
}
const result = await launchCameraAsync({
allowsEditing: false,
base64: true,
quality: 0.2,
});
if (!result.canceled && result.assets && result.assets.length > 0) {
const image = result.assets[0];
setPicture(image.uri);
setFormData({...formData, image: image.base64});
}
}
async function getCurrentLocation() {
let {status} = await Location.requestForegroundPermissionsAsync();
if (status !== 'granted') {
console.log('Permission to access location was denied');
Alert.alert("Błąd", "Nie udało się uzyskać dostępu do pobierania lokalizacji");
return;
}
try {
let location = await Location.getCurrentPositionAsync({});
setLocation(location);
setFormData({
...formData,
latitude: location.coords.latitude,
longitude: location.coords.longitude
});
Alert.alert("Sukces!", "Pobrano lokalizację\n" +
"Szerokość geograficzna: " + location.coords.latitude + "\n" +
"Długość geograficzna: " + location.coords.longitude);
} catch (error) {
Alert.alert("Błąd", "Nie udało się pobrać lokalizacji");
console.error(error);
}
}
const selectImageMethod = (method) => {
setImageMethod(method);
if (method === 'camera') {
takePicture()
} else {
setFormData({...formData, image: ""});
setPicture(null);
}
}
return (
<KeyboardAvoidingView
style={{flex: 1}}
behavior={Platform.OS === "ios" ? "padding" : "height"}
>
<ScrollView contentContainerStyle={styles.container} contentInsetAdjustmentBehavior="automatic"
style={[{backgroundColor: theme.colors.background}]}
>
<Snackbar
visible={visible}
onDismiss={() => setVisible(false)}
duration={3000}
>
{message}
</Snackbar>
<Text style={styles.label} variant="labelLarge">Jak chcesz dodać zdjęcie?</Text>
<View style={styles.imageMethodButtons}>
<Button
mode={imageMethod === 'link' ? "contained" : "outlined"}
onPress={() => selectImageMethod('link')}
style={styles.methodButton}
>
Użyj linku
</Button>
<Button
mode={imageMethod === 'camera' ? "contained" : "outlined"}
onPress={() => selectImageMethod('camera')}
style={styles.methodButton}
>
Zrób zdjęcie
</Button>
</View>
{imageMethod === 'link' && (
<TextInput
mode="outlined"
label="Link do zdjęcia"
placeholder="Wpisz link do zdjęcia"
multiline={true}
style={{margin: 10, width: "100%"}}
value={formData.image}
onChangeText={(e) => setFormData({...formData, image: e})}
/>
)}
{picture && (
<View style={styles.imageContainer}>
<Image
source={{uri: picture}}
style={styles.image}
/>
</View>
)}
<TextInput
mode="outlined"
label="Nazwa"
placeholder="Wpisz nazwę"
style={{margin: 10, width: "100%"}}
value={formData.name}
onChangeText={(e) => setFormData({...formData, name: e})}
/>
<TextInput
mode="outlined"
label="Opis"
placeholder="Wpisz opis"
style={{margin: 10, width: "100%"}}
multiline={true}
value={formData.description}
onChangeText={(e) => setFormData({...formData, description: e})}
/>
<TextInput
mode="outlined"
label="Powierzchnia"
placeholder="Wpisz powierzchnię"
style={{margin: 10, width: "100%"}}
value={formData.area}
keyboardType="numbers-and-punctuation"
onChangeText={(e) => setFormData({...formData, area: e})}
/>
<TextInput
mode="outlined"
label="Ludność"
placeholder="Wpisz liczbę ludności"
style={{margin: 10, width: "100%"}}
value={formData.population}
keyboardType="numeric"
onChangeText={(e) => setFormData({...formData, population: e})}
/>
<Button
style={{margin: 10, width: "100%"}}
icon="plus-circle-outline"
mode={"contained"}
onPress={handleAddLocation}
>
Dodaj
</Button>
<Button
style={{margin: 10, width: "100%"}} mode={"contained"}
onPress={getCurrentLocation}
icon={location ? "check-circle-outline" : "map-marker-outline"}
>
Pobierz aktualną lokalizację
</Button>
</ScrollView>
</KeyboardAvoidingView>
);
}
const styles = StyleSheet.create({
container: {
// backgroundColor: "#25292e",
alignItems: "center",
padding: 10,
flexGrow: 1,
},
imageContainer: {
width: "100%",
alignItems: "flex-start",
marginVertical: 10,
},
image: {
width: 150,
height: 150,
borderStyle: "solid",
borderWidth: 1,
borderRadius: 5,
},
imageMethodButtons: {
flexDirection: 'row',
justifyContent: 'space-between',
width: '100%',
marginVertical: 10,
},
methodButton: {
flex: 1,
marginHorizontal: 5,
},
label: {
marginTop: 10,
marginBottom: 10,
verticalAlign: "middle",
textAlign: "center",
}
});

View File

@@ -1,21 +0,0 @@
import { Text, View, StyleSheet } from 'react-native';
export default function FormScreen() {
return (
<View style={styles.container}>
<Text style={styles.text}>Form</Text>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#25292e',
justifyContent: 'center',
alignItems: 'center',
},
text: {
color: '#fff',
},
});

60
app/(tabs)/index.jsx Normal file
View File

@@ -0,0 +1,60 @@
import {
View,
StyleSheet,
FlatList,
} from "react-native";
import {useTheme, Card, Text, Button, ActivityIndicator} from "react-native-paper";
import {Link} from "expo-router";
import useLocationStore from "@/locationStore";
export default function Index() {
const theme = useTheme();
const {locations, loading} = useLocationStore();
if (loading) {
return (
<View style={[styles.container, {backgroundColor: theme.colors.background}]}>
<ActivityIndicator size="large" color={theme.colors.primary}/>
</View>
);
}
return (
<View
style={[styles.container, {backgroundColor: theme.colors.background}]}
>
<FlatList
data={locations}
keyExtractor={(item) => item.id.toString()}
renderItem={({item}) => (
<Card style={{margin: 10}}>
<Card.Cover
style={{marginBottom: 10}}
source={item.imageSource}
/>
<Card.Content style={{marginBottom: 10}}>
<Text variant="titleLarge">{item.name}</Text>
<Text variant="bodyMedium">
{item.description && item.description.split(".")[0]}...
</Text>
</Card.Content>
<Card.Actions>
<Link href={`/location/${item.id}`} asChild>
<Button>Zobacz więcej</Button>
</Link>
</Card.Actions>
</Card>
)}
/>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: "#25292e",
justifyContent: "center",
alignItems: "center",
},
});

View File

@@ -1,43 +0,0 @@
import { View, StyleSheet, FlatList } from 'react-native';
import { useTheme, Card, Text, Button} from 'react-native-paper';
import { Link } from 'expo-router';
import { Locations } from '@/constants/Locations';
export default function Index() {
const theme = useTheme();
return (
<View style={[styles.container, {backgroundColor: theme.colors.background}]}>
<FlatList
data={Locations}
keyExtractor={(item) => item.name}
renderItem={({ item }) => (
<Card style={{ margin: 10}}>
<Card.Cover style={{ marginBottom:10 }} source={{ uri: item.image }} />
<Card.Content style={{ marginBottom:10 }}>
<Text variant="titleLarge">{item.name}</Text>
<Text variant="bodyMedium">{item.description}</Text>
</Card.Content>
<Card.Actions>
<Link href={`/location/${item.id}`} asChild>
<Button>Zobacz więcej</Button>
</Link>
</Card.Actions>
</Card>
)}>
</FlatList>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#25292e',
justifyContent: 'center',
alignItems: 'center',
},
text: {
color: '#fff',
},
});

77
app/_layout.jsx Normal file
View File

@@ -0,0 +1,77 @@
import { Link, Stack, useRouter } from "expo-router";
import { useColorScheme } from "react-native";
import { PaperProvider } from "react-native-paper";
import { MD3LightTheme, MD3DarkTheme } from "react-native-paper";
import { useEffect } from "react";
import Ionicons from "@expo/vector-icons/Ionicons";
import useLocationStore from "@/locationStore";
export default function RootLayout() {
const colorScheme = useColorScheme();
const theme = colorScheme === "dark" ? MD3DarkTheme : MD3LightTheme;
const router = useRouter();
const fetchLocations = useLocationStore((state) => state.fetchLocations);
useEffect(() => {
fetchLocations();
}, []);
const deleteLocation = useLocationStore((state) => state.deleteLocation);
const handleDelete = async (id) => {
const isDeleted = await deleteLocation(id);
if (isDeleted) {
router.replace("/");
}
};
return (
<PaperProvider theme={theme}>
<Stack
screenOptions={{
headerStyle: {
backgroundColor: theme.colors.primaryContainer,
borderBottomWidth: 0,
},
headerTintColor: theme.colors.primary,
}}
>
<Stack.Screen name="(tabs)" options={{ headerShown: false }} />
<Stack.Screen
name="location/[id]"
options={({ route }) => ({
title: "Lokalizacja",
headerBackTitle: "Powrót",
headerRight: () => (
<Link
href={`location/edit/${route.params.id}`}
asChild
style={{ marginRight: 11 }}
>
<Ionicons
name="pencil"
color={theme.colors.primary}
size={24}
/>
</Link>
),
})}
/>
<Stack.Screen
name="location/edit/[id]"
options={({ route }) => ({
title: "Edycja",
headerRight: () => (
<Ionicons
name="trash-bin"
color={theme.colors.primary}
size={24}
onPress={() => handleDelete(route.params.id)}
/>
),
})}
/>
</Stack>
</PaperProvider>
);
}

View File

@@ -1,30 +0,0 @@
import { Stack } from 'expo-router';
import { useColorScheme } from 'react-native';
import { PaperProvider} from 'react-native-paper';
import { MD3LightTheme, MD3DarkTheme } from 'react-native-paper';
const colorScheme = useColorScheme();
const theme = colorScheme === 'dark' ? MD3DarkTheme :
MD3LightTheme;
export default function RootLayout() {
return (
<PaperProvider theme={theme} >
<Stack screenOptions={{
headerStyle: {
backgroundColor: theme.colors.primaryContainer,
borderBottomWidth:0
},
headerTintColor: theme.colors.primary,
}}>
<Stack.Screen name="(tabs)" options={{ headerShown: false }} />
<Stack.Screen
name="location/[id]"
options={{
title: "Lokalizacja",
headerBackTitle: "Powrót",
}}/>
</Stack>
</PaperProvider>
);
}

View File

@@ -1,66 +0,0 @@
import { View, ScrollView, StyleSheet } from 'react-native';
import { Text, Card } from 'react-native-paper';
import { useLocalSearchParams } from 'expo-router';
import { Locations } from '@/constants/Locations';
export default function Location() {
const { id } = useLocalSearchParams();
const location = Locations.find(loc => loc.id == id);
if (!location) {
return (
<View style={styles.container}>
<Text style={styles.text}>Brak lokalizaji - {id}</Text>
</View>
);
}
return (
<View style={styles.container}>
<ScrollView>
<Card style={{ margin: 10}}>
<Card.Cover style={{marginBottom: 10}} source={{ uri: location.image }} />
<Card.Content style={{marginBottom: 10}}>
<Text variant="headlineLarge" style={{marginBottom: 10}}>
{location.name}
</Text>
<Text variant="headlineLarge" style={{marginBottom: 10}}>
Opis:
</Text>
<Text variant="bodyMedium">
{location.longDescription}
</Text>
</Card.Content>
<Card.Content>
<Text variant="headlineLarge" style={{marginBottom: 10}}>
Statystyki:
</Text>
<Text variant="bodyMedium" style={{marginBottom: 10}}>
Powierzchnia: {location.area} km²
</Text>
<Text variant="bodyMedium" style={{marginBottom: 10}}>
Ludność: {location.population} osób
</Text>
<Text variant="bodyMedium" style={{marginBottom: 10}}>
Gęstość zaludnienia: {location.density} osób/km²
</Text>
<Text variant="bodyMedium" style={{marginBottom: 10}}>
Wysokość nad poziomem morza: {location.elevation} m n.p.m.
</Text>
</Card.Content>
</Card>
</ScrollView></View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#25292e',
justifyContent: 'center',
alignItems: 'center',
},
text: {
color: '#fff',
},
});

86
app/location/[id].jsx Normal file
View File

@@ -0,0 +1,86 @@
import { View, ScrollView, StyleSheet } from "react-native";
import { useEffect } from "react";
import {useTheme, Text, Card, ActivityIndicator} from "react-native-paper";
import { useLocalSearchParams } from "expo-router";
import useLocationStore from "@/locationStore";
export default function Location() {
const theme = useTheme();
const { id } = useLocalSearchParams();
const getLocation = useLocationStore((state) => state.getLocation);
const fetchLocations = useLocationStore((state) => state.fetchLocations);
const loading = useLocationStore((state) => state.loading);
const location = getLocation(id);
useEffect(() => {
if (!location && !loading) {
fetchLocations();
}
}, [location, loading, fetchLocations]);
if (loading) {
return (
<View style={[styles.container, {backgroundColor: theme.colors.background}]}>
<ActivityIndicator size="large" color={theme.colors.primary}/>
</View>
);
}
if (!location) {
return (
<View style={styles.container}>
<Text style={styles.text}>Brak lokalizacji - {id}</Text>
</View>
);
}
return (
<View style={[styles.container, {backgroundColor: theme.colors.background}]}>
<ScrollView>
<Card style={{ margin: 10 }}>
<Card.Cover
style={{ marginBottom: 10 }}
source={location.imageSource}
/>
<Card.Content style={{ marginBottom: 10 }}>
<Text variant="headlineLarge" style={{ marginBottom: 10 }}>
{location.name}
</Text>
<Text variant="headlineLarge" style={{ marginBottom: 10 }}>
Opis:
</Text>
<Text variant="bodyMedium">{location.description}</Text>
</Card.Content>
<Card.Content>
<Text variant="headlineLarge" style={{ marginBottom: 10 }}>
Statystyki:
</Text>
<Text variant="bodyMedium" style={{ marginBottom: 10 }}>
Powierzchnia: {location.area} km²
</Text>
<Text variant="bodyMedium" style={{ marginBottom: 10 }}>
Ludność: {location.population} osób
</Text>
<Text variant="bodyMedium" style={{ marginBottom: 10 }}>
Współrzedne geograficzne: {location.latitude}, {location.longitude}
</Text>
</Card.Content>
</Card>
</ScrollView>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: "#25292e",
justifyContent: "center",
},
text: {
color: "#fff",
},
});

243
app/location/edit/[id].jsx Normal file
View File

@@ -0,0 +1,243 @@
import {useState, useEffect} from "react";
import {
StyleSheet,
Platform,
ScrollView,
KeyboardAvoidingView,
View,
ActivityIndicator,
Image,
} from "react-native";
import {TextInput, Button, Snackbar, useTheme, Text} from "react-native-paper";
import {useLocalSearchParams, useRouter} from "expo-router";
import useLocationStore from "@/locationStore";
import {normalizeImageSource} from "@/api/locations";
export default function EditLocation() {
const theme = useTheme();
const {id} = useLocalSearchParams();
const router = useRouter();
const [loading, setLoading] = useState(true);
const [formData, setFormData] = useState({
name: "",
description: "",
image: "",
area: "",
population: "",
longitude: "",
latitude: "",
});
const [message, setMessage] = useState("");
const [visible, setVisible] = useState(false);
const updateLocation = useLocationStore((state) => state.updateLocation);
const getLocation = useLocationStore((state) => state.getLocation);
useEffect(() => {
const data = getLocation(id);
if (data) {
setFormData({
name: data.name,
description: data.description,
image: data.image,
area: data.area.toString(),
population: data.population.toString(),
longitude: data.longitude.toString(),
latitude: data.latitude.toString(),
});
}
setLoading(false);
}, [id, getLocation]);
const handleEditLocation = async () => {
if (
formData.name &&
formData.description &&
formData.image &&
formData.area &&
formData.population &&
formData.longitude &&
formData.latitude
) {
// console.log("Form data:", formData);
const response = await updateLocation(id, {
name: formData.name,
description: formData.description,
image: formData.image,
area: parseFloat(formData.area),
population: parseInt(formData.population, 10),
longitude: parseFloat(formData.longitude),
latitude: parseFloat(formData.latitude),
});
if (response) {
setMessage("Lokalizacja została zaktualizowana!");
setVisible(true);
// while (router.canGoBack()) {
// Pop from stack until one element is left
// router.back();
// }
setTimeout(() => {
router.replace("/");
}, 300); // Replace the last remaining stack element
} else {
setMessage("Wystąpił błąd podczas aktualizacji lokalizacji!");
setVisible(true);
}
} else {
setMessage("Wypełnij wszystkie pola!");
setVisible(true);
}
};
const isBase64Image = (str) => {
let image = normalizeImageSource(str);
return image && (
image.uri.startsWith('data:image/jpeg;base64,') ||
image.uri.startsWith('data:image/png;base64,') ||
image.uri.startsWith('data:image/gif;base64,') ||
image.uri.startsWith('data:image/webp;base64,')
);
};
if (loading) {
return (
<View
style={[styles.container, {backgroundColor: theme.colors.background}]}
>
<ActivityIndicator size="large" color={theme.colors.primary}/>
</View>
);
}
if (!formData) {
return (
<View style={styles.container}>
<Text style={styles.text}>Brak lokalizacji - {id}</Text>
</View>
);
}
return (
<KeyboardAvoidingView
style={{flex: 1}}
behavior={Platform.OS === "ios" ? "padding" : "height"}
>
<ScrollView contentContainerStyle={styles.container}>
<Snackbar
visible={visible}
onDismiss={() => setVisible(false)}
duration={3000}
>
{message}
</Snackbar>
{isBase64Image(formData.image) ? (
<View style={styles.imageContainer}>
<Text style={{color: theme.colors.onBackground, marginBottom: 5}}>
Aktualne zdjęcie:
</Text>
<Image
source={normalizeImageSource(formData.image)}
style={styles.imagePreview}
resizeMode="contain"
/>
</View>
) : (
<TextInput
mode="outlined"
label="Link do zdjęcia"
multiline={true}
placeholder="Wpisz link do zdjęcia"
style={{margin: 10, width: "100%"}}
value={formData.image}
onChangeText={(e) => setFormData({...formData, image: e})}
/>
)}
<TextInput
mode="outlined"
label="Nazwa"
placeholder="Wpisz nazwę"
style={{margin: 10, width: "100%"}}
value={formData.name}
onChangeText={(e) => setFormData({...formData, name: e})}
/>
<TextInput
mode="outlined"
label="Opis"
placeholder="Wpisz opis"
style={{margin: 10, width: "100%"}}
multiline={true}
value={formData.description}
onChangeText={(e) => setFormData({...formData, description: e})}
/>
<TextInput
mode="outlined"
label="Powierzchnia"
placeholder="Wpisz powierzchnię"
style={{margin: 10, width: "100%"}}
value={formData.area}
keyboardType="numbers-and-punctuation"
onChangeText={(e) => setFormData({...formData, area: e})}
/>
<TextInput
mode="outlined"
label="Ludność"
placeholder="Wpisz liczbę ludności"
style={{margin: 10, width: "100%"}}
value={formData.population}
keyboardType="numeric"
onChangeText={(e) => setFormData({...formData, population: e})}
/>
<TextInput
mode="outlined"
label="Długość geograficzna"
placeholder="Wpisz długość geograficzną"
style={{margin: 10, width: "100%"}}
value={formData.longitude}
keyboardType="numbers-and-punctuation"
onChangeText={(e) => setFormData({...formData, longitude: e})}
/>
<TextInput
mode="outlined"
label="Szerokość geograficzna"
placeholder="Wpisz szerokość geograficzną"
style={{margin: 10, width: "100%"}}
value={formData.latitude}
keyboardType="numbers-and-punctuation"
onChangeText={(e) => setFormData({...formData, latitude: e})}
/>
<Button
style={{margin: 10, width: "100%"}}
icon="plus-circle-outline"
mode={"contained"}
onPress={handleEditLocation}
>
Edytuj
</Button>
</ScrollView>
</KeyboardAvoidingView>
);
}
const styles = StyleSheet.create({
container: {
backgroundColor: "#25292e",
justifyContent: "flex-start",
alignItems: "center",
flexGrow: 1,
},
imageContainer: {
margin: 10,
width: "100%",
alignItems: "center",
padding: 10,
},
imagePreview: {
width: "100%",
height: 200,
borderRadius: 5,
}
});

8
babel.config.js Normal file
View File

@@ -0,0 +1,8 @@
module.exports = {
presets: [
'@babel/preset-env',
'babel-preset-expo',
['@babel/preset-react', {runtime: 'automatic'}],
['@babel/preset-flow', {all: true}],
]
};

View File

@@ -1,58 +0,0 @@
export const Locations = [
{
"id": 1,
"name": "Warszawa",
"description": "Stolica Polski, położona w centralnej części kraju.",
"longDescription": "Warszawa jest największym miastem w Polsce, znanym z bogatej historii, kultury i architektury. Warto zobaczyć Zamek Królewski, Stare Miasto oraz Muzeum Powstania Warszawskiego. Miasto oferuje wiele atrakcji turystycznych, w tym parki, muzea i teatry. Warszawa jest również ważnym ośrodkiem gospodarczym i kulturalnym.",
"image": "https://www.niesamowitapolska.eu/images/mazowieckie/warszawa/38607734_m.jpg",
"area": 517.24,
"population": 1790658,
"density": 3460,
"elevation": 100
},
{
"id": 2,
"name": "Kielce",
"description": "Stolica województwa świętokrzyskiego, położona w centralnej Polsce.",
"longDescription": "Kielce to miasto w centralnej Polsce, znane z pięknych krajobrazów i bogatej historii. Warto odwiedzić Kielecki Park Etnograficzny, Muzeum Zabawek oraz Katedrę Wniebowzięcia Najświętszej Maryi Panny. Kielce są również znane z licznych festiwali i wydarzeń kulturalnych. Miasto oferuje wiele atrakcji turystycznych, w tym parki, muzea i teatry.",
"image": "https://as2.ftcdn.net/jpg/05/42/90/67/1000_F_542906717_cf5i6HeCJsPluuH5tqq5MbsSdfpopmtT.webp",
"area": 109.4,
"population": 196000,
"density": 1790,
"elevation": 350
},
{
"id": 3,
"name": "Kraków",
"description": "Miasto położone w południowej Polsce, znane z bogatej historii i kultury.",
"longDescription": "Kraków to jedno z najstarszych i najpiękniejszych miast w Polsce, znane z bogatej historii, kultury i architektury. Warto zobaczyć Wawel, Stare Miasto oraz Sukiennice. Kraków jest również znany z licznych festiwali, wydarzeń kulturalnych oraz tradycji kulinarnych. Miasto oferuje wiele atrakcji turystycznych, w tym parki, muzea i teatry.",
"image": "https://nawakacje.eu/wp-content/uploads/2020/12/krakow-atrakcje.jpg",
"area": 326.85,
"population": 779115,
"density": 2380,
"elevation": 219
},
{
"id": 4,
"name": "Wrocław",
"description": "Miasto w zachodniej Polsce, znane z pięknych mostów i architektury.",
"longDescription": "Wrocław to miasto w zachodniej Polsce, znane z pięknych mostów, architektury i bogatej historii. Warto odwiedzić Ostrów Tumski, Rynek oraz Halę Stulecia. Wrocław jest również znany z licznych festiwali, wydarzeń kulturalnych oraz tradycji kulinarnych. Miasto oferuje wiele atrakcji turystycznych, w tym parki, muzea i teatry.",
"image": "https://backend.triverna.pl/blog/wp-content/uploads/2023/10/Wroclaw-z-lotu-ptaka-1.jpeg",
"area": 292.82,
"population": 640000,
"density": 2180,
"elevation": 120
},
{
"id": 5,
"name": "Poznań",
"description": "Miasto w zachodniej Polsce, znane z tradycji piwowarskich.",
"longDescription": "Poznań to miasto w zachodniej Polsce, znane z tradycji piwowarskich, bogatej historii i kultury. Warto odwiedzić Stary Rynek, Katedrę Poznańską oraz Ostrów Tumski. Poznań jest również znany z licznych festiwali, wydarzeń kulturalnych oraz tradycji kulinarnych. Miasto oferuje wiele atrakcji turystycznych, w tym parki, muzea i teatry.",
"image":"https://wycieczkoteka.pl/images/2024/11/07/poznan-co-zwiedzic.jpg",
"area": 262.82,
"population": 535802,
"density": 2030,
"elevation": 60
}
]

88
locationStore.js Normal file
View File

@@ -0,0 +1,88 @@
import {create} from "zustand";
import * as api from "@/api/locations";
const useLocationStore = create((set, get) => ({
locations: [],
loading: false,
error: null,
fetchLocations: async () => {
set({loading: true, error: null});
try {
const data = await api.listLocations();
set({locations: data, loading: false});
} catch (error) {
set({error, loading: false});
}
},
addLocation: async (location) => {
try {
const newLoc = await api.addLocation(location);
const normalizedLoc = {
...newLoc,
imageSource: api.normalizeImageSource(newLoc.image)
};
set((state) => ({
locations: [...state.locations, normalizedLoc],
}));
return normalizedLoc;
} catch (error) {
set({error, loading: false});
return null;
}
},
updateLocation: async (id, location) => {
try {
const updated = await api.updateLocation(id, location);
const normalizedLoc = {
...updated,
imageSource: api.normalizeImageSource(updated.image)
};
const stringId = String(id);
set((state) => ({
locations: state.locations.map((loc) =>
String(loc.id) === stringId ? normalizedLoc : loc
),
}));
return normalizedLoc;
} catch (error) {
set({error, loading: false});
return null;
}
},
deleteLocation: async (id) => {
try {
const deleted = await api.deleteLocation(id);
const stringId = String(id);
set((state) => ({
locations: state.locations.filter((loc) => String(loc.id) !== stringId),
loading: false,
}));
return deleted;
} catch (error) {
set({error, loading: false});
return false;
}
},
getLocation: (id) => {
const location = get().locations.find((loc) => String(loc.id) === String(id));
if (location && !location.imageSource) {
return {
...location,
imageSource: api.normalizeImageSource(location.image)
};
}
return location;
},
}));
export default useLocationStore;

11778
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -6,26 +6,37 @@
"start": "expo start",
"android": "expo start --android",
"ios": "expo start --ios",
"web": "expo start --web"
"web": "expo start --web",
"test": "jest"
},
"dependencies": {
"@expo/metro-runtime": "~4.0.1",
"expo": "~52.0.43",
"expo-constants": "~17.0.8",
"expo-linking": "~7.0.5",
"expo-router": "~4.0.20",
"expo-status-bar": "~2.0.1",
"react": "18.3.1",
"react-dom": "18.3.1",
"react-native": "0.76.9",
"@expo/metro-runtime": "~5.0.4",
"axios": "^1.9.0",
"expo": "^53.0.0",
"expo-constants": "~17.1.6",
"expo-linking": "~7.1.4",
"expo-router": "~5.1.0",
"expo-status-bar": "~2.2.3",
"expo-image-picker": "~16.1.4",
"expo-location": "~18.1.5",
"react": "19.0.0",
"react-dom": "19.0.0",
"react-native": "0.79.3",
"react-native-paper": "^5.13.1",
"react-native-safe-area-context": "4.12.0",
"react-native-screens": "~4.4.0",
"react-native-web": "~0.19.13"
"react-native-safe-area-context": "5.4.0",
"react-native-screens": "~4.11.1",
"react-native-web": "^0.20.0",
"zustand": "^5.0.3"
},
"devDependencies": {
"@babel/core": "^7.20.0",
"@types/react": "~18.3.12",
"@babel/core": "^7.27.1",
"@babel/preset-env": "^7.27.2",
"@babel/preset-flow": "^7.27.1",
"@babel/preset-react": "^7.27.1",
"@babel/preset-typescript": "^7.27.1",
"@types/react": "~19.0.10",
"babel-jest": "^29.7.0",
"jest": "^29.7.0",
"typescript": "^5.3.3"
},
"private": true