diff --git a/code/01-starting-code/App.js b/code/01-starting-code/App.js
new file mode 100644
index 00000000..8c08ae1b
--- /dev/null
+++ b/code/01-starting-code/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/code/01-starting-code/app.json b/code/01-starting-code/app.json
new file mode 100644
index 00000000..9a1223e7
--- /dev/null
+++ b/code/01-starting-code/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/code/01-starting-code/assets/adaptive-icon.png b/code/01-starting-code/assets/adaptive-icon.png
new file mode 100644
index 00000000..03d6f6b6
Binary files /dev/null and b/code/01-starting-code/assets/adaptive-icon.png differ
diff --git a/code/01-starting-code/assets/favicon.png b/code/01-starting-code/assets/favicon.png
new file mode 100644
index 00000000..e75f697b
Binary files /dev/null and b/code/01-starting-code/assets/favicon.png differ
diff --git a/code/01-starting-code/assets/icon.png b/code/01-starting-code/assets/icon.png
new file mode 100644
index 00000000..a0b1526f
Binary files /dev/null and b/code/01-starting-code/assets/icon.png differ
diff --git a/code/01-starting-code/assets/splash.png b/code/01-starting-code/assets/splash.png
new file mode 100644
index 00000000..0e89705a
Binary files /dev/null and b/code/01-starting-code/assets/splash.png differ
diff --git a/code/01-starting-code/babel.config.js b/code/01-starting-code/babel.config.js
new file mode 100644
index 00000000..2900afe9
--- /dev/null
+++ b/code/01-starting-code/babel.config.js
@@ -0,0 +1,6 @@
+module.exports = function(api) {
+ api.cache(true);
+ return {
+ presets: ['babel-preset-expo'],
+ };
+};
diff --git a/code/01-starting-code/components/ExpensesOutput/ExpenseItem.js b/code/01-starting-code/components/ExpensesOutput/ExpenseItem.js
new file mode 100644
index 00000000..e6b52bd9
--- /dev/null
+++ b/code/01-starting-code/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/code/01-starting-code/components/ExpensesOutput/ExpensesList.js b/code/01-starting-code/components/ExpensesOutput/ExpensesList.js
new file mode 100644
index 00000000..771be213
--- /dev/null
+++ b/code/01-starting-code/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/code/01-starting-code/components/ExpensesOutput/ExpensesOutput.js b/code/01-starting-code/components/ExpensesOutput/ExpensesOutput.js
new file mode 100644
index 00000000..e070127a
--- /dev/null
+++ b/code/01-starting-code/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/code/01-starting-code/components/ExpensesOutput/ExpensesSummary.js b/code/01-starting-code/components/ExpensesOutput/ExpensesSummary.js
new file mode 100644
index 00000000..27ec4baa
--- /dev/null
+++ b/code/01-starting-code/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/code/01-starting-code/components/ManageExpense/ExpenseForm.js b/code/01-starting-code/components/ManageExpense/ExpenseForm.js
new file mode 100644
index 00000000..9a2f4cc6
--- /dev/null
+++ b/code/01-starting-code/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/code/01-starting-code/components/ManageExpense/Input.js b/code/01-starting-code/components/ManageExpense/Input.js
new file mode 100644
index 00000000..8c0537f5
--- /dev/null
+++ b/code/01-starting-code/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/code/01-starting-code/components/UI/Button.js b/code/01-starting-code/components/UI/Button.js
new file mode 100644
index 00000000..48b6a1e6
--- /dev/null
+++ b/code/01-starting-code/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/code/01-starting-code/components/UI/IconButton.js b/code/01-starting-code/components/UI/IconButton.js
new file mode 100644
index 00000000..cc717c99
--- /dev/null
+++ b/code/01-starting-code/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/code/01-starting-code/constants/styles.js b/code/01-starting-code/constants/styles.js
new file mode 100644
index 00000000..29faff14
--- /dev/null
+++ b/code/01-starting-code/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/code/01-starting-code/package.json b/code/01-starting-code/package.json
new file mode 100644
index 00000000..10eddada
--- /dev/null
+++ b/code/01-starting-code/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": "~44.0.0",
+ "expo-status-bar": "~1.2.0",
+ "react": "17.0.1",
+ "react-dom": "17.0.1",
+ "react-native": "0.64.3",
+ "react-native-safe-area-context": "3.3.2",
+ "react-native-screens": "~3.10.1",
+ "react-native-web": "0.17.1"
+ },
+ "devDependencies": {
+ "@babel/core": "^7.12.9"
+ },
+ "private": true
+}
diff --git a/code/01-starting-code/screens/AllExpenses.js b/code/01-starting-code/screens/AllExpenses.js
new file mode 100644
index 00000000..b0838e68
--- /dev/null
+++ b/code/01-starting-code/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/code/01-starting-code/screens/ManageExpense.js b/code/01-starting-code/screens/ManageExpense.js
new file mode 100644
index 00000000..80e90add
--- /dev/null
+++ b/code/01-starting-code/screens/ManageExpense.js
@@ -0,0 +1,81 @@
+import { useContext, useLayoutEffect } 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';
+
+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/code/01-starting-code/screens/RecentExpenses.js b/code/01-starting-code/screens/RecentExpenses.js
new file mode 100644
index 00000000..5ba47322
--- /dev/null
+++ b/code/01-starting-code/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/code/01-starting-code/store/expenses-context.js b/code/01-starting-code/store/expenses-context.js
new file mode 100644
index 00000000..8d697418
--- /dev/null
+++ b/code/01-starting-code/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/code/01-starting-code/util/date.js b/code/01-starting-code/util/date.js
new file mode 100644
index 00000000..28185a47
--- /dev/null
+++ b/code/01-starting-code/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/code/02-sending-post-requests/App.js b/code/02-sending-post-requests/App.js
new file mode 100644
index 00000000..8c08ae1b
--- /dev/null
+++ b/code/02-sending-post-requests/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/code/02-sending-post-requests/app.json b/code/02-sending-post-requests/app.json
new file mode 100644
index 00000000..9a1223e7
--- /dev/null
+++ b/code/02-sending-post-requests/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/code/02-sending-post-requests/assets/adaptive-icon.png b/code/02-sending-post-requests/assets/adaptive-icon.png
new file mode 100644
index 00000000..03d6f6b6
Binary files /dev/null and b/code/02-sending-post-requests/assets/adaptive-icon.png differ
diff --git a/code/02-sending-post-requests/assets/favicon.png b/code/02-sending-post-requests/assets/favicon.png
new file mode 100644
index 00000000..e75f697b
Binary files /dev/null and b/code/02-sending-post-requests/assets/favicon.png differ
diff --git a/code/02-sending-post-requests/assets/icon.png b/code/02-sending-post-requests/assets/icon.png
new file mode 100644
index 00000000..a0b1526f
Binary files /dev/null and b/code/02-sending-post-requests/assets/icon.png differ
diff --git a/code/02-sending-post-requests/assets/splash.png b/code/02-sending-post-requests/assets/splash.png
new file mode 100644
index 00000000..0e89705a
Binary files /dev/null and b/code/02-sending-post-requests/assets/splash.png differ
diff --git a/code/02-sending-post-requests/babel.config.js b/code/02-sending-post-requests/babel.config.js
new file mode 100644
index 00000000..2900afe9
--- /dev/null
+++ b/code/02-sending-post-requests/babel.config.js
@@ -0,0 +1,6 @@
+module.exports = function(api) {
+ api.cache(true);
+ return {
+ presets: ['babel-preset-expo'],
+ };
+};
diff --git a/code/02-sending-post-requests/components/ExpensesOutput/ExpenseItem.js b/code/02-sending-post-requests/components/ExpensesOutput/ExpenseItem.js
new file mode 100644
index 00000000..e6b52bd9
--- /dev/null
+++ b/code/02-sending-post-requests/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/code/02-sending-post-requests/components/ExpensesOutput/ExpensesList.js b/code/02-sending-post-requests/components/ExpensesOutput/ExpensesList.js
new file mode 100644
index 00000000..771be213
--- /dev/null
+++ b/code/02-sending-post-requests/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/code/02-sending-post-requests/components/ExpensesOutput/ExpensesOutput.js b/code/02-sending-post-requests/components/ExpensesOutput/ExpensesOutput.js
new file mode 100644
index 00000000..e070127a
--- /dev/null
+++ b/code/02-sending-post-requests/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/code/02-sending-post-requests/components/ExpensesOutput/ExpensesSummary.js b/code/02-sending-post-requests/components/ExpensesOutput/ExpensesSummary.js
new file mode 100644
index 00000000..27ec4baa
--- /dev/null
+++ b/code/02-sending-post-requests/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/code/02-sending-post-requests/components/ManageExpense/ExpenseForm.js b/code/02-sending-post-requests/components/ManageExpense/ExpenseForm.js
new file mode 100644
index 00000000..9a2f4cc6
--- /dev/null
+++ b/code/02-sending-post-requests/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/code/02-sending-post-requests/components/ManageExpense/Input.js b/code/02-sending-post-requests/components/ManageExpense/Input.js
new file mode 100644
index 00000000..8c0537f5
--- /dev/null
+++ b/code/02-sending-post-requests/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/code/02-sending-post-requests/components/UI/Button.js b/code/02-sending-post-requests/components/UI/Button.js
new file mode 100644
index 00000000..48b6a1e6
--- /dev/null
+++ b/code/02-sending-post-requests/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/code/02-sending-post-requests/components/UI/IconButton.js b/code/02-sending-post-requests/components/UI/IconButton.js
new file mode 100644
index 00000000..cc717c99
--- /dev/null
+++ b/code/02-sending-post-requests/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/code/02-sending-post-requests/constants/styles.js b/code/02-sending-post-requests/constants/styles.js
new file mode 100644
index 00000000..29faff14
--- /dev/null
+++ b/code/02-sending-post-requests/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/code/02-sending-post-requests/package.json b/code/02-sending-post-requests/package.json
new file mode 100644
index 00000000..eb0d79f5
--- /dev/null
+++ b/code/02-sending-post-requests/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": "^0.26.0",
+ "expo": "~44.0.0",
+ "expo-status-bar": "~1.2.0",
+ "react": "17.0.1",
+ "react-dom": "17.0.1",
+ "react-native": "0.64.3",
+ "react-native-safe-area-context": "3.3.2",
+ "react-native-screens": "~3.10.1",
+ "react-native-web": "0.17.1"
+ },
+ "devDependencies": {
+ "@babel/core": "^7.12.9"
+ },
+ "private": true
+}
diff --git a/code/02-sending-post-requests/screens/AllExpenses.js b/code/02-sending-post-requests/screens/AllExpenses.js
new file mode 100644
index 00000000..b0838e68
--- /dev/null
+++ b/code/02-sending-post-requests/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/code/02-sending-post-requests/screens/ManageExpense.js b/code/02-sending-post-requests/screens/ManageExpense.js
new file mode 100644
index 00000000..42b31cd4
--- /dev/null
+++ b/code/02-sending-post-requests/screens/ManageExpense.js
@@ -0,0 +1,82 @@
+import { useContext, useLayoutEffect } from 'react';
+import { StyleSheet, View } from 'react-native';
+
+import ExpenseForm from '../components/ManageExpense/ExpenseForm';
+import IconButton from '../components/UI/IconButton';
+import { GlobalStyles } from '../constants/styles';
+import { ExpensesContext } from '../store/expenses-context';
+import { storeExpense } from '../util/http';
+
+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 {
+ storeExpense(expenseData);
+ 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/code/02-sending-post-requests/screens/RecentExpenses.js b/code/02-sending-post-requests/screens/RecentExpenses.js
new file mode 100644
index 00000000..5ba47322
--- /dev/null
+++ b/code/02-sending-post-requests/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/code/02-sending-post-requests/store/expenses-context.js b/code/02-sending-post-requests/store/expenses-context.js
new file mode 100644
index 00000000..8d697418
--- /dev/null
+++ b/code/02-sending-post-requests/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/code/02-sending-post-requests/util/date.js b/code/02-sending-post-requests/util/date.js
new file mode 100644
index 00000000..28185a47
--- /dev/null
+++ b/code/02-sending-post-requests/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/code/02-sending-post-requests/util/http.js b/code/02-sending-post-requests/util/http.js
new file mode 100644
index 00000000..31fb4769
--- /dev/null
+++ b/code/02-sending-post-requests/util/http.js
@@ -0,0 +1,8 @@
+import axios from 'axios';
+
+export function storeExpense(expenseData) {
+ axios.post(
+ 'https://react-native-course-3cceb-default-rtdb.firebaseio.com/expenses.json',
+ expenseData
+ );
+}
diff --git a/code/03-transforming-using-fetched-data/App.js b/code/03-transforming-using-fetched-data/App.js
new file mode 100644
index 00000000..8c08ae1b
--- /dev/null
+++ b/code/03-transforming-using-fetched-data/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/code/03-transforming-using-fetched-data/app.json b/code/03-transforming-using-fetched-data/app.json
new file mode 100644
index 00000000..9a1223e7
--- /dev/null
+++ b/code/03-transforming-using-fetched-data/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/code/03-transforming-using-fetched-data/assets/adaptive-icon.png b/code/03-transforming-using-fetched-data/assets/adaptive-icon.png
new file mode 100644
index 00000000..03d6f6b6
Binary files /dev/null and b/code/03-transforming-using-fetched-data/assets/adaptive-icon.png differ
diff --git a/code/03-transforming-using-fetched-data/assets/favicon.png b/code/03-transforming-using-fetched-data/assets/favicon.png
new file mode 100644
index 00000000..e75f697b
Binary files /dev/null and b/code/03-transforming-using-fetched-data/assets/favicon.png differ
diff --git a/code/03-transforming-using-fetched-data/assets/icon.png b/code/03-transforming-using-fetched-data/assets/icon.png
new file mode 100644
index 00000000..a0b1526f
Binary files /dev/null and b/code/03-transforming-using-fetched-data/assets/icon.png differ
diff --git a/code/03-transforming-using-fetched-data/assets/splash.png b/code/03-transforming-using-fetched-data/assets/splash.png
new file mode 100644
index 00000000..0e89705a
Binary files /dev/null and b/code/03-transforming-using-fetched-data/assets/splash.png differ
diff --git a/code/03-transforming-using-fetched-data/babel.config.js b/code/03-transforming-using-fetched-data/babel.config.js
new file mode 100644
index 00000000..2900afe9
--- /dev/null
+++ b/code/03-transforming-using-fetched-data/babel.config.js
@@ -0,0 +1,6 @@
+module.exports = function(api) {
+ api.cache(true);
+ return {
+ presets: ['babel-preset-expo'],
+ };
+};
diff --git a/code/03-transforming-using-fetched-data/components/ExpensesOutput/ExpenseItem.js b/code/03-transforming-using-fetched-data/components/ExpensesOutput/ExpenseItem.js
new file mode 100644
index 00000000..e6b52bd9
--- /dev/null
+++ b/code/03-transforming-using-fetched-data/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/code/03-transforming-using-fetched-data/components/ExpensesOutput/ExpensesList.js b/code/03-transforming-using-fetched-data/components/ExpensesOutput/ExpensesList.js
new file mode 100644
index 00000000..771be213
--- /dev/null
+++ b/code/03-transforming-using-fetched-data/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/code/03-transforming-using-fetched-data/components/ExpensesOutput/ExpensesOutput.js b/code/03-transforming-using-fetched-data/components/ExpensesOutput/ExpensesOutput.js
new file mode 100644
index 00000000..e070127a
--- /dev/null
+++ b/code/03-transforming-using-fetched-data/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/code/03-transforming-using-fetched-data/components/ExpensesOutput/ExpensesSummary.js b/code/03-transforming-using-fetched-data/components/ExpensesOutput/ExpensesSummary.js
new file mode 100644
index 00000000..27ec4baa
--- /dev/null
+++ b/code/03-transforming-using-fetched-data/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/code/03-transforming-using-fetched-data/components/ManageExpense/ExpenseForm.js b/code/03-transforming-using-fetched-data/components/ManageExpense/ExpenseForm.js
new file mode 100644
index 00000000..9a2f4cc6
--- /dev/null
+++ b/code/03-transforming-using-fetched-data/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/code/03-transforming-using-fetched-data/components/ManageExpense/Input.js b/code/03-transforming-using-fetched-data/components/ManageExpense/Input.js
new file mode 100644
index 00000000..8c0537f5
--- /dev/null
+++ b/code/03-transforming-using-fetched-data/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/code/03-transforming-using-fetched-data/components/UI/Button.js b/code/03-transforming-using-fetched-data/components/UI/Button.js
new file mode 100644
index 00000000..48b6a1e6
--- /dev/null
+++ b/code/03-transforming-using-fetched-data/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/code/03-transforming-using-fetched-data/components/UI/IconButton.js b/code/03-transforming-using-fetched-data/components/UI/IconButton.js
new file mode 100644
index 00000000..cc717c99
--- /dev/null
+++ b/code/03-transforming-using-fetched-data/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/code/03-transforming-using-fetched-data/constants/styles.js b/code/03-transforming-using-fetched-data/constants/styles.js
new file mode 100644
index 00000000..29faff14
--- /dev/null
+++ b/code/03-transforming-using-fetched-data/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/code/03-transforming-using-fetched-data/package.json b/code/03-transforming-using-fetched-data/package.json
new file mode 100644
index 00000000..eb0d79f5
--- /dev/null
+++ b/code/03-transforming-using-fetched-data/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": "^0.26.0",
+ "expo": "~44.0.0",
+ "expo-status-bar": "~1.2.0",
+ "react": "17.0.1",
+ "react-dom": "17.0.1",
+ "react-native": "0.64.3",
+ "react-native-safe-area-context": "3.3.2",
+ "react-native-screens": "~3.10.1",
+ "react-native-web": "0.17.1"
+ },
+ "devDependencies": {
+ "@babel/core": "^7.12.9"
+ },
+ "private": true
+}
diff --git a/code/03-transforming-using-fetched-data/screens/AllExpenses.js b/code/03-transforming-using-fetched-data/screens/AllExpenses.js
new file mode 100644
index 00000000..b0838e68
--- /dev/null
+++ b/code/03-transforming-using-fetched-data/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/code/03-transforming-using-fetched-data/screens/ManageExpense.js b/code/03-transforming-using-fetched-data/screens/ManageExpense.js
new file mode 100644
index 00000000..42b31cd4
--- /dev/null
+++ b/code/03-transforming-using-fetched-data/screens/ManageExpense.js
@@ -0,0 +1,82 @@
+import { useContext, useLayoutEffect } from 'react';
+import { StyleSheet, View } from 'react-native';
+
+import ExpenseForm from '../components/ManageExpense/ExpenseForm';
+import IconButton from '../components/UI/IconButton';
+import { GlobalStyles } from '../constants/styles';
+import { ExpensesContext } from '../store/expenses-context';
+import { storeExpense } from '../util/http';
+
+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 {
+ storeExpense(expenseData);
+ 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/code/03-transforming-using-fetched-data/screens/RecentExpenses.js b/code/03-transforming-using-fetched-data/screens/RecentExpenses.js
new file mode 100644
index 00000000..83d615a1
--- /dev/null
+++ b/code/03-transforming-using-fetched-data/screens/RecentExpenses.js
@@ -0,0 +1,36 @@
+import { useContext, useEffect } from 'react';
+
+import ExpensesOutput from '../components/ExpensesOutput/ExpensesOutput';
+import { ExpensesContext } from '../store/expenses-context';
+import { getDateMinusDays } from '../util/date';
+import { fetchExpenses } from '../util/http';
+
+function RecentExpenses() {
+ const expensesCtx = useContext(ExpensesContext);
+
+ useEffect(() => {
+ async function getExpenses() {
+ const expenses = await fetchExpenses();
+ expensesCtx.setExpenses(expenses);
+ }
+
+ getExpenses();
+ }, []);
+
+ 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/code/03-transforming-using-fetched-data/store/expenses-context.js b/code/03-transforming-using-fetched-data/store/expenses-context.js
new file mode 100644
index 00000000..a1b2c895
--- /dev/null
+++ b/code/03-transforming-using-fetched-data/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':
+ const id = new Date().toString() + Math.random().toString();
+ return [{ ...action.payload, id: id }, ...state];
+ case 'SET':
+ return action.payload;
+ 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,
+ setExpenses: setExpenses,
+ addExpense: addExpense,
+ deleteExpense: deleteExpense,
+ updateExpense: updateExpense,
+ };
+
+ return (
+
+ {children}
+
+ );
+}
+
+export default ExpensesContextProvider;
diff --git a/code/03-transforming-using-fetched-data/util/date.js b/code/03-transforming-using-fetched-data/util/date.js
new file mode 100644
index 00000000..28185a47
--- /dev/null
+++ b/code/03-transforming-using-fetched-data/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/code/03-transforming-using-fetched-data/util/http.js b/code/03-transforming-using-fetched-data/util/http.js
new file mode 100644
index 00000000..75950f78
--- /dev/null
+++ b/code/03-transforming-using-fetched-data/util/http.js
@@ -0,0 +1,26 @@
+import axios from 'axios';
+
+const BACKEND_URL =
+ 'https://react-native-course-3cceb-default-rtdb.firebaseio.com';
+
+export function storeExpense(expenseData) {
+ axios.post(BACKEND_URL + '/expenses.json', expenseData);
+}
+
+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;
+}
diff --git a/code/04-using-response-data-from-post/App.js b/code/04-using-response-data-from-post/App.js
new file mode 100644
index 00000000..8c08ae1b
--- /dev/null
+++ b/code/04-using-response-data-from-post/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/code/04-using-response-data-from-post/app.json b/code/04-using-response-data-from-post/app.json
new file mode 100644
index 00000000..9a1223e7
--- /dev/null
+++ b/code/04-using-response-data-from-post/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/code/04-using-response-data-from-post/assets/adaptive-icon.png b/code/04-using-response-data-from-post/assets/adaptive-icon.png
new file mode 100644
index 00000000..03d6f6b6
Binary files /dev/null and b/code/04-using-response-data-from-post/assets/adaptive-icon.png differ
diff --git a/code/04-using-response-data-from-post/assets/favicon.png b/code/04-using-response-data-from-post/assets/favicon.png
new file mode 100644
index 00000000..e75f697b
Binary files /dev/null and b/code/04-using-response-data-from-post/assets/favicon.png differ
diff --git a/code/04-using-response-data-from-post/assets/icon.png b/code/04-using-response-data-from-post/assets/icon.png
new file mode 100644
index 00000000..a0b1526f
Binary files /dev/null and b/code/04-using-response-data-from-post/assets/icon.png differ
diff --git a/code/04-using-response-data-from-post/assets/splash.png b/code/04-using-response-data-from-post/assets/splash.png
new file mode 100644
index 00000000..0e89705a
Binary files /dev/null and b/code/04-using-response-data-from-post/assets/splash.png differ
diff --git a/code/04-using-response-data-from-post/babel.config.js b/code/04-using-response-data-from-post/babel.config.js
new file mode 100644
index 00000000..2900afe9
--- /dev/null
+++ b/code/04-using-response-data-from-post/babel.config.js
@@ -0,0 +1,6 @@
+module.exports = function(api) {
+ api.cache(true);
+ return {
+ presets: ['babel-preset-expo'],
+ };
+};
diff --git a/code/04-using-response-data-from-post/components/ExpensesOutput/ExpenseItem.js b/code/04-using-response-data-from-post/components/ExpensesOutput/ExpenseItem.js
new file mode 100644
index 00000000..e6b52bd9
--- /dev/null
+++ b/code/04-using-response-data-from-post/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/code/04-using-response-data-from-post/components/ExpensesOutput/ExpensesList.js b/code/04-using-response-data-from-post/components/ExpensesOutput/ExpensesList.js
new file mode 100644
index 00000000..771be213
--- /dev/null
+++ b/code/04-using-response-data-from-post/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/code/04-using-response-data-from-post/components/ExpensesOutput/ExpensesOutput.js b/code/04-using-response-data-from-post/components/ExpensesOutput/ExpensesOutput.js
new file mode 100644
index 00000000..e070127a
--- /dev/null
+++ b/code/04-using-response-data-from-post/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/code/04-using-response-data-from-post/components/ExpensesOutput/ExpensesSummary.js b/code/04-using-response-data-from-post/components/ExpensesOutput/ExpensesSummary.js
new file mode 100644
index 00000000..27ec4baa
--- /dev/null
+++ b/code/04-using-response-data-from-post/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/code/04-using-response-data-from-post/components/ManageExpense/ExpenseForm.js b/code/04-using-response-data-from-post/components/ManageExpense/ExpenseForm.js
new file mode 100644
index 00000000..9a2f4cc6
--- /dev/null
+++ b/code/04-using-response-data-from-post/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/code/04-using-response-data-from-post/components/ManageExpense/Input.js b/code/04-using-response-data-from-post/components/ManageExpense/Input.js
new file mode 100644
index 00000000..8c0537f5
--- /dev/null
+++ b/code/04-using-response-data-from-post/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/code/04-using-response-data-from-post/components/UI/Button.js b/code/04-using-response-data-from-post/components/UI/Button.js
new file mode 100644
index 00000000..48b6a1e6
--- /dev/null
+++ b/code/04-using-response-data-from-post/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/code/04-using-response-data-from-post/components/UI/IconButton.js b/code/04-using-response-data-from-post/components/UI/IconButton.js
new file mode 100644
index 00000000..cc717c99
--- /dev/null
+++ b/code/04-using-response-data-from-post/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/code/04-using-response-data-from-post/constants/styles.js b/code/04-using-response-data-from-post/constants/styles.js
new file mode 100644
index 00000000..29faff14
--- /dev/null
+++ b/code/04-using-response-data-from-post/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/code/04-using-response-data-from-post/package.json b/code/04-using-response-data-from-post/package.json
new file mode 100644
index 00000000..eb0d79f5
--- /dev/null
+++ b/code/04-using-response-data-from-post/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": "^0.26.0",
+ "expo": "~44.0.0",
+ "expo-status-bar": "~1.2.0",
+ "react": "17.0.1",
+ "react-dom": "17.0.1",
+ "react-native": "0.64.3",
+ "react-native-safe-area-context": "3.3.2",
+ "react-native-screens": "~3.10.1",
+ "react-native-web": "0.17.1"
+ },
+ "devDependencies": {
+ "@babel/core": "^7.12.9"
+ },
+ "private": true
+}
diff --git a/code/04-using-response-data-from-post/screens/AllExpenses.js b/code/04-using-response-data-from-post/screens/AllExpenses.js
new file mode 100644
index 00000000..b0838e68
--- /dev/null
+++ b/code/04-using-response-data-from-post/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/code/04-using-response-data-from-post/screens/ManageExpense.js b/code/04-using-response-data-from-post/screens/ManageExpense.js
new file mode 100644
index 00000000..0f2a8c68
--- /dev/null
+++ b/code/04-using-response-data-from-post/screens/ManageExpense.js
@@ -0,0 +1,82 @@
+import { useContext, useLayoutEffect } from 'react';
+import { StyleSheet, View } from 'react-native';
+
+import ExpenseForm from '../components/ManageExpense/ExpenseForm';
+import IconButton from '../components/UI/IconButton';
+import { GlobalStyles } from '../constants/styles';
+import { ExpensesContext } from '../store/expenses-context';
+import { storeExpense } from '../util/http';
+
+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();
+ }
+
+ async function confirmHandler(expenseData) {
+ if (isEditing) {
+ expensesCtx.updateExpense(editedExpenseId, expenseData);
+ } else {
+ const id = await storeExpense(expenseData);
+ expensesCtx.addExpense({ ...expenseData, id: id });
+ }
+ 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/code/04-using-response-data-from-post/screens/RecentExpenses.js b/code/04-using-response-data-from-post/screens/RecentExpenses.js
new file mode 100644
index 00000000..83d615a1
--- /dev/null
+++ b/code/04-using-response-data-from-post/screens/RecentExpenses.js
@@ -0,0 +1,36 @@
+import { useContext, useEffect } from 'react';
+
+import ExpensesOutput from '../components/ExpensesOutput/ExpensesOutput';
+import { ExpensesContext } from '../store/expenses-context';
+import { getDateMinusDays } from '../util/date';
+import { fetchExpenses } from '../util/http';
+
+function RecentExpenses() {
+ const expensesCtx = useContext(ExpensesContext);
+
+ useEffect(() => {
+ async function getExpenses() {
+ const expenses = await fetchExpenses();
+ expensesCtx.setExpenses(expenses);
+ }
+
+ getExpenses();
+ }, []);
+
+ 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/code/04-using-response-data-from-post/store/expenses-context.js b/code/04-using-response-data-from-post/store/expenses-context.js
new file mode 100644
index 00000000..ed752a01
--- /dev/null
+++ b/code/04-using-response-data-from-post/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,
+ setExpenses: setExpenses,
+ addExpense: addExpense,
+ deleteExpense: deleteExpense,
+ updateExpense: updateExpense,
+ };
+
+ return (
+
+ {children}
+
+ );
+}
+
+export default ExpensesContextProvider;
diff --git a/code/04-using-response-data-from-post/util/date.js b/code/04-using-response-data-from-post/util/date.js
new file mode 100644
index 00000000..28185a47
--- /dev/null
+++ b/code/04-using-response-data-from-post/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/code/04-using-response-data-from-post/util/http.js b/code/04-using-response-data-from-post/util/http.js
new file mode 100644
index 00000000..bfacd8dd
--- /dev/null
+++ b/code/04-using-response-data-from-post/util/http.js
@@ -0,0 +1,28 @@
+import axios from 'axios';
+
+const BACKEND_URL =
+ 'https://react-native-course-3cceb-default-rtdb.firebaseio.com';
+
+export async function storeExpense(expenseData) {
+ const response = await axios.post(BACKEND_URL + '/expenses.json', expenseData);
+ 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;
+}
diff --git a/code/05-updating-and-deleting/App.js b/code/05-updating-and-deleting/App.js
new file mode 100644
index 00000000..8c08ae1b
--- /dev/null
+++ b/code/05-updating-and-deleting/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/code/05-updating-and-deleting/app.json b/code/05-updating-and-deleting/app.json
new file mode 100644
index 00000000..9a1223e7
--- /dev/null
+++ b/code/05-updating-and-deleting/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/code/05-updating-and-deleting/assets/adaptive-icon.png b/code/05-updating-and-deleting/assets/adaptive-icon.png
new file mode 100644
index 00000000..03d6f6b6
Binary files /dev/null and b/code/05-updating-and-deleting/assets/adaptive-icon.png differ
diff --git a/code/05-updating-and-deleting/assets/favicon.png b/code/05-updating-and-deleting/assets/favicon.png
new file mode 100644
index 00000000..e75f697b
Binary files /dev/null and b/code/05-updating-and-deleting/assets/favicon.png differ
diff --git a/code/05-updating-and-deleting/assets/icon.png b/code/05-updating-and-deleting/assets/icon.png
new file mode 100644
index 00000000..a0b1526f
Binary files /dev/null and b/code/05-updating-and-deleting/assets/icon.png differ
diff --git a/code/05-updating-and-deleting/assets/splash.png b/code/05-updating-and-deleting/assets/splash.png
new file mode 100644
index 00000000..0e89705a
Binary files /dev/null and b/code/05-updating-and-deleting/assets/splash.png differ
diff --git a/code/05-updating-and-deleting/babel.config.js b/code/05-updating-and-deleting/babel.config.js
new file mode 100644
index 00000000..2900afe9
--- /dev/null
+++ b/code/05-updating-and-deleting/babel.config.js
@@ -0,0 +1,6 @@
+module.exports = function(api) {
+ api.cache(true);
+ return {
+ presets: ['babel-preset-expo'],
+ };
+};
diff --git a/code/05-updating-and-deleting/components/ExpensesOutput/ExpenseItem.js b/code/05-updating-and-deleting/components/ExpensesOutput/ExpenseItem.js
new file mode 100644
index 00000000..e6b52bd9
--- /dev/null
+++ b/code/05-updating-and-deleting/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/code/05-updating-and-deleting/components/ExpensesOutput/ExpensesList.js b/code/05-updating-and-deleting/components/ExpensesOutput/ExpensesList.js
new file mode 100644
index 00000000..771be213
--- /dev/null
+++ b/code/05-updating-and-deleting/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/code/05-updating-and-deleting/components/ExpensesOutput/ExpensesOutput.js b/code/05-updating-and-deleting/components/ExpensesOutput/ExpensesOutput.js
new file mode 100644
index 00000000..e070127a
--- /dev/null
+++ b/code/05-updating-and-deleting/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/code/05-updating-and-deleting/components/ExpensesOutput/ExpensesSummary.js b/code/05-updating-and-deleting/components/ExpensesOutput/ExpensesSummary.js
new file mode 100644
index 00000000..27ec4baa
--- /dev/null
+++ b/code/05-updating-and-deleting/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/code/05-updating-and-deleting/components/ManageExpense/ExpenseForm.js b/code/05-updating-and-deleting/components/ManageExpense/ExpenseForm.js
new file mode 100644
index 00000000..9a2f4cc6
--- /dev/null
+++ b/code/05-updating-and-deleting/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/code/05-updating-and-deleting/components/ManageExpense/Input.js b/code/05-updating-and-deleting/components/ManageExpense/Input.js
new file mode 100644
index 00000000..8c0537f5
--- /dev/null
+++ b/code/05-updating-and-deleting/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/code/05-updating-and-deleting/components/UI/Button.js b/code/05-updating-and-deleting/components/UI/Button.js
new file mode 100644
index 00000000..48b6a1e6
--- /dev/null
+++ b/code/05-updating-and-deleting/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/code/05-updating-and-deleting/components/UI/IconButton.js b/code/05-updating-and-deleting/components/UI/IconButton.js
new file mode 100644
index 00000000..cc717c99
--- /dev/null
+++ b/code/05-updating-and-deleting/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/code/05-updating-and-deleting/constants/styles.js b/code/05-updating-and-deleting/constants/styles.js
new file mode 100644
index 00000000..29faff14
--- /dev/null
+++ b/code/05-updating-and-deleting/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/code/05-updating-and-deleting/package.json b/code/05-updating-and-deleting/package.json
new file mode 100644
index 00000000..eb0d79f5
--- /dev/null
+++ b/code/05-updating-and-deleting/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": "^0.26.0",
+ "expo": "~44.0.0",
+ "expo-status-bar": "~1.2.0",
+ "react": "17.0.1",
+ "react-dom": "17.0.1",
+ "react-native": "0.64.3",
+ "react-native-safe-area-context": "3.3.2",
+ "react-native-screens": "~3.10.1",
+ "react-native-web": "0.17.1"
+ },
+ "devDependencies": {
+ "@babel/core": "^7.12.9"
+ },
+ "private": true
+}
diff --git a/code/05-updating-and-deleting/screens/AllExpenses.js b/code/05-updating-and-deleting/screens/AllExpenses.js
new file mode 100644
index 00000000..b0838e68
--- /dev/null
+++ b/code/05-updating-and-deleting/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/code/05-updating-and-deleting/screens/ManageExpense.js b/code/05-updating-and-deleting/screens/ManageExpense.js
new file mode 100644
index 00000000..af131566
--- /dev/null
+++ b/code/05-updating-and-deleting/screens/ManageExpense.js
@@ -0,0 +1,84 @@
+import { useContext, useLayoutEffect } from 'react';
+import { StyleSheet, View } from 'react-native';
+
+import ExpenseForm from '../components/ManageExpense/ExpenseForm';
+import IconButton from '../components/UI/IconButton';
+import { GlobalStyles } from '../constants/styles';
+import { ExpensesContext } from '../store/expenses-context';
+import { storeExpense, updateExpense, deleteExpense } from '../util/http';
+
+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]);
+
+ async function deleteExpenseHandler() {
+ await deleteExpense(editedExpenseId);
+ expensesCtx.deleteExpense(editedExpenseId);
+ navigation.goBack();
+ }
+
+ function cancelHandler() {
+ navigation.goBack();
+ }
+
+ async function confirmHandler(expenseData) {
+ if (isEditing) {
+ expensesCtx.updateExpense(editedExpenseId, expenseData);
+ await updateExpense(editedExpenseId, expenseData);
+ } else {
+ const id = await storeExpense(expenseData);
+ expensesCtx.addExpense({ ...expenseData, id: id });
+ }
+ 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/code/05-updating-and-deleting/screens/RecentExpenses.js b/code/05-updating-and-deleting/screens/RecentExpenses.js
new file mode 100644
index 00000000..83d615a1
--- /dev/null
+++ b/code/05-updating-and-deleting/screens/RecentExpenses.js
@@ -0,0 +1,36 @@
+import { useContext, useEffect } from 'react';
+
+import ExpensesOutput from '../components/ExpensesOutput/ExpensesOutput';
+import { ExpensesContext } from '../store/expenses-context';
+import { getDateMinusDays } from '../util/date';
+import { fetchExpenses } from '../util/http';
+
+function RecentExpenses() {
+ const expensesCtx = useContext(ExpensesContext);
+
+ useEffect(() => {
+ async function getExpenses() {
+ const expenses = await fetchExpenses();
+ expensesCtx.setExpenses(expenses);
+ }
+
+ getExpenses();
+ }, []);
+
+ 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/code/05-updating-and-deleting/store/expenses-context.js b/code/05-updating-and-deleting/store/expenses-context.js
new file mode 100644
index 00000000..ed752a01
--- /dev/null
+++ b/code/05-updating-and-deleting/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,
+ setExpenses: setExpenses,
+ addExpense: addExpense,
+ deleteExpense: deleteExpense,
+ updateExpense: updateExpense,
+ };
+
+ return (
+
+ {children}
+
+ );
+}
+
+export default ExpensesContextProvider;
diff --git a/code/05-updating-and-deleting/util/date.js b/code/05-updating-and-deleting/util/date.js
new file mode 100644
index 00000000..28185a47
--- /dev/null
+++ b/code/05-updating-and-deleting/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/code/05-updating-and-deleting/util/http.js b/code/05-updating-and-deleting/util/http.js
new file mode 100644
index 00000000..8c4d4e40
--- /dev/null
+++ b/code/05-updating-and-deleting/util/http.js
@@ -0,0 +1,36 @@
+import axios from 'axios';
+
+const BACKEND_URL =
+ 'https://react-native-course-3cceb-default-rtdb.firebaseio.com';
+
+export async function storeExpense(expenseData) {
+ const response = await axios.post(BACKEND_URL + '/expenses.json', expenseData);
+ 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`);
+}
diff --git a/code/06-managing-loading-state/App.js b/code/06-managing-loading-state/App.js
new file mode 100644
index 00000000..8c08ae1b
--- /dev/null
+++ b/code/06-managing-loading-state/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/code/06-managing-loading-state/app.json b/code/06-managing-loading-state/app.json
new file mode 100644
index 00000000..9a1223e7
--- /dev/null
+++ b/code/06-managing-loading-state/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/code/06-managing-loading-state/assets/adaptive-icon.png b/code/06-managing-loading-state/assets/adaptive-icon.png
new file mode 100644
index 00000000..03d6f6b6
Binary files /dev/null and b/code/06-managing-loading-state/assets/adaptive-icon.png differ
diff --git a/code/06-managing-loading-state/assets/favicon.png b/code/06-managing-loading-state/assets/favicon.png
new file mode 100644
index 00000000..e75f697b
Binary files /dev/null and b/code/06-managing-loading-state/assets/favicon.png differ
diff --git a/code/06-managing-loading-state/assets/icon.png b/code/06-managing-loading-state/assets/icon.png
new file mode 100644
index 00000000..a0b1526f
Binary files /dev/null and b/code/06-managing-loading-state/assets/icon.png differ
diff --git a/code/06-managing-loading-state/assets/splash.png b/code/06-managing-loading-state/assets/splash.png
new file mode 100644
index 00000000..0e89705a
Binary files /dev/null and b/code/06-managing-loading-state/assets/splash.png differ
diff --git a/code/06-managing-loading-state/babel.config.js b/code/06-managing-loading-state/babel.config.js
new file mode 100644
index 00000000..2900afe9
--- /dev/null
+++ b/code/06-managing-loading-state/babel.config.js
@@ -0,0 +1,6 @@
+module.exports = function(api) {
+ api.cache(true);
+ return {
+ presets: ['babel-preset-expo'],
+ };
+};
diff --git a/code/06-managing-loading-state/components/ExpensesOutput/ExpenseItem.js b/code/06-managing-loading-state/components/ExpensesOutput/ExpenseItem.js
new file mode 100644
index 00000000..e6b52bd9
--- /dev/null
+++ b/code/06-managing-loading-state/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/code/06-managing-loading-state/components/ExpensesOutput/ExpensesList.js b/code/06-managing-loading-state/components/ExpensesOutput/ExpensesList.js
new file mode 100644
index 00000000..771be213
--- /dev/null
+++ b/code/06-managing-loading-state/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/code/06-managing-loading-state/components/ExpensesOutput/ExpensesOutput.js b/code/06-managing-loading-state/components/ExpensesOutput/ExpensesOutput.js
new file mode 100644
index 00000000..e070127a
--- /dev/null
+++ b/code/06-managing-loading-state/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/code/06-managing-loading-state/components/ExpensesOutput/ExpensesSummary.js b/code/06-managing-loading-state/components/ExpensesOutput/ExpensesSummary.js
new file mode 100644
index 00000000..27ec4baa
--- /dev/null
+++ b/code/06-managing-loading-state/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/code/06-managing-loading-state/components/ManageExpense/ExpenseForm.js b/code/06-managing-loading-state/components/ManageExpense/ExpenseForm.js
new file mode 100644
index 00000000..9a2f4cc6
--- /dev/null
+++ b/code/06-managing-loading-state/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/code/06-managing-loading-state/components/ManageExpense/Input.js b/code/06-managing-loading-state/components/ManageExpense/Input.js
new file mode 100644
index 00000000..8c0537f5
--- /dev/null
+++ b/code/06-managing-loading-state/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/code/06-managing-loading-state/components/UI/Button.js b/code/06-managing-loading-state/components/UI/Button.js
new file mode 100644
index 00000000..48b6a1e6
--- /dev/null
+++ b/code/06-managing-loading-state/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/code/06-managing-loading-state/components/UI/IconButton.js b/code/06-managing-loading-state/components/UI/IconButton.js
new file mode 100644
index 00000000..cc717c99
--- /dev/null
+++ b/code/06-managing-loading-state/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/code/06-managing-loading-state/components/UI/LoadingOverlay.js b/code/06-managing-loading-state/components/UI/LoadingOverlay.js
new file mode 100644
index 00000000..7a8abfd7
--- /dev/null
+++ b/code/06-managing-loading-state/components/UI/LoadingOverlay.js
@@ -0,0 +1,23 @@
+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/code/06-managing-loading-state/constants/styles.js b/code/06-managing-loading-state/constants/styles.js
new file mode 100644
index 00000000..29faff14
--- /dev/null
+++ b/code/06-managing-loading-state/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/code/06-managing-loading-state/package.json b/code/06-managing-loading-state/package.json
new file mode 100644
index 00000000..eb0d79f5
--- /dev/null
+++ b/code/06-managing-loading-state/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": "^0.26.0",
+ "expo": "~44.0.0",
+ "expo-status-bar": "~1.2.0",
+ "react": "17.0.1",
+ "react-dom": "17.0.1",
+ "react-native": "0.64.3",
+ "react-native-safe-area-context": "3.3.2",
+ "react-native-screens": "~3.10.1",
+ "react-native-web": "0.17.1"
+ },
+ "devDependencies": {
+ "@babel/core": "^7.12.9"
+ },
+ "private": true
+}
diff --git a/code/06-managing-loading-state/screens/AllExpenses.js b/code/06-managing-loading-state/screens/AllExpenses.js
new file mode 100644
index 00000000..b0838e68
--- /dev/null
+++ b/code/06-managing-loading-state/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/code/06-managing-loading-state/screens/ManageExpense.js b/code/06-managing-loading-state/screens/ManageExpense.js
new file mode 100644
index 00000000..b7b9b249
--- /dev/null
+++ b/code/06-managing-loading-state/screens/ManageExpense.js
@@ -0,0 +1,93 @@
+import { useContext, useLayoutEffect, useState } from 'react';
+import { StyleSheet, View } from 'react-native';
+
+import ExpenseForm from '../components/ManageExpense/ExpenseForm';
+import IconButton from '../components/UI/IconButton';
+import LoadingOverlay from '../components/UI/LoadingOverlay';
+import { GlobalStyles } from '../constants/styles';
+import { ExpensesContext } from '../store/expenses-context';
+import { storeExpense, updateExpense, deleteExpense } from '../util/http';
+
+function ManageExpense({ route, navigation }) {
+ const [isSubmitting, setIsSubmitting] = useState(false);
+ 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);
+ await deleteExpense(editedExpenseId);
+ // setIsSubmitting(false);
+ expensesCtx.deleteExpense(editedExpenseId);
+ navigation.goBack();
+ }
+
+ function cancelHandler() {
+ navigation.goBack();
+ }
+
+ async function confirmHandler(expenseData) {
+ setIsSubmitting(true);
+ if (isEditing) {
+ expensesCtx.updateExpense(editedExpenseId, expenseData);
+ await updateExpense(editedExpenseId, expenseData);
+ } else {
+ const id = await storeExpense(expenseData);
+ expensesCtx.addExpense({ ...expenseData, id: id });
+ }
+ navigation.goBack();
+ }
+
+ 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/code/06-managing-loading-state/screens/RecentExpenses.js b/code/06-managing-loading-state/screens/RecentExpenses.js
new file mode 100644
index 00000000..e1576fab
--- /dev/null
+++ b/code/06-managing-loading-state/screens/RecentExpenses.js
@@ -0,0 +1,44 @@
+import { useContext, useEffect, useState } from 'react';
+
+import ExpensesOutput from '../components/ExpensesOutput/ExpensesOutput';
+import LoadingOverlay from '../components/UI/LoadingOverlay';
+import { ExpensesContext } from '../store/expenses-context';
+import { getDateMinusDays } from '../util/date';
+import { fetchExpenses } from '../util/http';
+
+function RecentExpenses() {
+ const [isFetching, setIsFetching] = useState(true);
+ const expensesCtx = useContext(ExpensesContext);
+
+ useEffect(() => {
+ async function getExpenses() {
+ setIsFetching(true);
+ const expenses = await fetchExpenses();
+ setIsFetching(false);
+ expensesCtx.setExpenses(expenses);
+ }
+
+ getExpenses();
+ }, []);
+
+ 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/code/06-managing-loading-state/store/expenses-context.js b/code/06-managing-loading-state/store/expenses-context.js
new file mode 100644
index 00000000..ed752a01
--- /dev/null
+++ b/code/06-managing-loading-state/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,
+ setExpenses: setExpenses,
+ addExpense: addExpense,
+ deleteExpense: deleteExpense,
+ updateExpense: updateExpense,
+ };
+
+ return (
+
+ {children}
+
+ );
+}
+
+export default ExpensesContextProvider;
diff --git a/code/06-managing-loading-state/util/date.js b/code/06-managing-loading-state/util/date.js
new file mode 100644
index 00000000..28185a47
--- /dev/null
+++ b/code/06-managing-loading-state/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/code/06-managing-loading-state/util/http.js b/code/06-managing-loading-state/util/http.js
new file mode 100644
index 00000000..8c4d4e40
--- /dev/null
+++ b/code/06-managing-loading-state/util/http.js
@@ -0,0 +1,36 @@
+import axios from 'axios';
+
+const BACKEND_URL =
+ 'https://react-native-course-3cceb-default-rtdb.firebaseio.com';
+
+export async function storeExpense(expenseData) {
+ const response = await axios.post(BACKEND_URL + '/expenses.json', expenseData);
+ 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`);
+}
diff --git a/code/07-handling-request-errors/App.js b/code/07-handling-request-errors/App.js
new file mode 100644
index 00000000..8c08ae1b
--- /dev/null
+++ b/code/07-handling-request-errors/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/code/07-handling-request-errors/app.json b/code/07-handling-request-errors/app.json
new file mode 100644
index 00000000..9a1223e7
--- /dev/null
+++ b/code/07-handling-request-errors/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/code/07-handling-request-errors/assets/adaptive-icon.png b/code/07-handling-request-errors/assets/adaptive-icon.png
new file mode 100644
index 00000000..03d6f6b6
Binary files /dev/null and b/code/07-handling-request-errors/assets/adaptive-icon.png differ
diff --git a/code/07-handling-request-errors/assets/favicon.png b/code/07-handling-request-errors/assets/favicon.png
new file mode 100644
index 00000000..e75f697b
Binary files /dev/null and b/code/07-handling-request-errors/assets/favicon.png differ
diff --git a/code/07-handling-request-errors/assets/icon.png b/code/07-handling-request-errors/assets/icon.png
new file mode 100644
index 00000000..a0b1526f
Binary files /dev/null and b/code/07-handling-request-errors/assets/icon.png differ
diff --git a/code/07-handling-request-errors/assets/splash.png b/code/07-handling-request-errors/assets/splash.png
new file mode 100644
index 00000000..0e89705a
Binary files /dev/null and b/code/07-handling-request-errors/assets/splash.png differ
diff --git a/code/07-handling-request-errors/babel.config.js b/code/07-handling-request-errors/babel.config.js
new file mode 100644
index 00000000..2900afe9
--- /dev/null
+++ b/code/07-handling-request-errors/babel.config.js
@@ -0,0 +1,6 @@
+module.exports = function(api) {
+ api.cache(true);
+ return {
+ presets: ['babel-preset-expo'],
+ };
+};
diff --git a/code/07-handling-request-errors/components/ExpensesOutput/ExpenseItem.js b/code/07-handling-request-errors/components/ExpensesOutput/ExpenseItem.js
new file mode 100644
index 00000000..e6b52bd9
--- /dev/null
+++ b/code/07-handling-request-errors/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/code/07-handling-request-errors/components/ExpensesOutput/ExpensesList.js b/code/07-handling-request-errors/components/ExpensesOutput/ExpensesList.js
new file mode 100644
index 00000000..771be213
--- /dev/null
+++ b/code/07-handling-request-errors/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/code/07-handling-request-errors/components/ExpensesOutput/ExpensesOutput.js b/code/07-handling-request-errors/components/ExpensesOutput/ExpensesOutput.js
new file mode 100644
index 00000000..e070127a
--- /dev/null
+++ b/code/07-handling-request-errors/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/code/07-handling-request-errors/components/ExpensesOutput/ExpensesSummary.js b/code/07-handling-request-errors/components/ExpensesOutput/ExpensesSummary.js
new file mode 100644
index 00000000..27ec4baa
--- /dev/null
+++ b/code/07-handling-request-errors/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/code/07-handling-request-errors/components/ManageExpense/ExpenseForm.js b/code/07-handling-request-errors/components/ManageExpense/ExpenseForm.js
new file mode 100644
index 00000000..9a2f4cc6
--- /dev/null
+++ b/code/07-handling-request-errors/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/code/07-handling-request-errors/components/ManageExpense/Input.js b/code/07-handling-request-errors/components/ManageExpense/Input.js
new file mode 100644
index 00000000..8c0537f5
--- /dev/null
+++ b/code/07-handling-request-errors/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/code/07-handling-request-errors/components/UI/Button.js b/code/07-handling-request-errors/components/UI/Button.js
new file mode 100644
index 00000000..48b6a1e6
--- /dev/null
+++ b/code/07-handling-request-errors/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/code/07-handling-request-errors/components/UI/ErrorOverlay.js b/code/07-handling-request-errors/components/UI/ErrorOverlay.js
new file mode 100644
index 00000000..2771abc9
--- /dev/null
+++ b/code/07-handling-request-errors/components/UI/ErrorOverlay.js
@@ -0,0 +1,33 @@
+import { View, StyleSheet, Text } 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: {
+ color: 'white',
+ textAlign: 'center',
+ marginBottom: 8,
+ },
+ title: {
+ fontSize: 20,
+ fontWeight: 'bold',
+ },
+});
diff --git a/code/07-handling-request-errors/components/UI/IconButton.js b/code/07-handling-request-errors/components/UI/IconButton.js
new file mode 100644
index 00000000..cc717c99
--- /dev/null
+++ b/code/07-handling-request-errors/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/code/07-handling-request-errors/components/UI/LoadingOverlay.js b/code/07-handling-request-errors/components/UI/LoadingOverlay.js
new file mode 100644
index 00000000..7a8abfd7
--- /dev/null
+++ b/code/07-handling-request-errors/components/UI/LoadingOverlay.js
@@ -0,0 +1,23 @@
+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/code/07-handling-request-errors/constants/styles.js b/code/07-handling-request-errors/constants/styles.js
new file mode 100644
index 00000000..29faff14
--- /dev/null
+++ b/code/07-handling-request-errors/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/code/07-handling-request-errors/package.json b/code/07-handling-request-errors/package.json
new file mode 100644
index 00000000..eb0d79f5
--- /dev/null
+++ b/code/07-handling-request-errors/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": "^0.26.0",
+ "expo": "~44.0.0",
+ "expo-status-bar": "~1.2.0",
+ "react": "17.0.1",
+ "react-dom": "17.0.1",
+ "react-native": "0.64.3",
+ "react-native-safe-area-context": "3.3.2",
+ "react-native-screens": "~3.10.1",
+ "react-native-web": "0.17.1"
+ },
+ "devDependencies": {
+ "@babel/core": "^7.12.9"
+ },
+ "private": true
+}
diff --git a/code/07-handling-request-errors/screens/AllExpenses.js b/code/07-handling-request-errors/screens/AllExpenses.js
new file mode 100644
index 00000000..b0838e68
--- /dev/null
+++ b/code/07-handling-request-errors/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/code/07-handling-request-errors/screens/ManageExpense.js b/code/07-handling-request-errors/screens/ManageExpense.js
new file mode 100644
index 00000000..d155c8da
--- /dev/null
+++ b/code/07-handling-request-errors/screens/ManageExpense.js
@@ -0,0 +1,109 @@
+import { useContext, useLayoutEffect, useState } from 'react';
+import { StyleSheet, View } from 'react-native';
+
+import ExpenseForm from '../components/ManageExpense/ExpenseForm';
+import ErrorOverlay from '../components/UI/ErrorOverlay';
+import IconButton from '../components/UI/IconButton';
+import LoadingOverlay from '../components/UI/LoadingOverlay';
+import { GlobalStyles } from '../constants/styles';
+import { ExpensesContext } from '../store/expenses-context';
+import { storeExpense, updateExpense, deleteExpense } from '../util/http';
+
+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(editedExpenseId, 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/code/07-handling-request-errors/screens/RecentExpenses.js b/code/07-handling-request-errors/screens/RecentExpenses.js
new file mode 100644
index 00000000..bc53da81
--- /dev/null
+++ b/code/07-handling-request-errors/screens/RecentExpenses.js
@@ -0,0 +1,55 @@
+import { useContext, useEffect, useState } from 'react';
+
+import ExpensesOutput from '../components/ExpensesOutput/ExpensesOutput';
+import ErrorOverlay from '../components/UI/ErrorOverlay';
+import LoadingOverlay from '../components/UI/LoadingOverlay';
+import { ExpensesContext } from '../store/expenses-context';
+import { getDateMinusDays } from '../util/date';
+import { fetchExpenses } from '../util/http';
+
+function RecentExpenses() {
+ const [isFetching, setIsFetching] = useState(true);
+ 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/code/07-handling-request-errors/store/expenses-context.js b/code/07-handling-request-errors/store/expenses-context.js
new file mode 100644
index 00000000..ed752a01
--- /dev/null
+++ b/code/07-handling-request-errors/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,
+ setExpenses: setExpenses,
+ addExpense: addExpense,
+ deleteExpense: deleteExpense,
+ updateExpense: updateExpense,
+ };
+
+ return (
+
+ {children}
+
+ );
+}
+
+export default ExpensesContextProvider;
diff --git a/code/07-handling-request-errors/util/date.js b/code/07-handling-request-errors/util/date.js
new file mode 100644
index 00000000..28185a47
--- /dev/null
+++ b/code/07-handling-request-errors/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/code/07-handling-request-errors/util/http.js b/code/07-handling-request-errors/util/http.js
new file mode 100644
index 00000000..8c4d4e40
--- /dev/null
+++ b/code/07-handling-request-errors/util/http.js
@@ -0,0 +1,36 @@
+import axios from 'axios';
+
+const BACKEND_URL =
+ 'https://react-native-course-3cceb-default-rtdb.firebaseio.com';
+
+export async function storeExpense(expenseData) {
+ const response = await axios.post(BACKEND_URL + '/expenses.json', expenseData);
+ 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`);
+}