commit 0e7f7e1dfd2c3ffb36ca73a55469f4faba6b9c74 Author: Dennis Hundertmark Date: Tue Feb 25 11:38:57 2025 +0100 Initial commit Generated by create-expo-app 3.2.0. diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c9d575d --- /dev/null +++ b/.gitignore @@ -0,0 +1,38 @@ +# Learn more https://docs.github.com/en/get-started/getting-started-with-git/ignoring-files + +# dependencies +node_modules/ + +# Expo +.expo/ +dist/ +web-build/ +expo-env.d.ts + +# Native +*.orig.* +*.jks +*.p8 +*.p12 +*.key +*.mobileprovision + +# Metro +.metro-health-check* + +# debug +npm-debug.* +yarn-debug.* +yarn-error.* + +# macOS +.DS_Store +*.pem + +# local env files +.env*.local + +# typescript +*.tsbuildinfo + +app-example diff --git a/README.md b/README.md new file mode 100644 index 0000000..cd4feb8 --- /dev/null +++ b/README.md @@ -0,0 +1,50 @@ +# Welcome to your Expo app 👋 + +This is an [Expo](https://expo.dev) project created with [`create-expo-app`](https://www.npmjs.com/package/create-expo-app). + +## Get started + +1. Install dependencies + + ```bash + npm install + ``` + +2. Start the app + + ```bash + npx expo start + ``` + +In the output, you'll find options to open the app in a + +- [development build](https://docs.expo.dev/develop/development-builds/introduction/) +- [Android emulator](https://docs.expo.dev/workflow/android-studio-emulator/) +- [iOS simulator](https://docs.expo.dev/workflow/ios-simulator/) +- [Expo Go](https://expo.dev/go), a limited sandbox for trying out app development with Expo + +You can start developing by editing the files inside the **app** directory. This project uses [file-based routing](https://docs.expo.dev/router/introduction). + +## Get a fresh project + +When you're ready, run: + +```bash +npm run reset-project +``` + +This command will move the starter code to the **app-example** directory and create a blank **app** directory where you can start developing. + +## Learn more + +To learn more about developing your project with Expo, look at the following resources: + +- [Expo documentation](https://docs.expo.dev/): Learn fundamentals, or go into advanced topics with our [guides](https://docs.expo.dev/guides). +- [Learn Expo tutorial](https://docs.expo.dev/tutorial/introduction/): Follow a step-by-step tutorial where you'll create a project that runs on Android, iOS, and the web. + +## Join the community + +Join our community of developers creating universal apps. + +- [Expo on GitHub](https://github.com/expo/expo): View our open source platform and contribute. +- [Discord community](https://chat.expo.dev): Chat with Expo users and ask questions. diff --git a/app.json b/app.json new file mode 100644 index 0000000..268d393 --- /dev/null +++ b/app.json @@ -0,0 +1,43 @@ +{ + "expo": { + "name": "my-app", + "slug": "my-app", + "version": "1.0.0", + "jsEngine": "hermes", + "orientation": "portrait", + "icon": "./assets/images/icon.png", + "scheme": "myapp", + "userInterfaceStyle": "automatic", + "newArchEnabled": true, + "ios": { + "supportsTablet": true, + "jsEngine": "jsc" + }, + "android": { + "adaptiveIcon": { + "foregroundImage": "./assets/images/adaptive-icon.png", + "backgroundColor": "#ffffff" + } + }, + "web": { + "bundler": "metro", + "output": "static", + "favicon": "./assets/images/favicon.png" + }, + "plugins": [ + "expo-router", + [ + "expo-splash-screen", + { + "image": "./assets/images/splash-icon.png", + "imageWidth": 200, + "resizeMode": "contain", + "backgroundColor": "#ffffff" + } + ] + ], + "experiments": { + "typedRoutes": true + } + } +} diff --git a/app/_layout.tsx b/app/_layout.tsx new file mode 100644 index 0000000..2d5cd5b --- /dev/null +++ b/app/_layout.tsx @@ -0,0 +1,66 @@ +import { COLORS } from "@/constants/colors"; +import { Tabs } from "expo-router"; +import { Ionicons } from "@expo/vector-icons"; +import React from "react"; + +export default function RootLayout() { + return ( + + + ( + + ), + }} + > + ( + + ), + }} + > + ( + + ), + }} + > + + ); +} diff --git a/app/fav.tsx b/app/fav.tsx new file mode 100644 index 0000000..a0bfdfc --- /dev/null +++ b/app/fav.tsx @@ -0,0 +1,111 @@ +import { + FlatList, + RefreshControl, + StyleSheet, + Text, + TouchableOpacity, + View, +} from "react-native"; +import React, { useEffect } from "react"; +import { Film } from "@/types/film"; +import AsyncStorage from "@react-native-async-storage/async-storage"; +import { FAVORITES_KEY } from "@/constants/keys"; +import { COLORS } from "@/constants/colors"; +import FilmItem from "@/components/FilmItem"; +import ListEmptyComponent from "@/components/ListEmptyComponent"; +import { Ionicons } from "@expo/vector-icons"; +import { useFocusEffect } from "expo-router"; + +const Page = () => { + const [favorites, setFavorites] = React.useState([]); + const [refreshing, setRefreshing] = React.useState(false); + + const fetchFavorites = async () => { + try { + const favorites = await AsyncStorage.getItem(FAVORITES_KEY); + if (favorites) { + setFavorites(JSON.parse(favorites) as Film[]); + } + } catch (error) { + } finally { + setRefreshing(false); + } + }; + + const onRefresh = () => { + setRefreshing(true); + fetchFavorites(); + }; + + const removeFavorite = async (film: Film) => { + const updatedFavorites = favorites.filter( + (f) => f.episode_id !== film.episode_id, + ); + try { + setFavorites(updatedFavorites); + await AsyncStorage.setItem( + FAVORITES_KEY, + JSON.stringify(updatedFavorites), + ); + } catch (error) { + console.error(error); + } + }; + + const renderItem = ({ item }: { item: Film }) => ( + + {item.title} + removeFavorite(item)}> + + + + ); + + useFocusEffect( + React.useCallback(() => { + fetchFavorites(); + }, []), + ); + + return ( + + item.episode_id.toString()} + refreshing={refreshing} + refreshControl={ + + } + ListEmptyComponent={() => } + /> + + ); +}; + +export default Page; + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: COLORS.containerBackground, + }, + itemContainer: { + flexDirection: "row", + justifyContent: "space-between", + alignItems: "center", + backgroundColor: COLORS.itemBackground, + padding: 16, + marginVertical: 8, + marginHorizontal: 16, + borderRadius: 8, + }, + itemText: { + fontSize: 16, + color: COLORS.text, + }, +}); diff --git a/app/films/[id].tsx b/app/films/[id].tsx new file mode 100644 index 0000000..044e3e5 --- /dev/null +++ b/app/films/[id].tsx @@ -0,0 +1,133 @@ +import { COLORS } from "@/constants/colors"; +import { FAVORITES_KEY } from "@/constants/keys"; +import { Film } from "@/types/film"; +import { Ionicons } from "@expo/vector-icons"; +import AsyncStorage from "@react-native-async-storage/async-storage"; +import { Stack, useLocalSearchParams } from "expo-router"; +import React, { useEffect, useState } from "react"; +import { + ScrollView, + StyleSheet, + Text, + TouchableOpacity, + View, +} from "react-native"; + +const Page = () => { + const { id } = useLocalSearchParams(); + const [film, setFilm] = useState(null); + const [loading, setLoading] = useState(true); + const [isFavorite, setIsFavorite] = useState(false); + + useEffect(() => { + const fetchFilm = async () => { + try { + setLoading(true); + const response = await fetch(`https://swapi.dev/api/films/${id}`); + const data = await response.json(); + setFilm(data); + checkFavoriteStatus(data); + } catch (error) { + console.error(error); + } finally { + setLoading(false); + } + }; + + fetchFilm(); + }, [id]); + + const checkFavoriteStatus = async (film: Film) => { + try { + const favorites = await AsyncStorage.getItem(FAVORITES_KEY); + if (favorites) { + const favoriteFilms = JSON.parse(favorites) as Film[]; + setIsFavorite( + favoriteFilms.some((f) => f.episode_id === film.episode_id), + ); + } + } catch (error) {} + }; + + const toggleFavorite = async () => { + try { + const favorites = await AsyncStorage.getItem(FAVORITES_KEY); + let favoriteFilms = favorites ? JSON.parse(favorites) : []; + if (isFavorite) { + favoriteFilms = favoriteFilms.filter( + (f: Film) => f.episode_id !== film?.episode_id, + ); + } else { + favoriteFilms.push(film); + } + await AsyncStorage.setItem(FAVORITES_KEY, JSON.stringify(favoriteFilms)); + setIsFavorite(!isFavorite); + } catch (error) {} + }; + + if (loading) { + return ( + + Loading... + + ); + } + + if (!film) { + return ( + + Film not found + + ); + } + + return ( + + ( + + + + ), + }} + /> + Title: {film.title} + Director: {film.director} + Producer: {film.producer} + Release Date: {film.release_date} + {film.opening_crawl} + + ); +}; + +export default Page; + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: COLORS.containerBackground, + padding: 16, + }, + title: { + fontSize: 24, + fontWeight: "bold", + color: COLORS.text, + marginBottom: 16, + }, + details: { + fontSize: 16, + color: COLORS.text, + marginBottom: 8, + }, + crawl: { + fontSize: 16, + color: COLORS.text, + fontStyle: "italic", + marginTop: 16, + }, +}); diff --git a/app/films/_layout.tsx b/app/films/_layout.tsx new file mode 100644 index 0000000..175b960 --- /dev/null +++ b/app/films/_layout.tsx @@ -0,0 +1,36 @@ +import { StyleSheet, Text, View } from "react-native"; +import React from "react"; +import { Stack } from "expo-router"; +import { COLORS } from "@/constants/colors"; + +const Layout = () => { + return ( + + + + + ); +}; + +export default Layout; + +const styles = StyleSheet.create({}); diff --git a/app/films/index.tsx b/app/films/index.tsx new file mode 100644 index 0000000..204122c --- /dev/null +++ b/app/films/index.tsx @@ -0,0 +1,68 @@ +import FilmItem from "@/components/FilmItem"; +import ListEmptyComponent from "@/components/ListEmptyComponent"; +import { COLORS } from "@/constants/colors"; +import { Film } from "@/types/film"; +import React, { useEffect } from "react"; +import { FlatList, RefreshControl, StyleSheet, View } from "react-native"; + +const Page = () => { + const [films, setFilms] = React.useState([]); + const [refreshing, setRefreshing] = React.useState(false); + const [loading, setLoading] = React.useState(false); + + const fetchFilms = async () => { + try { + setLoading(true); + const response = await fetch("https://swapi.dev/api/films/"); + const data = await response.json(); + setFilms(data.results); + } catch (error) { + console.error(error); + } finally { + setLoading(false); + setRefreshing(false); + } + }; + + useEffect(() => { + fetchFilms(); + }, []); + + const onRefresh = () => { + setRefreshing(true); + fetchFilms(); + }; + + return ( + + item.episode_id.toString()} + renderItem={({ item }) => } + refreshControl={ + + } + ListEmptyComponent={ + + } + /> + + ); +}; + +export default Page; + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: COLORS.containerBackground, + }, + item: { + padding: 16, + borderBottomWidth: 1, + }, +}); diff --git a/app/index.tsx b/app/index.tsx new file mode 100644 index 0000000..9fffc8b --- /dev/null +++ b/app/index.tsx @@ -0,0 +1,6 @@ +import { Redirect } from "expo-router"; +import { Text, View } from "react-native"; + +export default function Index() { + return ; +} diff --git a/app/people/[id].tsx b/app/people/[id].tsx new file mode 100644 index 0000000..a9c862f --- /dev/null +++ b/app/people/[id].tsx @@ -0,0 +1,89 @@ +import { COLORS } from "@/constants/colors"; +import { Person } from "@/types/person"; +import { useLocalSearchParams } from "expo-router"; +import React, { useEffect, useState } from "react"; +import { ScrollView, StyleSheet, Text, View } from "react-native"; + +const Page = () => { + const { id } = useLocalSearchParams(); + const [person, setPerson] = useState(null); + const [loading, setLoading] = useState(true); + + useEffect(() => { + const fetchPeople = async () => { + try { + setLoading(true); + const response = await fetch(`https://swapi.dev/api/people/${id}`); + const data = await response.json(); + setPerson(data); + } catch (error) { + console.error(error); + } finally { + setLoading(false); + } + }; + + fetchPeople(); + }, [id]); + + if (loading) { + return ( + + Loading... + + ); + } + + if (!person) { + return ( + + Film not found + + ); + } + + return ( + + + {person.name} + + Height: {person.height} cm + Mass: {person.mass} kg + Hair Color: {person.hair_color} + Skin Color: {person.skin_color} + Eye Color: {person.eye_color} + Birth Year: {person.birth_year} + Gender: {person.gender} + + ); +}; + +export default Page; + +const styles = StyleSheet.create({ + container: { + flex: 1, + padding: 16, + backgroundColor: COLORS.background, + }, + header: { + flexDirection: "row", + justifyContent: "space-between", + alignItems: "center", + marginBottom: 16, + }, + name: { + fontSize: 24, + fontWeight: "bold", + color: COLORS.text, + flex: 1, + }, + favoriteButton: { + padding: 8, + }, + detail: { + fontSize: 16, + color: COLORS.text, + marginBottom: 8, + }, +}); diff --git a/app/people/_layout.tsx b/app/people/_layout.tsx new file mode 100644 index 0000000..05ec849 --- /dev/null +++ b/app/people/_layout.tsx @@ -0,0 +1,36 @@ +import { StyleSheet, Text, View } from "react-native"; +import React from "react"; +import { Stack } from "expo-router"; +import { COLORS } from "@/constants/colors"; + +const Layout = () => { + return ( + + + + + ); +}; + +export default Layout; + +const styles = StyleSheet.create({}); diff --git a/app/people/index.tsx b/app/people/index.tsx new file mode 100644 index 0000000..c7b8748 --- /dev/null +++ b/app/people/index.tsx @@ -0,0 +1,158 @@ +import ListEmptyComponent from "@/components/ListEmptyComponent"; +import PersonItem from "@/components/PersonItem"; +import { COLORS } from "@/constants/colors"; +import { Person } from "@/types/person"; +import React, { useEffect, useState } from "react"; +import { + ActivityIndicator, + Button, + FlatList, + RefreshControl, + StyleSheet, + TextInput, + View, +} from "react-native"; + +interface ApiResponse { + results: Person[]; + next: string | null; + result?: Person[]; +} +const Page = () => { + const [people, setPeople] = useState([]); + const [refreshing, setRefreshing] = useState(false); + const [loading, setLoading] = useState(true); + const [searchQuery, setSearchQuery] = useState(""); + const [nextPage, setNextPage] = useState(null); + const [loadingMore, setLoadingMore] = useState(false); + + const fetchPeople = async (query: string = "", url: string | null = null) => { + setLoading(true); + try { + let apiUrl = url || `https://swapi.dev/api/people`; + if (query) { + apiUrl = `https://swapi.tech/api/people/?name=${query}`; + } + + const response = await fetch(apiUrl); + const data: ApiResponse = await response.json(); + + if (query && data.result) { + const transformedResults = data.result.map((item: any) => ({ + name: item.properties.name, + birth_year: item.properties.birth_year, + gender: item.properties.gender, + url: item.properties.url, + })); + setPeople(transformedResults); + } else { + setPeople((prevPeople) => + url ? [...prevPeople, ...data.results] : data.results, + ); + } + setNextPage(data.next); + } catch (error) { + console.error("Error fetching people:", error); + } finally { + setRefreshing(false); + setLoading(false); + setLoadingMore(false); + } + }; + + useEffect(() => { + fetchPeople(); + }, []); + + const onRefresh = () => { + setRefreshing(true); + fetchPeople(searchQuery); + }; + + const handleSearch = () => { + fetchPeople(searchQuery); + }; + + const handleLoadMore = () => { + if (nextPage && !loadingMore) { + setLoadingMore(true); + fetchPeople(searchQuery, nextPage); + } + }; + + const renderFooter = () => { + if (!loadingMore) return null; + return ( + + + + ); + }; + + return ( + + + +