diff --git a/attachments/02-basics/00-starting-project/App.js b/attachments/02-basics/00-starting-project/App.js new file mode 100644 index 00000000..eeadebd5 --- /dev/null +++ b/attachments/02-basics/00-starting-project/App.js @@ -0,0 +1,80 @@ +import { useState } from 'react'; +import { Button, FlatList, StyleSheet, View } from 'react-native'; +import GoalItem from './components/GoalItem'; +import GoalInput from './components/GoalInput'; +import { StatusBar } from 'expo-status-bar'; + +export default function App() { + const [courseGoals, setCourseGoals] = useState([]); + const [modalIsVisible, setModalIsVisible] = useState(false); + + function startAddGoalHandler() { + setModalIsVisible(true); + } + + function endAddGoalHandler() { + setModalIsVisible(false); + } + + function addGoalHandler(enteredGoalText) { + console.log(enteredGoalText); + setCourseGoals((prev) => [ + ...prev, + { text: enteredGoalText, id: Math.random().toString() } + ]); + endAddGoalHandler(); + } + + function deleteGoalHandler(id) { + setCourseGoals((prev) => { + return prev.filter((goal) => goal.id !== id); + }); + } + + return ( + <> + + + + + + + {isEditing && ( + + + + )} + + ); +} + +export default ManageExpense; + +const styles = StyleSheet.create({ + container: { + flex: 1, + padding: 24, + backgroundColor: GlobalStyles.colors.primary800 + }, + buttonsContainer: { + flexDirection: 'row', + justifyContent: 'center', + alignItems: 'center' + }, + button: { + minWidth: 120, + marginHorizontal: 8 + }, + deleteContainer: { + marginTop: 16, + paddingTop: 8, + borderTopWidth: 2, + borderTopColor: GlobalStyles.colors.primary200, + alignItems: 'center' + } +}); diff --git a/attachments/08-practice-app/00-starting-project/screens/RecentExpenses.js b/attachments/08-practice-app/00-starting-project/screens/RecentExpenses.js new file mode 100644 index 00000000..135f5386 --- /dev/null +++ b/attachments/08-practice-app/00-starting-project/screens/RecentExpenses.js @@ -0,0 +1,26 @@ +import { useContext } from 'react'; +import ExpensesOutput from '../components/Expenses/ExpensesOutput'; +import { ExpensesContext } from '../store/expenses-context'; + +import { getDateMinusDays } from '../util/date'; + +function RecentExpenses() { + const expensesCtx = useContext(ExpensesContext); + + const recentExpenses = expensesCtx.expenses.filter((expense) => { + const today = new Date(); + const date7DaysAgo = getDateMinusDays(today, 7); + + return expense.date > date7DaysAgo; + }); + + return ( + + ); +} + +export default RecentExpenses; diff --git a/attachments/08-practice-app/00-starting-project/store/expenses-context.js b/attachments/08-practice-app/00-starting-project/store/expenses-context.js new file mode 100644 index 00000000..2c720a4a --- /dev/null +++ b/attachments/08-practice-app/00-starting-project/store/expenses-context.js @@ -0,0 +1,97 @@ +import { createContext, useReducer } from 'react'; + +export const DUMMY_EXPENSES = [ + { + id: 'e1', + description: 'A pair of shoes', + date: new Date('2021-12-19'), + amount: 59.99 + }, + { + id: 'e2', + description: 'A pair of trousers', + date: new Date('2021-12-19'), + amount: 18.59 + }, + { + id: 'e3', + description: 'Some bananas', + date: new Date('2021-12-01'), + amount: 5.99 + }, + { + id: 'e4', + description: 'A book', + date: new Date('2022-02-19'), + amount: 14.99 + }, + { + id: 'e5', + description: 'Another book', + date: new Date('2022-02-18'), + amount: 18.59 + } +]; + +export const ExpensesContext = createContext({ + expenses: [], + addExpense: ({ description, amount, date }) => {}, + deleteExpense: (id) => {}, + updateExpense: (id, { description, amount, date }) => {} +}); + +function expensesReducer(state, action) { + switch (action.type) { + case 'ADD': + const id = new Date().toString() + Math.random().toExponential.toString(); + + return [{ ...action.payload }, ...state]; + case 'UPDATE': + const updatableExpenseIndex = state.findIndex( + (expense) => expense.id === action.payload.id + ); + + const updatableExpense = state[updatableExpenseIndex]; + const updatedItem = { ...updatableExpense, ...action.payload.data }; + const updatedExpenses = [...state]; + updatedExpenses[updatableExpenseIndex] = updatedItem; + + return updatedExpenses; + case 'DELETE': + return state.expenses.filter( + (expense) => expense.id !== action.payload.id + ); + + default: + return state; + } +} + +export default function ExpensesContextProvider({ children }) { + const [expensesState, dispatch] = useReducer(expensesReducer, DUMMY_EXPENSES); + + function addExpense(expenseData) { + dispatch({ type: 'ADD', payload: expenseData }); + } + + function deleteExpense(id) { + dispatch({ type: 'DELETE', payload: id }); + } + + function updateExpense(id, expenseData) { + dispatch({ type: 'UPDATE', payload: { id: id, data: expenseData } }); + } + + const value = { + expenses: expensesState, + addExpense: addExpense, + deleteExpense: deleteExpense, + updateExpense: updateExpense + }; + + return ( + + {children} + + ); +} diff --git a/attachments/08-practice-app/00-starting-project/util/date.js b/attachments/08-practice-app/00-starting-project/util/date.js new file mode 100644 index 00000000..c666432e --- /dev/null +++ b/attachments/08-practice-app/00-starting-project/util/date.js @@ -0,0 +1,7 @@ +export function getFormattedDate(date) { + return `${date.getFullYear()}-${date.getMonth() + 1}-${date.getDate()}`; +} + +export function getDateMinusDays(date, days) { + return new Date(date.getFullYear(), date.getMonth(), date.getDate() - days); +} diff --git a/attachments/09-user-input/00-starting-project/App.js b/attachments/09-user-input/00-starting-project/App.js new file mode 100644 index 00000000..728db090 --- /dev/null +++ b/attachments/09-user-input/00-starting-project/App.js @@ -0,0 +1,92 @@ +import { StatusBar } from 'expo-status-bar'; +import { NavigationContainer } from '@react-navigation/native'; +import { createNativeStackNavigator } from '@react-navigation/native-stack'; +import { createBottomTabNavigator } from '@react-navigation/bottom-tabs'; +import { Ionicons } from '@expo/vector-icons'; + +import ManageExpense from './screens/ManageExpense'; +import RecentExpenses from './screens/RecentExpenses'; +import AllExpenses from './screens/AllExpenses'; +import { GlobalStyles } from './constants/styles'; +import IconButton from './components/UI/IconButton'; +import ExpensesContextProvider from './store/expenses-context'; + +const Stack = createNativeStackNavigator(); +const BottomTabs = createBottomTabNavigator(); + +function ExpensesOverview() { + return ( + ({ + headerStyle: { backgroundColor: GlobalStyles.colors.primary500 }, + headerTintColor: 'white', + tabBarStyle: { backgroundColor: GlobalStyles.colors.primary500 }, + tabBarActiveTintColor: GlobalStyles.colors.accent500, + headerRight: ({ tintColor }) => ( + { + navigation.navigate('ManageExpense'); + }} + /> + ), + })} + > + ( + + ), + }} + /> + ( + + ), + }} + /> + + ); +} + +export default function App() { + return ( + <> + + + + + + + + + + + ); +} diff --git a/attachments/09-user-input/00-starting-project/app.json b/attachments/09-user-input/00-starting-project/app.json new file mode 100644 index 00000000..de352e82 --- /dev/null +++ b/attachments/09-user-input/00-starting-project/app.json @@ -0,0 +1,32 @@ +{ + "expo": { + "name": "RNCourse", + "slug": "RNCourse", + "version": "1.0.0", + "orientation": "portrait", + "icon": "./assets/icon.png", + "splash": { + "image": "./assets/splash.png", + "resizeMode": "contain", + "backgroundColor": "#ffffff" + }, + "updates": { + "fallbackToCacheTimeout": 0 + }, + "assetBundlePatterns": [ + "**/*" + ], + "ios": { + "supportsTablet": true + }, + "android": { + "adaptiveIcon": { + "foregroundImage": "./assets/adaptive-icon.png", + "backgroundColor": "#FFFFFF" + } + }, + "web": { + "favicon": "./assets/favicon.png" + } + } +} diff --git a/attachments/09-user-input/00-starting-project/assets/adaptive-icon.png b/attachments/09-user-input/00-starting-project/assets/adaptive-icon.png new file mode 100644 index 00000000..03d6f6b6 Binary files /dev/null and b/attachments/09-user-input/00-starting-project/assets/adaptive-icon.png differ diff --git a/attachments/09-user-input/00-starting-project/assets/favicon.png b/attachments/09-user-input/00-starting-project/assets/favicon.png new file mode 100644 index 00000000..e75f697b Binary files /dev/null and b/attachments/09-user-input/00-starting-project/assets/favicon.png differ diff --git a/attachments/09-user-input/00-starting-project/assets/icon.png b/attachments/09-user-input/00-starting-project/assets/icon.png new file mode 100644 index 00000000..a0b1526f Binary files /dev/null and b/attachments/09-user-input/00-starting-project/assets/icon.png differ diff --git a/attachments/09-user-input/00-starting-project/assets/splash.png b/attachments/09-user-input/00-starting-project/assets/splash.png new file mode 100644 index 00000000..0e89705a Binary files /dev/null and b/attachments/09-user-input/00-starting-project/assets/splash.png differ diff --git a/attachments/09-user-input/00-starting-project/babel.config.js b/attachments/09-user-input/00-starting-project/babel.config.js new file mode 100644 index 00000000..e1babf6b --- /dev/null +++ b/attachments/09-user-input/00-starting-project/babel.config.js @@ -0,0 +1,6 @@ +module.exports = function(api) { + api.cache(true); + return { + presets: ['babel-preset-expo'], + }; +}; diff --git a/attachments/09-user-input/00-starting-project/components/ExpensesOutput/ExpenseItem.js b/attachments/09-user-input/00-starting-project/components/ExpensesOutput/ExpenseItem.js new file mode 100644 index 00000000..4a32d1bc --- /dev/null +++ b/attachments/09-user-input/00-starting-project/components/ExpensesOutput/ExpenseItem.js @@ -0,0 +1,76 @@ +import { Pressable, StyleSheet, Text, View } from 'react-native'; +import { useNavigation } from '@react-navigation/native'; + +import { GlobalStyles } from '../../constants/styles'; +import { getFormattedDate } from '../../util/date'; + +function ExpenseItem({ id, description, amount, date }) { + const navigation = useNavigation(); + + function expensePressHandler() { + navigation.navigate('ManageExpense', { + expenseId: id + }); + } + + return ( + pressed && styles.pressed} + > + + + + {description} + + {getFormattedDate(date)} + + + {amount.toFixed(2)} + + + + ); +} + +export default ExpenseItem; + +const styles = StyleSheet.create({ + pressed: { + opacity: 0.75, + }, + expenseItem: { + padding: 12, + marginVertical: 8, + backgroundColor: GlobalStyles.colors.primary500, + flexDirection: 'row', + justifyContent: 'space-between', + borderRadius: 6, + elevation: 3, + shadowColor: GlobalStyles.colors.gray500, + shadowRadius: 4, + shadowOffset: { width: 1, height: 1 }, + shadowOpacity: 0.4, + }, + textBase: { + color: GlobalStyles.colors.primary50, + }, + description: { + fontSize: 16, + marginBottom: 4, + fontWeight: 'bold', + }, + amountContainer: { + paddingHorizontal: 12, + paddingVertical: 4, + backgroundColor: 'white', + justifyContent: 'center', + alignItems: 'center', + borderRadius: 4, + minWidth: 80, + }, + amount: { + color: GlobalStyles.colors.primary500, + fontWeight: 'bold', + }, +}); diff --git a/attachments/09-user-input/00-starting-project/components/ExpensesOutput/ExpensesList.js b/attachments/09-user-input/00-starting-project/components/ExpensesOutput/ExpensesList.js new file mode 100644 index 00000000..80519f75 --- /dev/null +++ b/attachments/09-user-input/00-starting-project/components/ExpensesOutput/ExpensesList.js @@ -0,0 +1,19 @@ +import { FlatList } from 'react-native'; + +import ExpenseItem from './ExpenseItem'; + +function renderExpenseItem(itemData) { + return ; +} + +function ExpensesList({ expenses }) { + return ( + item.id} + /> + ); +} + +export default ExpensesList; diff --git a/attachments/09-user-input/00-starting-project/components/ExpensesOutput/ExpensesOutput.js b/attachments/09-user-input/00-starting-project/components/ExpensesOutput/ExpensesOutput.js new file mode 100644 index 00000000..675d12f2 --- /dev/null +++ b/attachments/09-user-input/00-starting-project/components/ExpensesOutput/ExpensesOutput.js @@ -0,0 +1,38 @@ +import { StyleSheet, Text, View } from 'react-native'; + +import { GlobalStyles } from '../../constants/styles'; +import ExpensesList from './ExpensesList'; +import ExpensesSummary from './ExpensesSummary'; + +function ExpensesOutput({ expenses, expensesPeriod, fallbackText }) { + let content = {fallbackText}; + + if (expenses.length > 0) { + content = ; + } + + return ( + + + {content} + + ); +} + +export default ExpensesOutput; + +const styles = StyleSheet.create({ + container: { + flex: 1, + paddingHorizontal: 24, + paddingTop: 24, + paddingBottom: 0, + backgroundColor: GlobalStyles.colors.primary700, + }, + infoText: { + color: 'white', + fontSize: 16, + textAlign: 'center', + marginTop: 32, + }, +}); diff --git a/attachments/09-user-input/00-starting-project/components/ExpensesOutput/ExpensesSummary.js b/attachments/09-user-input/00-starting-project/components/ExpensesOutput/ExpensesSummary.js new file mode 100644 index 00000000..33fdbfa0 --- /dev/null +++ b/attachments/09-user-input/00-starting-project/components/ExpensesOutput/ExpensesSummary.js @@ -0,0 +1,38 @@ +import { View, Text, StyleSheet } from 'react-native'; + +import { GlobalStyles } from '../../constants/styles'; + +function ExpensesSummary({ expenses, periodName }) { + const expensesSum = expenses.reduce((sum, expense) => { + return sum + expense.amount; + }, 0); + + return ( + + {periodName} + ${expensesSum.toFixed(2)} + + ); +} + +export default ExpensesSummary; + +const styles = StyleSheet.create({ + container: { + padding: 8, + backgroundColor: GlobalStyles.colors.primary50, + borderRadius: 6, + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + }, + period: { + fontSize: 12, + color: GlobalStyles.colors.primary400, + }, + sum: { + fontSize: 16, + fontWeight: 'bold', + color: GlobalStyles.colors.primary500, + }, +}); diff --git a/attachments/09-user-input/00-starting-project/components/ManageExpense/ExpenseForm.js b/attachments/09-user-input/00-starting-project/components/ManageExpense/ExpenseForm.js new file mode 100644 index 00000000..527f6624 --- /dev/null +++ b/attachments/09-user-input/00-starting-project/components/ManageExpense/ExpenseForm.js @@ -0,0 +1,162 @@ +import { View, StyleSheet, Text, Alert } from 'react-native'; +import Input from './Input'; +import { isValidElement, useState } from 'react'; +import Button from '../UI/Button'; +import { getFormattedDate } from '../../util/date'; +import { GlobalStyles } from '../../constants/styles'; + +function ExpenseForm({ submitButtonLabel, onCancel, onSubmit, defaultValues }) { + const [inputs, setInputs] = useState({ + amount: { + value: defaultValues ? defaultValues.amount.toString() : '', + isValid: true + }, + date: { + value: defaultValues ? getFormattedDate(defaultValues.date) : '', + isValid: true + }, + description: { + value: defaultValues ? defaultValues.description : '', + isValid: true + } + }); + + function inputChangedHandler(inputIdentifier, enteredValue) { + setInputs((currentInputValues) => { + return { + ...currentInputValues, + [inputIdentifier]: { value: enteredValue, isValid: true } + }; + }); + } + + function submitHandler() { + const expenseData = { + amount: +inputs.amount.value, + date: new Date(inputs.date.value), + description: inputs.description.value + }; + + const amountIsValid = !isNaN(expenseData.amount) && expenseData.amount > 0; + const dateIsValid = expenseData.date.toString() !== 'Invalid Date'; + const descriptionIsValid = expenseData.description.trim().length > 0; + + if (!amountIsValid || !dateIsValid || !descriptionIsValid) { + // Dit is een manier om feedback te geven aan de user maar er zijn nog andere manieren + // Alert.alert('Invalid input', 'Please check you input values'); + + setInputs((currentInputs) => { + return { + amount: { + value: currentInputs.amount.value, + isValid: amountIsValid + }, + date: { value: currentInputs.date.value, isValid: dateIsValid }, + description: { + value: currentInputs.description.value, + isValid: descriptionIsValid + } + }; + }); + return; + } + + onSubmit(expenseData); + } + + const formIsInvalid = + !inputs.amount.isValid || + !inputs.date.isValid || + !inputs.description.isValid; + + return ( + + Your Expense + + + + + + + {formIsInvalid && ( + + Invalid input values - please check your entered data!{' '} + + )} + + + + + + + ); +} + +export default ExpenseForm; + +const styles = StyleSheet.create({ + form: { + marginTop: 40 + }, + title: { + marginVertical: 24, + textAlign: 'center', + fontSize: 24, + fontWeight: 'bold', + color: 'white' + }, + inputsRow: { + flexDirection: 'row', + justifyContent: 'space-between' + }, + rowInput: { + flex: 1 + }, + errorText: { + textAlign: 'center', + color: GlobalStyles.colors.error500, + margin: 8 + }, + buttons: { + flexDirection: 'row', + justifyContent: 'center', + alignItems: 'center' + }, + button: { + minWidth: 120, + marginHorizontal: 8 + } +}); diff --git a/attachments/09-user-input/00-starting-project/components/ManageExpense/Input.js b/attachments/09-user-input/00-starting-project/components/ManageExpense/Input.js new file mode 100644 index 00000000..3c1fec6b --- /dev/null +++ b/attachments/09-user-input/00-starting-project/components/ManageExpense/Input.js @@ -0,0 +1,54 @@ +import { View, Text, StyleSheet, TextInput } from 'react-native'; +import { GlobalStyles } from '../../constants/styles'; + +function Input({ label, invalid, style, textInputConfig }) { + const inputStyles = [styles.input]; + + if (textInputConfig && textInputConfig.multiline) { + inputStyles.push(styles.inputMultiline); + } + + if (invalid) { + inputStyles.push(styles.invalidInput); + } + + return ( + + + {label} + + + + ); +} + +export default Input; + +const styles = StyleSheet.create({ + inputContainer: { + marginHorizontal: 4, + marginVertical: 8 + }, + label: { + fontSize: 12, + color: GlobalStyles.colors.primary100, + marginBottom: 4 + }, + input: { + backgroundColor: GlobalStyles.colors.primary100, + padding: 6, + borderRadius: 6, + fontSize: 18, + color: GlobalStyles.colors.primary700 + }, + inputMultiline: { + minHeight: 100, + textAlignVertical: 'top' + }, + invalidLabel: { + color: GlobalStyles.colors.error500 + }, + invalidInput: { + backgroundColor: GlobalStyles.colors.error50 + } +}); diff --git a/attachments/09-user-input/00-starting-project/components/UI/Button.js b/attachments/09-user-input/00-starting-project/components/UI/Button.js new file mode 100644 index 00000000..16e27972 --- /dev/null +++ b/attachments/09-user-input/00-starting-project/components/UI/Button.js @@ -0,0 +1,44 @@ +import { Pressable, StyleSheet, Text, View } from 'react-native'; +import { GlobalStyles } from '../../constants/styles'; + +function Button({ children, onPress, mode, style }) { + return ( + + pressed && styles.pressed} + > + + + {children} + + + + + ); +} + +export default Button; + +const styles = StyleSheet.create({ + button: { + borderRadius: 4, + padding: 8, + backgroundColor: GlobalStyles.colors.primary500, + }, + flat: { + backgroundColor: 'transparent', + }, + buttonText: { + color: 'white', + textAlign: 'center', + }, + flatText: { + color: GlobalStyles.colors.primary200, + }, + pressed: { + opacity: 0.75, + backgroundColor: GlobalStyles.colors.primary100, + borderRadius: 4, + }, +}); diff --git a/attachments/09-user-input/00-starting-project/components/UI/IconButton.js b/attachments/09-user-input/00-starting-project/components/UI/IconButton.js new file mode 100644 index 00000000..e0dd633e --- /dev/null +++ b/attachments/09-user-input/00-starting-project/components/UI/IconButton.js @@ -0,0 +1,29 @@ +import { Pressable, StyleSheet, View } from 'react-native'; +import { Ionicons } from '@expo/vector-icons'; + +function IconButton({ icon, size, color, onPress }) { + return ( + pressed && styles.pressed} + > + + + + + ); +} + +export default IconButton; + +const styles = StyleSheet.create({ + buttonContainer: { + borderRadius: 24, + padding: 6, + marginHorizontal: 8, + marginVertical: 2 + }, + pressed: { + opacity: 0.75, + }, +}); diff --git a/attachments/09-user-input/00-starting-project/constants/styles.js b/attachments/09-user-input/00-starting-project/constants/styles.js new file mode 100644 index 00000000..323f3002 --- /dev/null +++ b/attachments/09-user-input/00-starting-project/constants/styles.js @@ -0,0 +1,16 @@ +export const GlobalStyles = { + colors: { + primary50: '#e4d9fd', + primary100: '#c6affc', + primary200: '#a281f0', + primary400: '#5721d4', + primary500: '#3e04c3', + primary700: '#2d0689', + primary800: '#200364', + accent500: '#f7bc0c', + error50: '#fcc4e4', + error500: '#9b095c', + gray500: '#39324a', + gray700: '#221c30', + }, +}; diff --git a/attachments/09-user-input/00-starting-project/package.json b/attachments/09-user-input/00-starting-project/package.json new file mode 100644 index 00000000..813998ec --- /dev/null +++ b/attachments/09-user-input/00-starting-project/package.json @@ -0,0 +1,29 @@ +{ + "name": "rncourse", + "version": "1.0.0", + "main": "node_modules/expo/AppEntry.js", + "scripts": { + "start": "expo start", + "android": "expo start --android", + "ios": "expo start --ios", + "web": "expo start --web", + "eject": "expo eject" + }, + "dependencies": { + "@react-navigation/bottom-tabs": "^6.2.0", + "@react-navigation/native": "^6.0.8", + "@react-navigation/native-stack": "^6.5.0", + "expo": "^52.0.0", + "expo-status-bar": "~2.0.1", + "react": "18.3.1", + "react-dom": "18.3.1", + "react-native": "0.76.7", + "react-native-safe-area-context": "4.12.0", + "react-native-screens": "~4.4.0", + "react-native-web": "~0.19.13" + }, + "devDependencies": { + "@babel/core": "^7.25.2" + }, + "private": true +} diff --git a/attachments/09-user-input/00-starting-project/screens/AllExpenses.js b/attachments/09-user-input/00-starting-project/screens/AllExpenses.js new file mode 100644 index 00000000..7cb8f019 --- /dev/null +++ b/attachments/09-user-input/00-starting-project/screens/AllExpenses.js @@ -0,0 +1,18 @@ +import { useContext } from 'react'; + +import ExpensesOutput from '../components/ExpensesOutput/ExpensesOutput'; +import { ExpensesContext } from '../store/expenses-context'; + +function AllExpenses() { + const expensesCtx = useContext(ExpensesContext); + + return ( + + ); +} + +export default AllExpenses; diff --git a/attachments/09-user-input/00-starting-project/screens/ManageExpense.js b/attachments/09-user-input/00-starting-project/screens/ManageExpense.js new file mode 100644 index 00000000..0d19189b --- /dev/null +++ b/attachments/09-user-input/00-starting-project/screens/ManageExpense.js @@ -0,0 +1,81 @@ +import { useContext, useLayoutEffect } from 'react'; +import { StyleSheet, View, TextInput } from 'react-native'; + +import IconButton from '../components/UI/IconButton'; +import { GlobalStyles } from '../constants/styles'; +import { ExpensesContext } from '../store/expenses-context'; +import ExpenseForm from '../components/ManageExpense/ExpenseForm'; + +function ManageExpense({ route, navigation }) { + const expensesCtx = useContext(ExpensesContext); + + const editedExpenseId = route.params?.expenseId; + const isEditing = !!editedExpenseId; + + const selectedExpense = expensesCtx.expenses.find( + (expense) => expense.id === editedExpenseId + ); + + useLayoutEffect(() => { + navigation.setOptions({ + title: isEditing ? 'Edit Expense' : 'Add Expense' + }); + }, [navigation, isEditing]); + + function deleteExpenseHandler() { + expensesCtx.deleteExpense(editedExpenseId); + navigation.goBack(); + } + + function cancelHandler() { + navigation.goBack(); + } + + function confirmHandler(expenseData) { + if (isEditing) { + expensesCtx.updateExpense(editedExpenseId, expenseData); + } else { + expensesCtx.addExpense(expenseData); + } + navigation.goBack(); + } + + return ( + + + + {isEditing && ( + + + + )} + + ); +} + +export default ManageExpense; + +const styles = StyleSheet.create({ + container: { + flex: 1, + padding: 24, + backgroundColor: GlobalStyles.colors.primary800 + }, + deleteContainer: { + marginTop: 16, + paddingTop: 8, + borderTopWidth: 2, + borderTopColor: GlobalStyles.colors.primary200, + alignItems: 'center' + } +}); diff --git a/attachments/09-user-input/00-starting-project/screens/RecentExpenses.js b/attachments/09-user-input/00-starting-project/screens/RecentExpenses.js new file mode 100644 index 00000000..05d7935e --- /dev/null +++ b/attachments/09-user-input/00-starting-project/screens/RecentExpenses.js @@ -0,0 +1,26 @@ +import { useContext } from 'react'; + +import ExpensesOutput from '../components/ExpensesOutput/ExpensesOutput'; +import { ExpensesContext } from '../store/expenses-context'; +import { getDateMinusDays } from '../util/date'; + +function RecentExpenses() { + const expensesCtx = useContext(ExpensesContext); + + const recentExpenses = expensesCtx.expenses.filter((expense) => { + const today = new Date(); + const date7DaysAgo = getDateMinusDays(today, 7); + + return expense.date >= date7DaysAgo && expense.date <= today; + }); + + return ( + + ); +} + +export default RecentExpenses; diff --git a/attachments/09-user-input/00-starting-project/store/expenses-context.js b/attachments/09-user-input/00-starting-project/store/expenses-context.js new file mode 100644 index 00000000..61100e3d --- /dev/null +++ b/attachments/09-user-input/00-starting-project/store/expenses-context.js @@ -0,0 +1,117 @@ +import { createContext, useReducer } from 'react'; + +const DUMMY_EXPENSES = [ + { + id: 'e1', + description: 'A pair of shoes', + amount: 59.99, + date: new Date('2021-12-19'), + }, + { + id: 'e2', + description: 'A pair of trousers', + amount: 89.29, + date: new Date('2022-01-05'), + }, + { + id: 'e3', + description: 'Some bananas', + amount: 5.99, + date: new Date('2021-12-01'), + }, + { + id: 'e4', + description: 'A book', + amount: 14.99, + date: new Date('2022-02-19'), + }, + { + id: 'e5', + description: 'Another book', + amount: 18.59, + date: new Date('2022-02-18'), + }, + { + id: 'e6', + description: 'A pair of trousers', + amount: 89.29, + date: new Date('2022-01-05'), + }, + { + id: 'e7', + description: 'Some bananas', + amount: 5.99, + date: new Date('2021-12-01'), + }, + { + id: 'e8', + description: 'A book', + amount: 14.99, + date: new Date('2022-02-19'), + }, + { + id: 'e9', + description: 'Another book', + amount: 18.59, + date: new Date('2022-02-18'), + }, +]; + +export const ExpensesContext = createContext({ + expenses: [], + addExpense: ({ description, amount, date }) => {}, + deleteExpense: (id) => {}, + updateExpense: (id, { description, amount, date }) => {}, +}); + +function expensesReducer(state, action) { + switch (action.type) { + case 'ADD': + const id = new Date().toString() + Math.random().toString(); + return [{ ...action.payload, id: id }, ...state]; + case 'UPDATE': + const updatableExpenseIndex = state.findIndex( + (expense) => expense.id === action.payload.id + ); + const updatableExpense = state[updatableExpenseIndex]; + const updatedItem = { ...updatableExpense, ...action.payload.data }; + const updatedExpenses = [...state]; + updatedExpenses[updatableExpenseIndex] = updatedItem; + return updatedExpenses; + case 'DELETE': + return state.filter((expense) => expense.id !== action.payload); + default: + return state; + } +} + +function ExpensesContextProvider({ children }) { + const [expensesState, dispatch] = useReducer(expensesReducer, DUMMY_EXPENSES); + + function addExpense(expenseData) { + dispatch({ type: 'ADD', payload: expenseData }); + } + + function deleteExpense(id) { + dispatch({ type: 'DELETE', payload: id }); + } + + function updateExpense(id, expenseData) { + dispatch({ type: 'UPDATE', payload: { id: id, data: expenseData } }); + } + + const value = { + expenses: expensesState, + addExpense: addExpense, + deleteExpense: deleteExpense, + updateExpense: updateExpense, + }; + + return ( + + {children} + + ); +} + +export default ExpensesContextProvider; diff --git a/attachments/09-user-input/00-starting-project/util/date.js b/attachments/09-user-input/00-starting-project/util/date.js new file mode 100644 index 00000000..cd4de2aa --- /dev/null +++ b/attachments/09-user-input/00-starting-project/util/date.js @@ -0,0 +1,8 @@ +export function getFormattedDate(date) { + // return `${date.getFullYear()}-${date.getMonth() + 1}-${date.getDate()}`; + return date.toISOString().slice(0, 10); +} + +export function getDateMinusDays(date, days) { + return new Date(date.getFullYear(), date.getMonth(), date.getDate() - days); +} diff --git a/attachments/10-http/00-starting-project/App.js b/attachments/10-http/00-starting-project/App.js new file mode 100644 index 00000000..728db090 --- /dev/null +++ b/attachments/10-http/00-starting-project/App.js @@ -0,0 +1,92 @@ +import { StatusBar } from 'expo-status-bar'; +import { NavigationContainer } from '@react-navigation/native'; +import { createNativeStackNavigator } from '@react-navigation/native-stack'; +import { createBottomTabNavigator } from '@react-navigation/bottom-tabs'; +import { Ionicons } from '@expo/vector-icons'; + +import ManageExpense from './screens/ManageExpense'; +import RecentExpenses from './screens/RecentExpenses'; +import AllExpenses from './screens/AllExpenses'; +import { GlobalStyles } from './constants/styles'; +import IconButton from './components/UI/IconButton'; +import ExpensesContextProvider from './store/expenses-context'; + +const Stack = createNativeStackNavigator(); +const BottomTabs = createBottomTabNavigator(); + +function ExpensesOverview() { + return ( + ({ + headerStyle: { backgroundColor: GlobalStyles.colors.primary500 }, + headerTintColor: 'white', + tabBarStyle: { backgroundColor: GlobalStyles.colors.primary500 }, + tabBarActiveTintColor: GlobalStyles.colors.accent500, + headerRight: ({ tintColor }) => ( + { + navigation.navigate('ManageExpense'); + }} + /> + ), + })} + > + ( + + ), + }} + /> + ( + + ), + }} + /> + + ); +} + +export default function App() { + return ( + <> + + + + + + + + + + + ); +} diff --git a/attachments/10-http/00-starting-project/app.json b/attachments/10-http/00-starting-project/app.json new file mode 100644 index 00000000..de352e82 --- /dev/null +++ b/attachments/10-http/00-starting-project/app.json @@ -0,0 +1,32 @@ +{ + "expo": { + "name": "RNCourse", + "slug": "RNCourse", + "version": "1.0.0", + "orientation": "portrait", + "icon": "./assets/icon.png", + "splash": { + "image": "./assets/splash.png", + "resizeMode": "contain", + "backgroundColor": "#ffffff" + }, + "updates": { + "fallbackToCacheTimeout": 0 + }, + "assetBundlePatterns": [ + "**/*" + ], + "ios": { + "supportsTablet": true + }, + "android": { + "adaptiveIcon": { + "foregroundImage": "./assets/adaptive-icon.png", + "backgroundColor": "#FFFFFF" + } + }, + "web": { + "favicon": "./assets/favicon.png" + } + } +} diff --git a/attachments/10-http/00-starting-project/assets/adaptive-icon.png b/attachments/10-http/00-starting-project/assets/adaptive-icon.png new file mode 100644 index 00000000..03d6f6b6 Binary files /dev/null and b/attachments/10-http/00-starting-project/assets/adaptive-icon.png differ diff --git a/attachments/10-http/00-starting-project/assets/favicon.png b/attachments/10-http/00-starting-project/assets/favicon.png new file mode 100644 index 00000000..e75f697b Binary files /dev/null and b/attachments/10-http/00-starting-project/assets/favicon.png differ diff --git a/attachments/10-http/00-starting-project/assets/icon.png b/attachments/10-http/00-starting-project/assets/icon.png new file mode 100644 index 00000000..a0b1526f Binary files /dev/null and b/attachments/10-http/00-starting-project/assets/icon.png differ diff --git a/attachments/10-http/00-starting-project/assets/splash.png b/attachments/10-http/00-starting-project/assets/splash.png new file mode 100644 index 00000000..0e89705a Binary files /dev/null and b/attachments/10-http/00-starting-project/assets/splash.png differ diff --git a/attachments/10-http/00-starting-project/babel.config.js b/attachments/10-http/00-starting-project/babel.config.js new file mode 100644 index 00000000..e1babf6b --- /dev/null +++ b/attachments/10-http/00-starting-project/babel.config.js @@ -0,0 +1,6 @@ +module.exports = function(api) { + api.cache(true); + return { + presets: ['babel-preset-expo'], + }; +}; diff --git a/attachments/10-http/00-starting-project/components/ExpensesOutput/ExpenseItem.js b/attachments/10-http/00-starting-project/components/ExpensesOutput/ExpenseItem.js new file mode 100644 index 00000000..4a32d1bc --- /dev/null +++ b/attachments/10-http/00-starting-project/components/ExpensesOutput/ExpenseItem.js @@ -0,0 +1,76 @@ +import { Pressable, StyleSheet, Text, View } from 'react-native'; +import { useNavigation } from '@react-navigation/native'; + +import { GlobalStyles } from '../../constants/styles'; +import { getFormattedDate } from '../../util/date'; + +function ExpenseItem({ id, description, amount, date }) { + const navigation = useNavigation(); + + function expensePressHandler() { + navigation.navigate('ManageExpense', { + expenseId: id + }); + } + + return ( + pressed && styles.pressed} + > + + + + {description} + + {getFormattedDate(date)} + + + {amount.toFixed(2)} + + + + ); +} + +export default ExpenseItem; + +const styles = StyleSheet.create({ + pressed: { + opacity: 0.75, + }, + expenseItem: { + padding: 12, + marginVertical: 8, + backgroundColor: GlobalStyles.colors.primary500, + flexDirection: 'row', + justifyContent: 'space-between', + borderRadius: 6, + elevation: 3, + shadowColor: GlobalStyles.colors.gray500, + shadowRadius: 4, + shadowOffset: { width: 1, height: 1 }, + shadowOpacity: 0.4, + }, + textBase: { + color: GlobalStyles.colors.primary50, + }, + description: { + fontSize: 16, + marginBottom: 4, + fontWeight: 'bold', + }, + amountContainer: { + paddingHorizontal: 12, + paddingVertical: 4, + backgroundColor: 'white', + justifyContent: 'center', + alignItems: 'center', + borderRadius: 4, + minWidth: 80, + }, + amount: { + color: GlobalStyles.colors.primary500, + fontWeight: 'bold', + }, +}); diff --git a/attachments/10-http/00-starting-project/components/ExpensesOutput/ExpensesList.js b/attachments/10-http/00-starting-project/components/ExpensesOutput/ExpensesList.js new file mode 100644 index 00000000..80519f75 --- /dev/null +++ b/attachments/10-http/00-starting-project/components/ExpensesOutput/ExpensesList.js @@ -0,0 +1,19 @@ +import { FlatList } from 'react-native'; + +import ExpenseItem from './ExpenseItem'; + +function renderExpenseItem(itemData) { + return ; +} + +function ExpensesList({ expenses }) { + return ( + item.id} + /> + ); +} + +export default ExpensesList; diff --git a/attachments/10-http/00-starting-project/components/ExpensesOutput/ExpensesOutput.js b/attachments/10-http/00-starting-project/components/ExpensesOutput/ExpensesOutput.js new file mode 100644 index 00000000..675d12f2 --- /dev/null +++ b/attachments/10-http/00-starting-project/components/ExpensesOutput/ExpensesOutput.js @@ -0,0 +1,38 @@ +import { StyleSheet, Text, View } from 'react-native'; + +import { GlobalStyles } from '../../constants/styles'; +import ExpensesList from './ExpensesList'; +import ExpensesSummary from './ExpensesSummary'; + +function ExpensesOutput({ expenses, expensesPeriod, fallbackText }) { + let content = {fallbackText}; + + if (expenses.length > 0) { + content = ; + } + + return ( + + + {content} + + ); +} + +export default ExpensesOutput; + +const styles = StyleSheet.create({ + container: { + flex: 1, + paddingHorizontal: 24, + paddingTop: 24, + paddingBottom: 0, + backgroundColor: GlobalStyles.colors.primary700, + }, + infoText: { + color: 'white', + fontSize: 16, + textAlign: 'center', + marginTop: 32, + }, +}); diff --git a/attachments/10-http/00-starting-project/components/ExpensesOutput/ExpensesSummary.js b/attachments/10-http/00-starting-project/components/ExpensesOutput/ExpensesSummary.js new file mode 100644 index 00000000..33fdbfa0 --- /dev/null +++ b/attachments/10-http/00-starting-project/components/ExpensesOutput/ExpensesSummary.js @@ -0,0 +1,38 @@ +import { View, Text, StyleSheet } from 'react-native'; + +import { GlobalStyles } from '../../constants/styles'; + +function ExpensesSummary({ expenses, periodName }) { + const expensesSum = expenses.reduce((sum, expense) => { + return sum + expense.amount; + }, 0); + + return ( + + {periodName} + ${expensesSum.toFixed(2)} + + ); +} + +export default ExpensesSummary; + +const styles = StyleSheet.create({ + container: { + padding: 8, + backgroundColor: GlobalStyles.colors.primary50, + borderRadius: 6, + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + }, + period: { + fontSize: 12, + color: GlobalStyles.colors.primary400, + }, + sum: { + fontSize: 16, + fontWeight: 'bold', + color: GlobalStyles.colors.primary500, + }, +}); diff --git a/attachments/10-http/00-starting-project/components/ManageExpense/ExpenseForm.js b/attachments/10-http/00-starting-project/components/ManageExpense/ExpenseForm.js new file mode 100644 index 00000000..a1a0a361 --- /dev/null +++ b/attachments/10-http/00-starting-project/components/ManageExpense/ExpenseForm.js @@ -0,0 +1,156 @@ +import { useState } from 'react'; +import { StyleSheet, Text, View } from 'react-native'; + +import Input from './Input'; +import Button from '../UI/Button'; +import { getFormattedDate } from '../../util/date'; +import { GlobalStyles } from '../../constants/styles'; + +function ExpenseForm({ submitButtonLabel, onCancel, onSubmit, defaultValues }) { + const [inputs, setInputs] = useState({ + amount: { + value: defaultValues ? defaultValues.amount.toString() : '', + isValid: true, + }, + date: { + value: defaultValues ? getFormattedDate(defaultValues.date) : '', + isValid: true, + }, + description: { + value: defaultValues ? defaultValues.description : '', + isValid: true, + }, + }); + + function inputChangedHandler(inputIdentifier, enteredValue) { + setInputs((curInputs) => { + return { + ...curInputs, + [inputIdentifier]: { value: enteredValue, isValid: true }, + }; + }); + } + + function submitHandler() { + const expenseData = { + amount: +inputs.amount.value, + date: new Date(inputs.date.value), + description: inputs.description.value, + }; + + const amountIsValid = !isNaN(expenseData.amount) && expenseData.amount > 0; + const dateIsValid = expenseData.date.toString() !== 'Invalid Date'; + const descriptionIsValid = expenseData.description.trim().length > 0; + + if (!amountIsValid || !dateIsValid || !descriptionIsValid) { + // Alert.alert('Invalid input', 'Please check your input values'); + setInputs((curInputs) => { + return { + amount: { value: curInputs.amount.value, isValid: amountIsValid }, + date: { value: curInputs.date.value, isValid: dateIsValid }, + description: { + value: curInputs.description.value, + isValid: descriptionIsValid, + }, + }; + }); + return; + } + + onSubmit(expenseData); + } + + const formIsInvalid = + !inputs.amount.isValid || + !inputs.date.isValid || + !inputs.description.isValid; + + return ( + + Your Expense + + + + + + {formIsInvalid && ( + + Invalid input values - please check your entered data! + + )} + + + + + + ); +} + +export default ExpenseForm; + +const styles = StyleSheet.create({ + form: { + marginTop: 40, + }, + title: { + fontSize: 24, + fontWeight: 'bold', + color: 'white', + marginVertical: 24, + textAlign: 'center', + }, + inputsRow: { + flexDirection: 'row', + justifyContent: 'space-between', + }, + rowInput: { + flex: 1, + }, + errorText: { + textAlign: 'center', + color: GlobalStyles.colors.error500, + margin: 8, + }, + buttons: { + flexDirection: 'row', + justifyContent: 'center', + alignItems: 'center', + }, + button: { + minWidth: 120, + marginHorizontal: 8, + }, +}); diff --git a/attachments/10-http/00-starting-project/components/ManageExpense/Input.js b/attachments/10-http/00-starting-project/components/ManageExpense/Input.js new file mode 100644 index 00000000..5093a0bd --- /dev/null +++ b/attachments/10-http/00-starting-project/components/ManageExpense/Input.js @@ -0,0 +1,54 @@ +import { StyleSheet, Text, TextInput, View } from 'react-native'; + +import { GlobalStyles } from '../../constants/styles'; + +function Input({ label, invalid, style, textInputConfig }) { + + const inputStyles = [styles.input]; + + if (textInputConfig && textInputConfig.multiline) { + inputStyles.push(styles.inputMultiline) + } + + if (invalid) { + inputStyles.push(styles.invalidInput); + } + + return ( + + {label} + + + ); +} + +export default Input; + +const styles = StyleSheet.create({ + inputContainer: { + marginHorizontal: 4, + marginVertical: 8 + }, + label: { + fontSize: 12, + color: GlobalStyles.colors.primary100, + marginBottom: 4, + }, + input: { + backgroundColor: GlobalStyles.colors.primary100, + color: GlobalStyles.colors.primary700, + padding: 6, + borderRadius: 6, + fontSize: 18, + }, + inputMultiline: { + minHeight: 100, + textAlignVertical: 'top' + }, + invalidLabel: { + color: GlobalStyles.colors.error500 + }, + invalidInput: { + backgroundColor: GlobalStyles.colors.error50 + } +}); diff --git a/attachments/10-http/00-starting-project/components/UI/Button.js b/attachments/10-http/00-starting-project/components/UI/Button.js new file mode 100644 index 00000000..16e27972 --- /dev/null +++ b/attachments/10-http/00-starting-project/components/UI/Button.js @@ -0,0 +1,44 @@ +import { Pressable, StyleSheet, Text, View } from 'react-native'; +import { GlobalStyles } from '../../constants/styles'; + +function Button({ children, onPress, mode, style }) { + return ( + + pressed && styles.pressed} + > + + + {children} + + + + + ); +} + +export default Button; + +const styles = StyleSheet.create({ + button: { + borderRadius: 4, + padding: 8, + backgroundColor: GlobalStyles.colors.primary500, + }, + flat: { + backgroundColor: 'transparent', + }, + buttonText: { + color: 'white', + textAlign: 'center', + }, + flatText: { + color: GlobalStyles.colors.primary200, + }, + pressed: { + opacity: 0.75, + backgroundColor: GlobalStyles.colors.primary100, + borderRadius: 4, + }, +}); diff --git a/attachments/10-http/00-starting-project/components/UI/ErrorOverlay.js b/attachments/10-http/00-starting-project/components/UI/ErrorOverlay.js new file mode 100644 index 00000000..ca76b020 --- /dev/null +++ b/attachments/10-http/00-starting-project/components/UI/ErrorOverlay.js @@ -0,0 +1,31 @@ +import { View, Text, StyleSheet } from 'react-native'; +import { GlobalStyles } from '../../constants/styles'; + +function ErrorOverlay({ message }) { + return ( + + An error occurred! + {message} + + ); +} + +export default ErrorOverlay; + +const styles = StyleSheet.create({ + container: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + padding: 24, + backgroundColor: GlobalStyles.colors.primary700 + }, + text: { + textAlign: 'center', + marginBottom: 8 + }, + title: { + fontSize: 20, + fontWeight: 'bold' + } +}); diff --git a/attachments/10-http/00-starting-project/components/UI/IconButton.js b/attachments/10-http/00-starting-project/components/UI/IconButton.js new file mode 100644 index 00000000..e0dd633e --- /dev/null +++ b/attachments/10-http/00-starting-project/components/UI/IconButton.js @@ -0,0 +1,29 @@ +import { Pressable, StyleSheet, View } from 'react-native'; +import { Ionicons } from '@expo/vector-icons'; + +function IconButton({ icon, size, color, onPress }) { + return ( + pressed && styles.pressed} + > + + + + + ); +} + +export default IconButton; + +const styles = StyleSheet.create({ + buttonContainer: { + borderRadius: 24, + padding: 6, + marginHorizontal: 8, + marginVertical: 2 + }, + pressed: { + opacity: 0.75, + }, +}); diff --git a/attachments/10-http/00-starting-project/components/UI/LoadingOverlay.js b/attachments/10-http/00-starting-project/components/UI/LoadingOverlay.js new file mode 100644 index 00000000..2135d7af --- /dev/null +++ b/attachments/10-http/00-starting-project/components/UI/LoadingOverlay.js @@ -0,0 +1,23 @@ +import React from 'react'; +import { View, ActivityIndicator, StyleSheet } from 'react-native'; +import { GlobalStyles } from '../../constants/styles'; + +function LoadingOverlay() { + return ( + + + + ); +} + +export default LoadingOverlay; + +const styles = StyleSheet.create({ + container: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + padding: 24, + backgroundColor: GlobalStyles.colors.primary700 + } +}); diff --git a/attachments/10-http/00-starting-project/constants/styles.js b/attachments/10-http/00-starting-project/constants/styles.js new file mode 100644 index 00000000..323f3002 --- /dev/null +++ b/attachments/10-http/00-starting-project/constants/styles.js @@ -0,0 +1,16 @@ +export const GlobalStyles = { + colors: { + primary50: '#e4d9fd', + primary100: '#c6affc', + primary200: '#a281f0', + primary400: '#5721d4', + primary500: '#3e04c3', + primary700: '#2d0689', + primary800: '#200364', + accent500: '#f7bc0c', + error50: '#fcc4e4', + error500: '#9b095c', + gray500: '#39324a', + gray700: '#221c30', + }, +}; diff --git a/attachments/10-http/00-starting-project/package.json b/attachments/10-http/00-starting-project/package.json new file mode 100644 index 00000000..58970c6b --- /dev/null +++ b/attachments/10-http/00-starting-project/package.json @@ -0,0 +1,30 @@ +{ + "name": "rncourse", + "version": "1.0.0", + "main": "node_modules/expo/AppEntry.js", + "scripts": { + "start": "expo start", + "android": "expo start --android", + "ios": "expo start --ios", + "web": "expo start --web", + "eject": "expo eject" + }, + "dependencies": { + "@react-navigation/bottom-tabs": "^6.2.0", + "@react-navigation/native": "^6.0.8", + "@react-navigation/native-stack": "^6.5.0", + "axios": "^1.13.2", + "expo": "^52.0.0", + "expo-status-bar": "~2.0.1", + "react": "18.3.1", + "react-dom": "18.3.1", + "react-native": "0.76.7", + "react-native-safe-area-context": "4.12.0", + "react-native-screens": "~4.4.0", + "react-native-web": "~0.19.13" + }, + "devDependencies": { + "@babel/core": "^7.25.2" + }, + "private": true +} diff --git a/attachments/10-http/00-starting-project/screens/AllExpenses.js b/attachments/10-http/00-starting-project/screens/AllExpenses.js new file mode 100644 index 00000000..7cb8f019 --- /dev/null +++ b/attachments/10-http/00-starting-project/screens/AllExpenses.js @@ -0,0 +1,18 @@ +import { useContext } from 'react'; + +import ExpensesOutput from '../components/ExpensesOutput/ExpensesOutput'; +import { ExpensesContext } from '../store/expenses-context'; + +function AllExpenses() { + const expensesCtx = useContext(ExpensesContext); + + return ( + + ); +} + +export default AllExpenses; diff --git a/attachments/10-http/00-starting-project/screens/ManageExpense.js b/attachments/10-http/00-starting-project/screens/ManageExpense.js new file mode 100644 index 00000000..9d6ea805 --- /dev/null +++ b/attachments/10-http/00-starting-project/screens/ManageExpense.js @@ -0,0 +1,113 @@ +import { useContext, useLayoutEffect, useState } from 'react'; +import { StyleSheet, TextInput, View } from 'react-native'; + +import ExpenseForm from '../components/ManageExpense/ExpenseForm'; +import Button from '../components/UI/Button'; +import IconButton from '../components/UI/IconButton'; +import { GlobalStyles } from '../constants/styles'; +import { ExpensesContext } from '../store/expenses-context'; +import { deleteExpense, storeExpense, updateExpense } from '../util/http'; +import LoadingOverlay from '../components/UI/LoadingOverlay'; +import ErrorOverlay from '../components/UI/ErrorOverlay'; + +function ManageExpense({ route, navigation }) { + const [isSubmitting, setIsSubmitting] = useState(false); + const [error, setError] = useState(); + const expensesCtx = useContext(ExpensesContext); + + const editedExpenseId = route.params?.expenseId; + const isEditing = !!editedExpenseId; + + const selectedExpense = expensesCtx.expenses.find( + (expense) => expense.id === editedExpenseId + ); + + useLayoutEffect(() => { + navigation.setOptions({ + title: isEditing ? 'Edit Expense' : 'Add Expense' + }); + }, [navigation, isEditing]); + + async function deleteExpenseHandler() { + setIsSubmitting(true); + + try { + await deleteExpense(editedExpenseId); + expensesCtx.deleteExpense(editedExpenseId); + + navigation.goBack(); + } catch (error) { + setError('Could not delete expense - please try again later'); + setIsSubmitting(false); + } + } + + function cancelHandler() { + navigation.goBack(); + } + + async function confirmHandler(expenseData) { + setIsSubmitting(true); + + try { + if (isEditing) { + expensesCtx.updateExpense(editedExpenseId, expenseData); + + await updateExpense(expenseId, expenseData); + } else { + const id = await storeExpense(expenseData); + expensesCtx.addExpense({ ...expenseData, id: id }); + } + navigation.goBack(); + } catch (error) { + setError('Could not save data - please try again later!'); + // setIsSubmitting(false); + } + } + + if (error && !isSubmitting) { + return ; + } + + if (isSubmitting) { + return ; + } + + return ( + + + {isEditing && ( + + + + )} + + ); +} + +export default ManageExpense; + +const styles = StyleSheet.create({ + container: { + flex: 1, + padding: 24, + backgroundColor: GlobalStyles.colors.primary800 + }, + deleteContainer: { + marginTop: 16, + paddingTop: 8, + borderTopWidth: 2, + borderTopColor: GlobalStyles.colors.primary200, + alignItems: 'center' + } +}); diff --git a/attachments/10-http/00-starting-project/screens/RecentExpenses.js b/attachments/10-http/00-starting-project/screens/RecentExpenses.js new file mode 100644 index 00000000..5b315acd --- /dev/null +++ b/attachments/10-http/00-starting-project/screens/RecentExpenses.js @@ -0,0 +1,55 @@ +import { useContext, useEffect, useState } from 'react'; + +import ExpensesOutput from '../components/ExpensesOutput/ExpensesOutput'; +import { ExpensesContext } from '../store/expenses-context'; +import { getDateMinusDays } from '../util/date'; +import { fetchExpenses } from '../util/http'; +import LoadingOverlay from '../components/UI/LoadingOverlay'; +import ErrorOverlay from '../components/UI/ErrorOverlay'; + +function RecentExpenses() { + const [isFetching, setIsFetching] = useState(true); // true because when this component is loading for the first time we are fetching data + const [error, setError] = useState(); + const expensesCtx = useContext(ExpensesContext); + + useEffect(() => { + async function getExpenses() { + setIsFetching(true); + try { + const expenses = await fetchExpenses(); + expensesCtx.setExpenses(expenses); + } catch (error) { + setError('Could not fetch expenses!'); + } + + setIsFetching(false); + } + + getExpenses(); + }, []); + + if (error && !isFetching) { + return ; + } + + if (isFetching) { + return ; + } + + const recentExpenses = expensesCtx.expenses.filter((expense) => { + const today = new Date(); + const date7DaysAgo = getDateMinusDays(today, 7); + + return expense.date >= date7DaysAgo && expense.date <= today; + }); + + return ( + + ); +} + +export default RecentExpenses; diff --git a/attachments/10-http/00-starting-project/store/expenses-context.js b/attachments/10-http/00-starting-project/store/expenses-context.js new file mode 100644 index 00000000..7948e6cd --- /dev/null +++ b/attachments/10-http/00-starting-project/store/expenses-context.js @@ -0,0 +1,68 @@ +import { createContext, useReducer } from 'react'; + +export const ExpensesContext = createContext({ + expenses: [], + addExpense: ({ description, amount, date }) => {}, + setExpenses: (expenses) => {}, + deleteExpense: (id) => {}, + updateExpense: (id, { description, amount, date }) => {} +}); + +function expensesReducer(state, action) { + switch (action.type) { + case 'ADD': + return [{ ...action.payload }, ...state]; + case 'SET': + const inverted = action.payload.reverse(); + return inverted; + case 'UPDATE': + const updatableExpenseIndex = state.findIndex( + (expense) => expense.id === action.payload.id + ); + const updatableExpense = state[updatableExpenseIndex]; + const updatedItem = { ...updatableExpense, ...action.payload.data }; + const updatedExpenses = [...state]; + updatedExpenses[updatableExpenseIndex] = updatedItem; + return updatedExpenses; + case 'DELETE': + return state.filter((expense) => expense.id !== action.payload); + default: + return state; + } +} + +function ExpensesContextProvider({ children }) { + const [expensesState, dispatch] = useReducer(expensesReducer, []); + + function addExpense(expenseData) { + dispatch({ type: 'ADD', payload: expenseData }); + } + + function setExpenses(expenses) { + dispatch({ type: 'SET', payload: expenses }); + } + + function deleteExpense(id) { + dispatch({ type: 'DELETE', payload: id }); + } + + function updateExpense(id, expenseData) { + dispatch({ type: 'UPDATE', payload: { id: id, data: expenseData } }); + } + + const value = { + expenses: expensesState, + addExpense: addExpense, + setExpenses: setExpenses, + deleteExpense: deleteExpense, + updateExpense: updateExpense + }; + + return ( + + {children} + + ); +} + +export default ExpensesContextProvider; diff --git a/attachments/10-http/00-starting-project/util/date.js b/attachments/10-http/00-starting-project/util/date.js new file mode 100644 index 00000000..4abc38c5 --- /dev/null +++ b/attachments/10-http/00-starting-project/util/date.js @@ -0,0 +1,7 @@ +export function getFormattedDate(date) { + return date.toISOString().slice(0, 10); +} + +export function getDateMinusDays(date, days) { + return new Date(date.getFullYear(), date.getMonth(), date.getDate() - days); +} diff --git a/attachments/10-http/00-starting-project/util/http.js b/attachments/10-http/00-starting-project/util/http.js new file mode 100644 index 00000000..c0741250 --- /dev/null +++ b/attachments/10-http/00-starting-project/util/http.js @@ -0,0 +1,40 @@ +import axios from 'axios'; + +const BACKEND_URL = + 'https://react-native-course-e6af0-default-rtdb.europe-west1.firebasedatabase.app'; + +export async function storeExpense(expenseData) { + const response = await axios.post( + BACKEND_URL + '/expenses.json', + expenseData + ); + + // this is the id for the firebase object, id is generated by firebase when we add an expense + const id = response.data.name; + return id; +} + +export async function fetchExpenses() { + const response = await axios.get(BACKEND_URL + '/expenses.json'); + const expenses = []; + + for (const key in response.data) { + const expenseObj = { + id: key, + amount: response.data[key].amount, + date: new Date(response.data[key].date), + description: response.data[key].description + }; + expenses.push(expenseObj); + } + + return expenses; +} + +export function updateExpense(id, expenseData) { + return axios.put(BACKEND_URL + `/expenses/${id}.json`, expenseData); +} + +export function deleteExpense(id) { + return axios.delete(BACKEND_URL + `/expenses/${id}.json`, expenseData); +} diff --git a/attachments/11-auth/00-starting-project/App.js b/attachments/11-auth/00-starting-project/App.js new file mode 100644 index 00000000..c44b5ff0 --- /dev/null +++ b/attachments/11-auth/00-starting-project/App.js @@ -0,0 +1,115 @@ +import { NavigationContainer } from '@react-navigation/native'; +import { createNativeStackNavigator } from '@react-navigation/native-stack'; +import { StatusBar } from 'expo-status-bar'; + +import LoginScreen from './screens/LoginScreen'; +import SignupScreen from './screens/SignupScreen'; +import WelcomeScreen from './screens/WelcomeScreen'; +import { Colors } from './constants/styles'; +import AuthContextProvider, { AuthContext } from './store/auth-context'; +import { useContext, useEffect, useState } from 'react'; + +import IconButton from './components/ui/IconButton'; +import AsyncStorage from '@react-native-async-storage/async-storage'; +import AppLoading from 'expo-app-loading'; + +const Stack = createNativeStackNavigator(); + +function AuthStack() { + return ( + + + + + ); +} +// We can add route protection by simply putting this screen into its own screen setup and only rendering this navigator if a certain condition is met +function AuthenticatedStack() { + const authContext = useContext(AuthContext); + return ( + + ( + + ) + }} + /> + + ); +} + +function Navigation() { + const authContext = useContext(AuthContext); + + let content = ; + + if (authContext.isAuthenticated) { + content = ; + } + + return ( + + {/* */} + {content} + + ); +} + +// We added this use component so we can update our AuthContext when we have fetched the token, we then return the component and use the component in the App function instead of the +// The adventage off this approach is that we can now use the AppLoading component to prolong the loading screen until we're done fetching this token, to do this we need to install the package expo-app-loading (expo install expo-app-loading) +function Root() { + const [isTryingLogin, setIsTryingLogin] = useState(true); + const authContext = useContext(AuthContext); + + useEffect(() => { + async function fetchToken() { + const storedToken = await AsyncStorage.getItem('token'); + + if (storedToken) { + authContext.authenticate(storedToken); + } + + setIsTryingLogin(false); + } + + fetchToken(); + }, []); + + if (isTryingLogin) { + // this (AppLoading) will make sure that the splashscreen is prolonged + return ; + } + + return ; +} + +export default function App() { + return ( + <> + + + + + + ); +} diff --git a/attachments/11-auth/00-starting-project/app.json b/attachments/11-auth/00-starting-project/app.json new file mode 100644 index 00000000..de352e82 --- /dev/null +++ b/attachments/11-auth/00-starting-project/app.json @@ -0,0 +1,32 @@ +{ + "expo": { + "name": "RNCourse", + "slug": "RNCourse", + "version": "1.0.0", + "orientation": "portrait", + "icon": "./assets/icon.png", + "splash": { + "image": "./assets/splash.png", + "resizeMode": "contain", + "backgroundColor": "#ffffff" + }, + "updates": { + "fallbackToCacheTimeout": 0 + }, + "assetBundlePatterns": [ + "**/*" + ], + "ios": { + "supportsTablet": true + }, + "android": { + "adaptiveIcon": { + "foregroundImage": "./assets/adaptive-icon.png", + "backgroundColor": "#FFFFFF" + } + }, + "web": { + "favicon": "./assets/favicon.png" + } + } +} diff --git a/attachments/11-auth/00-starting-project/assets/adaptive-icon.png b/attachments/11-auth/00-starting-project/assets/adaptive-icon.png new file mode 100644 index 00000000..03d6f6b6 Binary files /dev/null and b/attachments/11-auth/00-starting-project/assets/adaptive-icon.png differ diff --git a/attachments/11-auth/00-starting-project/assets/favicon.png b/attachments/11-auth/00-starting-project/assets/favicon.png new file mode 100644 index 00000000..e75f697b Binary files /dev/null and b/attachments/11-auth/00-starting-project/assets/favicon.png differ diff --git a/attachments/11-auth/00-starting-project/assets/icon.png b/attachments/11-auth/00-starting-project/assets/icon.png new file mode 100644 index 00000000..a0b1526f Binary files /dev/null and b/attachments/11-auth/00-starting-project/assets/icon.png differ diff --git a/attachments/11-auth/00-starting-project/assets/splash.png b/attachments/11-auth/00-starting-project/assets/splash.png new file mode 100644 index 00000000..0e89705a Binary files /dev/null and b/attachments/11-auth/00-starting-project/assets/splash.png differ diff --git a/attachments/11-auth/00-starting-project/babel.config.js b/attachments/11-auth/00-starting-project/babel.config.js new file mode 100644 index 00000000..e1babf6b --- /dev/null +++ b/attachments/11-auth/00-starting-project/babel.config.js @@ -0,0 +1,6 @@ +module.exports = function(api) { + api.cache(true); + return { + presets: ['babel-preset-expo'], + }; +}; diff --git a/attachments/11-auth/00-starting-project/components/Auth/AuthContent.js b/attachments/11-auth/00-starting-project/components/Auth/AuthContent.js new file mode 100644 index 00000000..ab5801ef --- /dev/null +++ b/attachments/11-auth/00-starting-project/components/Auth/AuthContent.js @@ -0,0 +1,91 @@ +import { useState } from 'react'; +import { Alert, StyleSheet, View } from 'react-native'; + +import FlatButton from '../ui/FlatButton'; +import AuthForm from './AuthForm'; +import { Colors } from '../../constants/styles'; + +import { useNavigation } from '@react-navigation/native'; + +function AuthContent({ isLogin, onAuthenticate }) { + const navigation = useNavigation(); + + const [credentialsInvalid, setCredentialsInvalid] = useState({ + email: false, + password: false, + confirmEmail: false, + confirmPassword: false + }); + + function switchAuthModeHandler() { + if (isLogin) { + navigation.navigate('Signup'); + // We kunnen ook de replace method gebruiken ipv navigate, dit zorgt ervoor dat er geen back button is! Het voegt geen scherm toe aan de stack of screens maar vervangt het huidige scherm met het nieuwe scherm + } else { + navigation.navigate('Login'); + } + } + + function submitHandler(credentials) { + let { email, confirmEmail, password, confirmPassword } = credentials; + + email = email.trim(); + password = password.trim(); + + const emailIsValid = email.includes('@'); + const passwordIsValid = password.length > 6; + const emailsAreEqual = email === confirmEmail; + const passwordsAreEqual = password === confirmPassword; + + if ( + !emailIsValid || + !passwordIsValid || + (!isLogin && (!emailsAreEqual || !passwordsAreEqual)) + ) { + Alert.alert('Invalid input', 'Please check your entered credentials.'); + setCredentialsInvalid({ + email: !emailIsValid, + confirmEmail: !emailIsValid || !emailsAreEqual, + password: !passwordIsValid, + confirmPassword: !passwordIsValid || !passwordsAreEqual + }); + return; + } + onAuthenticate({ email, password }); + } + + return ( + + + + + {isLogin ? 'Create a new user' : 'Log in instead'} + + + + ); +} + +export default AuthContent; + +const styles = StyleSheet.create({ + authContent: { + marginTop: 64, + marginHorizontal: 32, + padding: 16, + borderRadius: 8, + backgroundColor: Colors.primary800, + elevation: 2, + shadowColor: 'black', + shadowOffset: { width: 1, height: 1 }, + shadowOpacity: 0.35, + shadowRadius: 4 + }, + buttons: { + marginTop: 8 + } +}); diff --git a/attachments/11-auth/00-starting-project/components/Auth/AuthForm.js b/attachments/11-auth/00-starting-project/components/Auth/AuthForm.js new file mode 100644 index 00000000..02979775 --- /dev/null +++ b/attachments/11-auth/00-starting-project/components/Auth/AuthForm.js @@ -0,0 +1,100 @@ +import { useState } from 'react'; +import { StyleSheet, View } from 'react-native'; + +import Button from '../ui/Button'; +import Input from './Input'; + +function AuthForm({ isLogin, onSubmit, credentialsInvalid }) { + const [enteredEmail, setEnteredEmail] = useState(''); + const [enteredConfirmEmail, setEnteredConfirmEmail] = useState(''); + const [enteredPassword, setEnteredPassword] = useState(''); + const [enteredConfirmPassword, setEnteredConfirmPassword] = useState(''); + + const { + email: emailIsInvalid, + confirmEmail: emailsDontMatch, + password: passwordIsInvalid, + confirmPassword: passwordsDontMatch, + } = credentialsInvalid; + + function updateInputValueHandler(inputType, enteredValue) { + switch (inputType) { + case 'email': + setEnteredEmail(enteredValue); + break; + case 'confirmEmail': + setEnteredConfirmEmail(enteredValue); + break; + case 'password': + setEnteredPassword(enteredValue); + break; + case 'confirmPassword': + setEnteredConfirmPassword(enteredValue); + break; + } + } + + function submitHandler() { + onSubmit({ + email: enteredEmail, + confirmEmail: enteredConfirmEmail, + password: enteredPassword, + confirmPassword: enteredConfirmPassword, + }); + } + + return ( + + + + {!isLogin && ( + + )} + + {!isLogin && ( + + )} + + + + + + ); +} + +export default AuthForm; + +const styles = StyleSheet.create({ + buttons: { + marginTop: 12, + }, +}); diff --git a/attachments/11-auth/00-starting-project/components/Auth/Input.js b/attachments/11-auth/00-starting-project/components/Auth/Input.js new file mode 100644 index 00000000..1de9878e --- /dev/null +++ b/attachments/11-auth/00-starting-project/components/Auth/Input.js @@ -0,0 +1,53 @@ +import { View, Text, TextInput, StyleSheet } from 'react-native'; + +import { Colors } from '../../constants/styles'; + +function Input({ + label, + keyboardType, + secure, + onUpdateValue, + value, + isInvalid +}) { + return ( + + + {label} + + + + ); +} + +export default Input; + +const styles = StyleSheet.create({ + inputContainer: { + marginVertical: 8 + }, + label: { + color: 'white', + marginBottom: 4 + }, + labelInvalid: { + color: Colors.error500 + }, + input: { + paddingVertical: 8, + paddingHorizontal: 6, + backgroundColor: Colors.primary100, + borderRadius: 4, + fontSize: 16 + }, + inputInvalid: { + backgroundColor: Colors.error100 + } +}); diff --git a/attachments/11-auth/00-starting-project/components/ui/Button.js b/attachments/11-auth/00-starting-project/components/ui/Button.js new file mode 100644 index 00000000..7dc391dd --- /dev/null +++ b/attachments/11-auth/00-starting-project/components/ui/Button.js @@ -0,0 +1,41 @@ +import { Pressable, StyleSheet, Text, View } from 'react-native'; + +import { Colors } from '../../constants/styles'; + +function Button({ children, onPress }) { + return ( + [styles.button, pressed && styles.pressed]} + onPress={onPress} + > + + {children} + + + ); +} + +export default Button; + +const styles = StyleSheet.create({ + button: { + borderRadius: 6, + paddingVertical: 6, + paddingHorizontal: 12, + backgroundColor: Colors.primary500, + elevation: 2, + shadowColor: 'black', + shadowOffset: { width: 1, height: 1 }, + shadowOpacity: 0.25, + shadowRadius: 4, + }, + pressed: { + opacity: 0.7, + }, + buttonText: { + textAlign: 'center', + color: 'white', + fontSize: 16, + fontWeight: 'bold' + }, +}); diff --git a/attachments/11-auth/00-starting-project/components/ui/FlatButton.js b/attachments/11-auth/00-starting-project/components/ui/FlatButton.js new file mode 100644 index 00000000..4aad4287 --- /dev/null +++ b/attachments/11-auth/00-starting-project/components/ui/FlatButton.js @@ -0,0 +1,32 @@ +import { Pressable, StyleSheet, Text, View } from 'react-native'; + +import { Colors } from '../../constants/styles'; + +function FlatButton({ children, onPress }) { + return ( + [styles.button, pressed && styles.pressed]} + onPress={onPress} + > + + {children} + + + ); +} + +export default FlatButton; + +const styles = StyleSheet.create({ + button: { + paddingVertical: 6, + paddingHorizontal: 12, + }, + pressed: { + opacity: 0.7, + }, + buttonText: { + textAlign: 'center', + color: Colors.primary100, + }, +}); diff --git a/attachments/11-auth/00-starting-project/components/ui/IconButton.js b/attachments/11-auth/00-starting-project/components/ui/IconButton.js new file mode 100644 index 00000000..3cdf769b --- /dev/null +++ b/attachments/11-auth/00-starting-project/components/ui/IconButton.js @@ -0,0 +1,25 @@ +import { Pressable, StyleSheet } from 'react-native'; +import { Ionicons } from '@expo/vector-icons'; + +function IconButton({ icon, color, size, onPress }) { + return ( + [styles.button, pressed && styles.pressed]} + onPress={onPress} + > + + + ); +} + +export default IconButton; + +const styles = StyleSheet.create({ + button: { + margin: 8, + borderRadius: 20, + }, + pressed: { + opacity: 0.7, + }, +}); diff --git a/attachments/11-auth/00-starting-project/components/ui/LoadingOverlay.js b/attachments/11-auth/00-starting-project/components/ui/LoadingOverlay.js new file mode 100644 index 00000000..2d8a87ae --- /dev/null +++ b/attachments/11-auth/00-starting-project/components/ui/LoadingOverlay.js @@ -0,0 +1,25 @@ +import { ActivityIndicator, StyleSheet, Text, View } from 'react-native'; + +function LoadingOverlay({ message }) { + return ( + + {message} + + + ); +} + +export default LoadingOverlay; + +const styles = StyleSheet.create({ + rootContainer: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + padding: 32, + }, + message: { + fontSize: 16, + marginBottom: 12, + }, +}); diff --git a/attachments/11-auth/00-starting-project/constants/styles.js b/attachments/11-auth/00-starting-project/constants/styles.js new file mode 100644 index 00000000..cca02d18 --- /dev/null +++ b/attachments/11-auth/00-starting-project/constants/styles.js @@ -0,0 +1,7 @@ +export const Colors = { + primary100: '#f9beda', + primary500: '#c30b64', + primary800: '#610440', + error100: '#fcdcbf', + error500: '#f37c13', +} \ No newline at end of file diff --git a/attachments/11-auth/00-starting-project/package.json b/attachments/11-auth/00-starting-project/package.json new file mode 100644 index 00000000..3c1a8b19 --- /dev/null +++ b/attachments/11-auth/00-starting-project/package.json @@ -0,0 +1,31 @@ +{ + "name": "rncourse", + "version": "1.0.0", + "main": "node_modules/expo/AppEntry.js", + "scripts": { + "start": "expo start", + "android": "expo start --android", + "ios": "expo start --ios", + "web": "expo start --web", + "eject": "expo eject" + }, + "dependencies": { + "@react-native-async-storage/async-storage": "^2.2.0", + "@react-navigation/native": "^6.0.8", + "@react-navigation/native-stack": "^6.5.0", + "axios": "^1.13.2", + "expo": "^52.0.0", + "expo-app-loading": "^2.1.1", + "expo-status-bar": "~2.0.1", + "react": "18.3.1", + "react-dom": "18.3.1", + "react-native": "0.76.7", + "react-native-safe-area-context": "4.12.0", + "react-native-screens": "~4.4.0", + "react-native-web": "~0.19.13" + }, + "devDependencies": { + "@babel/core": "^7.25.2" + }, + "private": true +} diff --git a/attachments/11-auth/00-starting-project/screens/LoginScreen.js b/attachments/11-auth/00-starting-project/screens/LoginScreen.js new file mode 100644 index 00000000..ce7de1ad --- /dev/null +++ b/attachments/11-auth/00-starting-project/screens/LoginScreen.js @@ -0,0 +1,35 @@ +import { useContext, useState } from 'react'; +import AuthContent from '../components/Auth/AuthContent'; +import LoadingOverlay from '../components/ui/LoadingOverlay'; +import { login } from '../util/Auth'; +import { Alert } from 'react-native'; +import { AuthContext } from '../store/auth-context'; + +// import { login } from '../util/auth'; + +function LoginScreen() { + const [isAuthenticating, setIsAuthenticating] = useState(false); + const authContext = useContext(AuthContext); + + async function loginHandler({ email, passwword }) { + setIsAuthenticating(true); + try { + const token = await login(email, passwword); + authContext.authenticate(token); + } catch { + Alert.alert( + 'Authentication failed!', + 'Could not log you in. Please check your credentials or try again later!' + ); + setIsAuthenticating(false); + } + } + + if (isAuthenticating) { + return ; + } + + return ; +} + +export default LoginScreen; diff --git a/attachments/11-auth/00-starting-project/screens/SignupScreen.js b/attachments/11-auth/00-starting-project/screens/SignupScreen.js new file mode 100644 index 00000000..bdd96045 --- /dev/null +++ b/attachments/11-auth/00-starting-project/screens/SignupScreen.js @@ -0,0 +1,33 @@ +import { useContext, useState } from 'react'; +import { Alert } from 'react-native'; +import { createUser } from '../util/Auth'; +import LoadingOverlay from '../components/ui/LoadingOverlay'; +import AuthContent from '../components/Auth/AuthContent'; +import { AuthContext } from '../store/auth-context'; + +function SignupScreen({ email, password }) { + const [isAuthenticating, setIsAuthenticating] = useState(false); + const authContext = useContext(AuthContext); + + async function signupHandler() { + setIsAuthenticating(true); + try { + const token = await createUser(email, password); + authContext.authenticate(token); + } catch (error) { + Alert.alert( + 'Authentication failed!', + 'Could not create user, please your input and try again later.' + ); + setIsAuthenticating(false); + } + } + + if (isAuthenticating) { + return ; + } + + return ; +} + +export default SignupScreen; diff --git a/attachments/11-auth/00-starting-project/screens/WelcomeScreen.js b/attachments/11-auth/00-starting-project/screens/WelcomeScreen.js new file mode 100644 index 00000000..2c5ab201 --- /dev/null +++ b/attachments/11-auth/00-starting-project/screens/WelcomeScreen.js @@ -0,0 +1,46 @@ +import { StyleSheet, Text, View } from 'react-native'; +import axios from 'axios'; +import { useContext, useState } from 'react'; +import { AuthContext } from '../store/auth-context'; + +function WelcomeScreen() { + const [fetchedMessage, setFetchedMessage] = useState(''); + + const authContext = useContext(AuthContext); + const token = authContext.token; + + useEffect(() => { + axios + .get( + `https://react-native-course-e6af0-default-rtdb.europe-west1.firebasedatabase.app/message.json?auth=${token}` + ) + .then((response) => { + console.log(response.data); + setFetchedMessage(response.data); + }); + }, [token]); + + return ( + + Welcome! + You authenticated successfully! + {fetchedMessage} + + ); +} + +export default WelcomeScreen; + +const styles = StyleSheet.create({ + rootContainer: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + padding: 32 + }, + title: { + fontSize: 20, + fontWeight: 'bold', + marginBottom: 8 + } +}); diff --git a/attachments/11-auth/00-starting-project/store/auth-context.js b/attachments/11-auth/00-starting-project/store/auth-context.js new file mode 100644 index 00000000..bfcb988f --- /dev/null +++ b/attachments/11-auth/00-starting-project/store/auth-context.js @@ -0,0 +1,33 @@ +import { createContext, useEffect, useState } from 'react'; +import AsyncStorage from '@react-native-async-storage/async-storage'; + +export const AuthContext = createContext({ + token: '', + isAuthenticated: false, + authenticate: (token) => {}, + logout: () => {} +}); + +export default function AuthContextProvider({ children }) { + const [authToken, setAuthToken] = useState(); + + function authenticate(token) { + setAuthToken(token); + // the value (in this case token) should always be a string, if it's a number it should be converted to a string first, an object could be converted to JSON (which is a string). + // Using AsyncStorage we can use the token when the app loads up to see if we have a stored token + AsyncStorage.setItem('token', token); + } + + function logout() { + setAuthToken(null); + AsyncStorage.removeItem('token'); + } + + const value = { + token: authToken, + isAuthenticated: !!authToken, + authenticate: authenticate, + logout: logout + }; + return {children}; +} diff --git a/attachments/11-auth/00-starting-project/util/Auth.js b/attachments/11-auth/00-starting-project/util/Auth.js new file mode 100644 index 00000000..ead9b811 --- /dev/null +++ b/attachments/11-auth/00-starting-project/util/Auth.js @@ -0,0 +1,27 @@ +import axios from 'axios'; + +const API_KEY = ''; + +async function authenticate(mode, email, password) { + const url = `https://identitytoolkit.googleapis.com/v1/accounts:${mode}?key=${API_KEY}`; + + const response = await axios.post(url, { + email: email, + password: password, + returnSecureToken: true + }); + + console.log(response.data); + + const token = response.data.idToken; + + return token; +} + +export async function createUser(email, password) { + return authenticate('signUp', email, password); +} + +export async function login(email, password) { + return authenticate('signInWithPassword', email, password); +} diff --git a/attachments/12-native-features/00-starting-project/App.js b/attachments/12-native-features/00-starting-project/App.js new file mode 100644 index 00000000..8c317101 --- /dev/null +++ b/attachments/12-native-features/00-starting-project/App.js @@ -0,0 +1,90 @@ +import { NavigationContainer } from '@react-navigation/native'; +import { createNativeStackNavigator } from '@react-navigation/native-stack'; +import { StatusBar } from 'expo-status-bar'; +import { StyleSheet } from 'react-native'; +import AllPlaces from './screens/AllPlaces'; +import AddPlace from './screens/AddPlace'; +import IconButton from './components/UI/IconButton'; +import { Colors } from './constants/colors'; +import Map from './screens/Map'; +import { useEffect, useState } from 'react'; +import { init } from './util/database'; +import AppLoading from 'expo-app-loading'; +import PlaceDetails from './screens/PlaceDetails'; + +const Stack = createNativeStackNavigator(); + +export default function App() { + const [dbInitialized, setDbInitialized] = useState(false); + useEffect(() => { + async function initDatabase() { + try { + await init(); + console.log('Databse initialized'); + + setDbInitialized(true); + } catch (error) { + console.log('Failed to initialize db with error: ', error); + } + } + + initDatabase(); + }, []); + + if (!dbInitialized) { + return ; + } + + return ( + <> + + + + ({ + title: 'Your Favorite Places', + headerRight: ({ tintColor }) => ( + navigation.navigate('AddPlace')} + /> + ) + })} + /> + + + + + + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: '#fff', + alignItems: 'center', + justifyContent: 'center' + } +}); diff --git a/attachments/12-native-features/00-starting-project/Models/place.js b/attachments/12-native-features/00-starting-project/Models/place.js new file mode 100644 index 00000000..a6fe6933 --- /dev/null +++ b/attachments/12-native-features/00-starting-project/Models/place.js @@ -0,0 +1,9 @@ +export class Place { + constructor(title, imageUri, location, id) { + this.title = title; + this.imageUri = imageUri; + this.address = location.address; + this.location = { lat: location.lat, lng: location.lng }; // { lat: 0.141241, lng: 127.121 } + this.id = id; // new Date().toString() + Math.random().toString(); // normally this would come from a BE but as we don't have one we create an id ourselves + } +} diff --git a/attachments/12-native-features/00-starting-project/app.json b/attachments/12-native-features/00-starting-project/app.json new file mode 100644 index 00000000..5b5aea46 --- /dev/null +++ b/attachments/12-native-features/00-starting-project/app.json @@ -0,0 +1,41 @@ +{ + "expo": { + "name": "RNCourse", + "slug": "RNCourse", + "version": "1.0.0", + "orientation": "portrait", + "icon": "./assets/icon.png", + "splash": { + "image": "./assets/splash.png", + "resizeMode": "contain", + "backgroundColor": "#ffffff" + }, + "updates": { + "fallbackToCacheTimeout": 0 + }, + "assetBundlePatterns": [ + "**/*" + ], + "ios": { + "supportsTablet": true + }, + "android": { + "adaptiveIcon": { + "foregroundImage": "./assets/adaptive-icon.png", + "backgroundColor": "#FFFFFF" + } + }, + "web": { + "favicon": "./assets/favicon.png" + }, + "plugins": [ + [ + "expo-image-picker", + { + "cameraPermission": "The app needs access to your camera in order to take photos of your favorite places." + } + ], + "expo-sqlite" + ] + } +} diff --git a/attachments/12-native-features/00-starting-project/assets/adaptive-icon.png b/attachments/12-native-features/00-starting-project/assets/adaptive-icon.png new file mode 100644 index 00000000..03d6f6b6 Binary files /dev/null and b/attachments/12-native-features/00-starting-project/assets/adaptive-icon.png differ diff --git a/attachments/12-native-features/00-starting-project/assets/favicon.png b/attachments/12-native-features/00-starting-project/assets/favicon.png new file mode 100644 index 00000000..e75f697b Binary files /dev/null and b/attachments/12-native-features/00-starting-project/assets/favicon.png differ diff --git a/attachments/12-native-features/00-starting-project/assets/icon.png b/attachments/12-native-features/00-starting-project/assets/icon.png new file mode 100644 index 00000000..a0b1526f Binary files /dev/null and b/attachments/12-native-features/00-starting-project/assets/icon.png differ diff --git a/attachments/12-native-features/00-starting-project/assets/splash.png b/attachments/12-native-features/00-starting-project/assets/splash.png new file mode 100644 index 00000000..0e89705a Binary files /dev/null and b/attachments/12-native-features/00-starting-project/assets/splash.png differ diff --git a/attachments/12-native-features/00-starting-project/babel.config.js b/attachments/12-native-features/00-starting-project/babel.config.js new file mode 100644 index 00000000..e1babf6b --- /dev/null +++ b/attachments/12-native-features/00-starting-project/babel.config.js @@ -0,0 +1,6 @@ +module.exports = function(api) { + api.cache(true); + return { + presets: ['babel-preset-expo'], + }; +}; diff --git a/attachments/12-native-features/00-starting-project/components/Places/ImagePicker.js b/attachments/12-native-features/00-starting-project/components/Places/ImagePicker.js new file mode 100644 index 00000000..7931b17b --- /dev/null +++ b/attachments/12-native-features/00-starting-project/components/Places/ImagePicker.js @@ -0,0 +1,85 @@ +import { Alert, Image, StyleSheet, Text, View } from 'react-native'; +import { + launchCameraAsync, + useCameraPermissions, + PermissionStatus +} from 'expo-image-picker'; +import { useState } from 'react'; +import { Colors } from '../../constants/colors'; +import OutlineButton from '../UI/OutlineButton'; + +function ImagePicker({ onTakeImage }) { + const [pickedImage, setPickedImage] = useState(); + const [cameraPermissionInformation, requestPermission] = + useCameraPermissions(); + + async function verifyPermissions() { + if (cameraPermissionInformation.status === PermissionStatus.UNDETERMINED) { + // request permisison, user will give the permission or decline it + const permissionResponse = await requestPermission(); + + return permissionResponse.granted; // true or false + } + + if (cameraPermissionInformation.status === PermissionStatus.DENIED) { + Alert.alert( + 'Insufficient Permissions', + 'You need to grant camera permisions to use this app.' + ); + return false; + } + + return true; + } + + async function takeImageHandler() { + const hasPermission = await verifyPermissions(); + + if (!hasPermission) { + return; + } + + const image = await launchCameraAsync({ + allowsEditing: true, + aspect: [16, 9], + quality: 0.5 + }); + + setPickedImage(image.uri); + onTakeImage(image.uri); + } + + let imagePreview = No image taken yet.; + + if (pickedImage) { + imagePreview = ; + } + + return ( + + {imagePreview} + + + Take Image + + + ); +} + +export default ImagePicker; + +const styles = StyleSheet.create({ + imagePreview: { + width: '100%', + height: 200, + marginVertical: 8, + justifyContent: 'center', + alignItems: 'center', + backgroundColor: Colors.primary100, + borderRadius: 4 + }, + image: { + width: '100%', + height: '100%' + } +}); diff --git a/attachments/12-native-features/00-starting-project/components/Places/LocationPicker.js b/attachments/12-native-features/00-starting-project/components/Places/LocationPicker.js new file mode 100644 index 00000000..f5e6682d --- /dev/null +++ b/attachments/12-native-features/00-starting-project/components/Places/LocationPicker.js @@ -0,0 +1,142 @@ +import { Alert, Image, StyleSheet, Text, View } from 'react-native'; +import OutlineButton from '../UI/OutlineButton'; +import { Colors } from '../../constants/colors'; +import { + getCurrentPositionAsync, + useForegroundPermissions, + PermissionStatus +} from 'expo-location'; +import { useEffect, useState } from 'react'; +import { getAddress, getMapPreview } from '../../util/location'; +import { + useNavigation, + useRoute, + useIsFocused +} from '@react-navigation/native'; + +function LocationPicker({ onPickLocation }) { + const [pickedLocation, setPickedLocation] = useState(); + const isFocused = useIsFocused(); + + const navigation = useNavigation(); + const route = useRoute(); + + const [locationPermissionInformation, requestPermission] = + useForegroundPermissions(); + + useEffect(() => { + if (isFocused && route.params) { + const mapPickedLocation = route.params && { + lat: route.params.pickedLat, + lng: route.params.pickedLng + }; + setPickedLocation(mapPickedLocation); + } + }, [route, isFocused]); + + useEffect(() => { + async function handleLocation() { + if (pickedLocation) { + const address = await getAddress( + pickedLocation.lat, + pickedLocation.lng + ); + onPickLocation({ ...pickedLocation, address: address }); + } + } + + handleLocation(); + }, [pickedLocation, onPickLocation]); + + async function verifyPermissions() { + if ( + locationPermissionInformation.status === PermissionStatus.UNDETERMINED + ) { + // request permisison, user will give the permission or decline it + const permissionResponse = await requestPermission(); + + return permissionResponse.granted; // true or false + } + + if (locationPermissionInformation.status === PermissionStatus.DENIED) { + Alert.alert( + 'Insufficient Permissions', + 'You need to grant camera permisions to use this app.' + ); + return false; + } + + return true; + } + + async function getLocationHandler() { + const hasPermission = await verifyPermissions(); + + if (!hasPermission) { + return; + } + + const location = await getCurrentPositionAsync(); + + setPickedLocation({ + lat: location.coords.latitude, + lng: location.coords.longitude + }); + } + + function pickOnMapHandler() { + navigation.navigate('Map'); + } + + let locationPreview = No location picked yet.; + + if (pickedLocation) { + locationPreview = ( + + ); + } + + return ( + + {locationPreview} + + + Locate User + + + Pick on Map + + + + ); +} + +export default LocationPicker; + +const styles = StyleSheet.create({ + mapPreview: { + width: '100%', + height: 200, + marginVertical: 8, + justifyContent: 'center', + alignItems: 'center', + backgroundColor: Colors.primary100, + borderRadius: 4, + overflow: 'hidden' + }, + actions: { + flexDirection: 'row', + justifyContent: 'space-around', + alignItems: 'center' + }, + image: { + width: '100%', + height: '100%', + borderRadius: 4 + } +}); diff --git a/attachments/12-native-features/00-starting-project/components/Places/PlaceForm.js b/attachments/12-native-features/00-starting-project/components/Places/PlaceForm.js new file mode 100644 index 00000000..25fac860 --- /dev/null +++ b/attachments/12-native-features/00-starting-project/components/Places/PlaceForm.js @@ -0,0 +1,75 @@ +import { useCallback, useState } from 'react'; +import { ScrollView, Text, TextInput, View, StyleSheet } from 'react-native'; +import { Colors } from '../../constants/colors'; +import ImagePicker from './ImagePicker'; +import LocationPicker from './LocationPicker'; +import Button from '../UI/Button'; +import { Place } from '../../Models/place'; + +function PlaceForm({ onCreatePlace }) { + const [enteredTitle, setEnteredTitle] = useState(''); + const [pickedLocation, setPickedLocation] = useState(); + const [selectImage, setSelectedImage] = useState(); + + function changeTitleHandler(enteredText) { + console.log('changeTitleHanlder, enteredText: ', enteredText); + + setEnteredTitle(enteredText); + } + + function imageTakenHandler(imageUri) { + setSelectedImage(imageUri); + } + + const pickLocationHandler = useCallback((location) => { + setPickedLocation(location); + }, []); + + function savePlaceHandler() { + const placeData = new Place(enteredTitle, selectImage, pickedLocation); + + onCreatePlace(placeData); + } + + return ( + + + TITLE + + + + + + + + + + ); +} + +export default PlaceForm; + +const styles = StyleSheet.create({ + form: { + flex: 1, + padding: 24 + }, + label: { + fontWeight: 'bold', + marginBottom: 4, + color: Colors.primary500 + }, + input: { + marginVertical: 8, + paddingHorizontal: 4, + paddingVertical: 8, + fontSize: 16, + borderBottomColor: Colors.primary700, + borderBottomWidth: 2, + backgroundColor: Colors.primary100 + } +}); diff --git a/attachments/12-native-features/00-starting-project/components/Places/PlaceItem.js b/attachments/12-native-features/00-starting-project/components/Places/PlaceItem.js new file mode 100644 index 00000000..28333a84 --- /dev/null +++ b/attachments/12-native-features/00-starting-project/components/Places/PlaceItem.js @@ -0,0 +1,56 @@ +import { Image, Pressable, StyleSheet, View } from 'react-native'; +import { Colors } from '../../constants/colors'; + +function PlaceItem({ place, onSelect }) { + return ( + [styles.item, pressed && styles.pressed]} + onPress={onSelect.bind(this, place.id)} + > + + + {place.title} + place.address + + + ); +} + +export default PlaceItem; + +const styles = StyleSheet.create({ + item: { + flexDirection: 'row', + alignItems: 'flex-start', + borderRadius: 6, + marginVertical: 12, + backgroundColor: Colors.primary500, + elevation: 2, + shadowColor: 'black', + shadowOpacity: 0.15, + shadowOffset: { width: 1, height: 1 }, + shadowRadius: 2 + }, + pressed: { + opacity: 0.9 + }, + image: { + flex: 1, + borderBottomLeftRadius: 4, + borderTopLeftRadius: 4, + height: 100 + }, + info: { + flex: 2, // make this twice as big as the image (2/3 of the availble space) + padding: 12 + }, + title: { + fontWeight: 'bold', + fontSize: 18, + color: Colors.gray700 + }, + address: { + fontSize: 12, + color: Colors.gray700 + } +}); diff --git a/attachments/12-native-features/00-starting-project/components/Places/PlacesList.js b/attachments/12-native-features/00-starting-project/components/Places/PlacesList.js new file mode 100644 index 00000000..b07f76a8 --- /dev/null +++ b/attachments/12-native-features/00-starting-project/components/Places/PlacesList.js @@ -0,0 +1,51 @@ +import { FlatList, StyleSheet, Text, View } from 'react-native'; +import PlaceItem from './PlaceItem'; +import { Colors } from '../../constants/colors'; +import { useNavigation } from '@react-navigation/native'; + +function PlacesList({ places }) { + const navigation = useNavigation(); + + function selectPlaceHandler(id) { + navigation.navigate('PlaceDetails', { + placeId: id + }); + } + + if (!places || places.length === 0) { + return ( + + + No places added yet - start adding some! + + + ); + } + return ( + item.id} + renderItem={({ item }) => ( + + )} + > + ); +} + +export default PlacesList; + +const styles = StyleSheet.create({ + list: { + margin: 24 + }, + fallbackContainer: { + flex: 1, + justifyContent: 'center', + alignItems: 'center' + }, + fallbackText: { + fontSize: 16, + color: Colors.primary200 + } +}); diff --git a/attachments/12-native-features/00-starting-project/components/UI/Button.js b/attachments/12-native-features/00-starting-project/components/UI/Button.js new file mode 100644 index 00000000..32e8928c --- /dev/null +++ b/attachments/12-native-features/00-starting-project/components/UI/Button.js @@ -0,0 +1,37 @@ +import { Pressable, StyleSheet, Text } from 'react-native'; +import { Colors } from '../../constants/colors'; + +function Button({ onPress, children }) { + return ( + [styles.button, pressed && styles.pressed]} + onPress={onPress} + > + {children} + + ); +} + +export default Button; +const styles = StyleSheet.create({ + button: { + paddingHorizontal: 12, + paddingVertical: 8, + margin: 4, + backgroundColor: Colors.primary800, + elevation: 2, + shadowColor: 'black', + shadowOpacity: 0.15, + shadowOffset: { width: 1, height: 1 }, + shadowRadius: 2, + borderRadius: 4 + }, + pressed: { + opacity: 0.7 + }, + text: { + textAlign: 'center', + fontSize: 16, + color: Colors.primary50 + } +}); diff --git a/attachments/12-native-features/00-starting-project/components/UI/IconButton.js b/attachments/12-native-features/00-starting-project/components/UI/IconButton.js new file mode 100644 index 00000000..20591371 --- /dev/null +++ b/attachments/12-native-features/00-starting-project/components/UI/IconButton.js @@ -0,0 +1,26 @@ +import { Pressable, StyleSheet } from 'react-native'; +import { Ionicons } from '@expo/vector-icons'; + +function IconButton({ icon, size, color, onPress }) { + return ( + [styles.button, pressed && styles.pressed]} + > + + + ); +} + +export default IconButton; + +const styles = StyleSheet.create({ + button: { + padding: 8, + justifyContent: 'center', + alignItems: 'center' + }, + pressed: { + opacity: 0.7 + } +}); diff --git a/attachments/12-native-features/00-starting-project/components/UI/OutlineButton.js b/attachments/12-native-features/00-starting-project/components/UI/OutlineButton.js new file mode 100644 index 00000000..c08cc020 --- /dev/null +++ b/attachments/12-native-features/00-starting-project/components/UI/OutlineButton.js @@ -0,0 +1,45 @@ +import React from 'react'; +import { Pressable, StyleSheet, Text } from 'react-native'; +import { Ionicons } from '@expo/vector-icons'; +import { Colors } from '../../constants/colors'; + +function OutlineButton({ onPress, icon, children }) { + return ( + [styles.button, pressed && styles.pressed]} + onPress={onPress} + > + + {children} + + ); +} + +export default OutlineButton; + +const styles = StyleSheet.create({ + button: { + paddingHorizontal: 12, + paddingVertical: 6, + margin: 4, + flexDirection: 'row', + justifyContent: 'center', + alignItems: 'center', + borderWidth: 1, + borderColor: Colors.primary500 + }, + pressed: { + opacity: 0.7 + }, + icon: { + marginRight: 6 + }, + text: { + color: Colors.primary500 + } +}); diff --git a/attachments/12-native-features/00-starting-project/constants/colors.js b/attachments/12-native-features/00-starting-project/constants/colors.js new file mode 100644 index 00000000..4680347c --- /dev/null +++ b/attachments/12-native-features/00-starting-project/constants/colors.js @@ -0,0 +1,11 @@ +export const Colors = { + primary50: '#cfeffd', + primary100: '#a0defb', + primary200: '#77cff8', + primary400: '#44bdf5', + primary500: '#1aacf0', + primary700: '#0570c9', + primary800: '#003b88', + accent500: '#e6b30b', + gray700: '#221c30' +}; diff --git a/attachments/12-native-features/00-starting-project/package.json b/attachments/12-native-features/00-starting-project/package.json new file mode 100644 index 00000000..58f5ce3d --- /dev/null +++ b/attachments/12-native-features/00-starting-project/package.json @@ -0,0 +1,33 @@ +{ + "name": "rncourse", + "version": "1.0.0", + "main": "node_modules/expo/AppEntry.js", + "scripts": { + "start": "expo start", + "android": "expo start --android", + "ios": "expo start --ios", + "web": "expo start --web", + "eject": "expo eject" + }, + "dependencies": { + "@react-navigation/native": "^7.1.25", + "@react-navigation/native-stack": "^7.8.6", + "expo": "^52.0.0", + "expo-app-loading": "^2.1.1", + "expo-image-picker": "~16.0.6", + "expo-location": "~18.0.10", + "expo-sqlite": "~15.1.4", + "expo-status-bar": "~2.0.1", + "react": "18.3.1", + "react-dom": "18.3.1", + "react-native": "0.76.7", + "react-native-maps": "1.18.0", + "react-native-safe-area-context": "4.12.0", + "react-native-screens": "~4.4.0", + "react-native-web": "~0.19.13" + }, + "devDependencies": { + "@babel/core": "^7.25.2" + }, + "private": true +} diff --git a/attachments/12-native-features/00-starting-project/screens/AddPlace.js b/attachments/12-native-features/00-starting-project/screens/AddPlace.js new file mode 100644 index 00000000..fe0efceb --- /dev/null +++ b/attachments/12-native-features/00-starting-project/screens/AddPlace.js @@ -0,0 +1,17 @@ +import PlaceForm from '../components/Places/PlaceForm'; +import { insertPlace } from '../util/database'; + +function AddPlace({ navigation }) { + async function createPlaceHandler(place) { + try { + await insertPlace(place); + navigation.navigate('AllPlaces'); + } catch (error) { + console.log('AddPlace, failed to insert place into databse'); + } + } + + return ; +} + +export default AddPlace; diff --git a/attachments/12-native-features/00-starting-project/screens/AllPlaces.js b/attachments/12-native-features/00-starting-project/screens/AllPlaces.js new file mode 100644 index 00000000..660f2e44 --- /dev/null +++ b/attachments/12-native-features/00-starting-project/screens/AllPlaces.js @@ -0,0 +1,26 @@ +import { useEffect, useState } from 'react'; +import PlacesList from '../components/Places/PlacesList'; +import { useIsFocused } from '@react-navigation/native'; +import { fetchPlaces } from '../util/database'; + +function AllPlaces({ route }) { + const [loadedPlaces, setLoadedPlaces] = useState([]); + const isFocused = useIsFocused(); + + useEffect(() => { + async function loadPlaces() { + const places = await fetchPlaces(); + setLoadedPlaces(places); + } + + if (isFocused) { + // setLoadedPlaces((currPlaces) => [...currPlaces, route.params.place]); + + loadPlaces(); + } + }, [isFocused]); + + return ; +} + +export default AllPlaces; diff --git a/attachments/12-native-features/00-starting-project/screens/Map.js b/attachments/12-native-features/00-starting-project/screens/Map.js new file mode 100644 index 00000000..f4862ff9 --- /dev/null +++ b/attachments/12-native-features/00-starting-project/screens/Map.js @@ -0,0 +1,90 @@ +import { useCallback, useLayoutEffect, useState } from 'react'; +import { Alert, StyleSheet } from 'react-native'; +import MapView, { Marker } from 'react-native-maps'; +import IconButton from '../components/UI/IconButton'; + +function Map({ navigation, route }) { + const initialLocation = route.params && { + lat: route.params.initialLat, + lng: route.params.initialLng + }; + + const [selectedLocation, setSelectedLocation] = useState(initialLocation); + + const region = { + latitude: initialLocation ? initialLocation.lat : 37.78, // center of the map + longitude: initialLocation ? initialLocation.lng : -122.43, + latitudeDelta: 0.0922, // how much content besides the center will be visible + longitudeDelta: 0.0421 + }; + + function selectLocationHandler(event) { + if (initialLocation) { + return; + } + + const lat = event.nativeEvent.coordinate.latitude; + const lng = event.nativeEvent.coordinate.longitude; + + setSelectedLocation({ lat: lat, lng: lng }); + } + + const savePickedLocationHandler = useCallback(() => { + if (!selectedLocation) { + Alert.alert( + 'No location picked!', + 'You have to pick a location (by tapping on the map) first!' + ); + return; + } + + // with the object added it will be passed to the AddPlace screen + navigation.navigate('AddPlace', { + pickedLat: selectedLocation.lat, + pickedLng: selectedLocation.lng + }); + }, [navigation, selectedLocation]); + + useLayoutEffect(() => { + if (initialLocation) { + return; + } + + navigation.setOptions({ + headerRight: ({ tintColor }) => ( + + ) + }); + }, [navigation, savePickedLocationHandler, initialLocation]); + + return ( + + {selectedLocation && ( + + )} + + ); +} + +export default Map; + +const styles = StyleSheet.create({ + map: { + flex: 1 + } +}); diff --git a/attachments/12-native-features/00-starting-project/screens/PlaceDetails.js b/attachments/12-native-features/00-starting-project/screens/PlaceDetails.js new file mode 100644 index 00000000..649dc1b8 --- /dev/null +++ b/attachments/12-native-features/00-starting-project/screens/PlaceDetails.js @@ -0,0 +1,82 @@ +import { Image, ScrollView, StyleSheet, Text, View } from 'react-native'; +import OutlineButton from '../components/UI/OutlineButton'; +import { Colors } from '../constants/colors'; +import { useEffect, useState } from 'react'; +import { fetchPlaceDetails } from '../util/database'; + +function PlaceDetails({ route, navigation }) { + const [fetchedPlace, setFetchedPlace] = useState(); + const selectedPlaceId = route.params.placeId; + + useEffect(() => { + // use selectedPlaceId to fetch data for a single place + + async function loadPlaceData() { + const place = fetchPlaceDetails(selectedPlaceId); + setFetchedPlace(place); + + navigation.setOptions({ + title: place.title + }); + } + + loadPlaceData(); + }, [selectedPlaceId]); + + if (!fetchedPlace) { + return ( + + Loading place data... + + ); + } + + function showOnMapHandler() { + navigation.navigate('Map', { + initialLat: fetchedPlace.location.lat, + initialLng: fetchedPlace.location.lng + }); + } + + return ( + + + + + {fetchedPlace.address} + + + + View on Map + + + ); +} + +export default PlaceDetails; + +const styles = StyleSheet.create({ + fallback: { + flex: 1, + justifyContent: 'center', + alignItems: 'center' + }, + image: { + height: '35%', + minHeight: 300, + width: '100%' + }, + locationContainer: { + justifyContent: 'center', + alignItems: 'center' + }, + addressContainer: { + padding: 20 + }, + address: { + color: Colors.primary500, + textAlign: 'center', + fontWeight: 'bold', + fontSize: 16 + } +}); diff --git a/attachments/12-native-features/00-starting-project/util/database.js b/attachments/12-native-features/00-starting-project/util/database.js new file mode 100644 index 00000000..ff936ea4 --- /dev/null +++ b/attachments/12-native-features/00-starting-project/util/database.js @@ -0,0 +1,195 @@ +import * as SQLite from 'expo-sqlite'; +import { Place } from '../Models/place'; + +const database = SQLite.openDatabaseSync('places.db'); + +export async function init() { + try { + database.withTransactionSync(() => { + database.execAsync(` + CREATE TABLE IF NOT EXISTS places ( + id INTEGER PRIMARY KEY NOT NULL, + title TEXT NOT NULL, + imageUri TEXT NOT NULL, + address TEXT NOT NULL, + lat REAL NOT NULL, + lng REAL NOT NULL + ); + `); + }); + } catch (error) { + console.log('Database init failed:', error); + throw error; + } + + // OLD CODE FROM COURSE, EXPO SQLite HAS SINCE CHANGED! + + // const promise = new Promise((resolve, reject) => { + // database.transaction((tx) => { + // tx.executeSql( + // `CREATE TABLE IF NOT EXISTS places ( + // id INTEGER PRIMARY KEY NOT NULL, + // title TEXT NOT NULL, + // imageUri TEXT NOT NULL, + // address TEXT NOT NULL, + // lat REAL NOT NULL, + // lng REAL NOT NULL + // )`, + // [], + // // Callback if everything succeeded + // () => { + // resolve(); + // }, + // // Callback if we have an error, _ is the transaction that failed but we are not interested in that in this case + // (_, error) => { + // reject(error); + // } + // ); + // }); + // }); + + // return promise; +} + +export async function insertPlace(place) { + try { + const result = await database.runAsync( + `INSERT INTO places (title, imageUri, address, lat, lng) VALUES (?, ?, ?, ?,?)`, + [ + place.title, + place.imageUri, + place.address, + place.location.lat, + place.location.lng + ] + ); + return result; + } catch (error) { + console.log('Insert place failed: ', error); + throw error; + } + + // CODE FROM THE COURSE, SQLite HAS CHANGED SINCE! + // const promise = new Promise((resolve, reject) => { + // database.transaction((tx) => { + // tx.executeSql( + // `INSERT INTO places (title, imageUri, address, lat, lng) VALUES (?, ?, ?, ?,?)`, + // [ + // place.title, + // place.imageUri, + // place.address, + // place.location.lat, + // place.location.lng + // ], + // // success case, first argument is the transaction but we are not interested in that for this case + // (_, result) => { + // console.log(result); + // resolve(result); + // }, + // // error case + // (_, error) => { + // reject(error); + // } + // ); + // }); + // }); + + // return promise; +} + +export async function fetchPlaces() { + try { + const result = await database.getAllAsync('SELECT * FROM places'); + + // return result; + + // TODO: - Check if this is correct, at the momemt of following and implementing the course I couldn't test it + const places = []; + for (const dp of result.rows._array) { + places.push( + new Place( + dp.title, + dp.imageUri, + { + address: dp.address, + lat: dp.lat, + lng: dp.lng + }, + dp.id + ) + ); + } + + return places; + } catch (error) { + console.log('Fetch places failed:', error); + throw error; + } + + // CODE FROM THE COURSE, SQLite has changed since then + // const promise = new Promise((resolve, reject) => { + // database.transaction((tx) => { + // tx.executeSql( + // 'SELECT * FROM places', + // // the second argument is the array of data should be injected into this command, but here we don't have any placeholders so we use an empty array! + // [], + // // success function + // (_, result) => { + // console.log(result); + + // resolve(result.rows._array); + + // resolve(result); + // }, + // // error function + // (_, error) => { + // reject(error); + // } + // ); + // }); + // }); + + // return promise; +} + +export async function fetchPlaceDetails(id) { + try { + const result = await database.getFirstAsync( + 'SELECT * FROM places WHERE id = ?', + [id] + ); + + // return place; + const dbPlace = result.rows._array[0]; + const place = new Place( + dbPlace.title, + dbPlace.imageUri, + { lat: dbPlace.lat, lng: dbPlace.lng, address: dbPlace.address }, + dbPlace.id + ); + return place; + } catch (error) { + console.log('Fetch place details failed:', error); + throw error; + } + + // CODE FROM THE COURSE, SQLite HAS CHANGED SINCE THEN! + // const promise = new Promise((resolve, reject) => { + // database.transaction((tx) => { + // tx.executeSql( + // 'SELECT * FROM places WHERE id = ?', + // [id], + // // success + // (_, result) => { + // resolve(result); + // }, + // // error + // (_, error) => { + // reject(error); + // } + // ); + // }); + // }); + + // return promise; +} diff --git a/attachments/12-native-features/00-starting-project/util/location.js b/attachments/12-native-features/00-starting-project/util/location.js new file mode 100644 index 00000000..83afc113 --- /dev/null +++ b/attachments/12-native-features/00-starting-project/util/location.js @@ -0,0 +1,22 @@ +const GOOGLE_API_KEY = ''; + +export function getMapPreview(lat, lng) { + const imagePreviewUrl = `https://maps.googleapis.com/maps/api/staticmap?center=${lat},${lng}&zoom=14&size=400x200&maptype=roadmap +&markers=color:red%7Clabel:S%7C${lat},${lng} +&key=${GOOGLE_API_KEY}`; // &signature=YOUR_SIGNATURE + + return imagePreviewUrl; +} + +export async function getAddress(lat, lng) { + const url = `https://maps.googleapis.com/maps/api/geocode/json?latlng=${lat},${lng}&key=${GOOGLE_API_KEY}`; + const response = await fetch(url); + + if (!response.ok) { + throw new Error('Failed to fetch adress!'); + } + + const data = await response.json(); + const address = data.results[0].formatted_address; + return address; +}