diff --git a/code/01-starting-code/App.js b/code/01-starting-code/App.js
new file mode 100644
index 0000000..8c08ae1
--- /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 0000000..9a1223e
--- /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 0000000..03d6f6b
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 0000000..e75f697
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 0000000..a0b1526
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 0000000..0e89705
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 0000000..2900afe
--- /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 0000000..e6b52bd
--- /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 0000000..771be21
--- /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 0000000..e070127
--- /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 0000000..27ec4ba
--- /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/UI/Button.js b/code/01-starting-code/components/UI/Button.js
new file mode 100644
index 0000000..48b6a1e
--- /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 0000000..cc717c9
--- /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 0000000..29faff1
--- /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 0000000..10eddad
--- /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 0000000..b0838e6
--- /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 0000000..9403703
--- /dev/null
+++ b/code/01-starting-code/screens/ManageExpense.js
@@ -0,0 +1,98 @@
+import { useContext, useLayoutEffect } from 'react';
+import { StyleSheet, View } from 'react-native';
+
+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;
+
+ useLayoutEffect(() => {
+ navigation.setOptions({
+ title: isEditing ? 'Edit Expense' : 'Add Expense',
+ });
+ }, [navigation, isEditing]);
+
+ function deleteExpenseHandler() {
+ expensesCtx.deleteExpense(editedExpenseId);
+ navigation.goBack();
+ }
+
+ function cancelHandler() {
+ navigation.goBack();
+ }
+
+ function confirmHandler() {
+ if (isEditing) {
+ expensesCtx.updateExpense(
+ editedExpenseId,
+ {
+ description: 'Test!!!!',
+ amount: 29.99,
+ date: new Date('2022-05-20'),
+ }
+ );
+ } else {
+ expensesCtx.addExpense({
+ description: 'Test',
+ amount: 19.99,
+ date: new Date('2022-05-19'),
+ });
+ }
+ navigation.goBack();
+ }
+
+ return (
+
+
+
+
+
+ {isEditing && (
+
+
+
+ )}
+
+ );
+}
+
+export default ManageExpense;
+
+const styles = StyleSheet.create({
+ container: {
+ flex: 1,
+ padding: 24,
+ backgroundColor: GlobalStyles.colors.primary800,
+ },
+ buttons: {
+ flexDirection: 'row',
+ justifyContent: 'center',
+ alignItems: 'center',
+ },
+ button: {
+ minWidth: 120,
+ marginHorizontal: 8,
+ },
+ deleteContainer: {
+ marginTop: 16,
+ paddingTop: 8,
+ borderTopWidth: 2,
+ borderTopColor: GlobalStyles.colors.primary200,
+ alignItems: 'center',
+ },
+});
diff --git a/code/01-starting-code/screens/RecentExpenses.js b/code/01-starting-code/screens/RecentExpenses.js
new file mode 100644
index 0000000..5ba4732
--- /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 0000000..8d69741
--- /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 0000000..c666432
--- /dev/null
+++ b/code/01-starting-code/util/date.js
@@ -0,0 +1,7 @@
+export function getFormattedDate(date) {
+ return `${date.getFullYear()}-${date.getMonth() + 1}-${date.getDate()}`;
+}
+
+export function getDateMinusDays(date, days) {
+ return new Date(date.getFullYear(), date.getMonth(), date.getDate() - days);
+}
diff --git a/code/02-configuring-the-form-input-elements/App.js b/code/02-configuring-the-form-input-elements/App.js
new file mode 100644
index 0000000..8c08ae1
--- /dev/null
+++ b/code/02-configuring-the-form-input-elements/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-configuring-the-form-input-elements/app.json b/code/02-configuring-the-form-input-elements/app.json
new file mode 100644
index 0000000..9a1223e
--- /dev/null
+++ b/code/02-configuring-the-form-input-elements/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-configuring-the-form-input-elements/assets/adaptive-icon.png b/code/02-configuring-the-form-input-elements/assets/adaptive-icon.png
new file mode 100644
index 0000000..03d6f6b
Binary files /dev/null and b/code/02-configuring-the-form-input-elements/assets/adaptive-icon.png differ
diff --git a/code/02-configuring-the-form-input-elements/assets/favicon.png b/code/02-configuring-the-form-input-elements/assets/favicon.png
new file mode 100644
index 0000000..e75f697
Binary files /dev/null and b/code/02-configuring-the-form-input-elements/assets/favicon.png differ
diff --git a/code/02-configuring-the-form-input-elements/assets/icon.png b/code/02-configuring-the-form-input-elements/assets/icon.png
new file mode 100644
index 0000000..a0b1526
Binary files /dev/null and b/code/02-configuring-the-form-input-elements/assets/icon.png differ
diff --git a/code/02-configuring-the-form-input-elements/assets/splash.png b/code/02-configuring-the-form-input-elements/assets/splash.png
new file mode 100644
index 0000000..0e89705
Binary files /dev/null and b/code/02-configuring-the-form-input-elements/assets/splash.png differ
diff --git a/code/02-configuring-the-form-input-elements/babel.config.js b/code/02-configuring-the-form-input-elements/babel.config.js
new file mode 100644
index 0000000..2900afe
--- /dev/null
+++ b/code/02-configuring-the-form-input-elements/babel.config.js
@@ -0,0 +1,6 @@
+module.exports = function(api) {
+ api.cache(true);
+ return {
+ presets: ['babel-preset-expo'],
+ };
+};
diff --git a/code/02-configuring-the-form-input-elements/components/ExpensesOutput/ExpenseItem.js b/code/02-configuring-the-form-input-elements/components/ExpensesOutput/ExpenseItem.js
new file mode 100644
index 0000000..e6b52bd
--- /dev/null
+++ b/code/02-configuring-the-form-input-elements/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-configuring-the-form-input-elements/components/ExpensesOutput/ExpensesList.js b/code/02-configuring-the-form-input-elements/components/ExpensesOutput/ExpensesList.js
new file mode 100644
index 0000000..771be21
--- /dev/null
+++ b/code/02-configuring-the-form-input-elements/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-configuring-the-form-input-elements/components/ExpensesOutput/ExpensesOutput.js b/code/02-configuring-the-form-input-elements/components/ExpensesOutput/ExpensesOutput.js
new file mode 100644
index 0000000..e070127
--- /dev/null
+++ b/code/02-configuring-the-form-input-elements/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-configuring-the-form-input-elements/components/ExpensesOutput/ExpensesSummary.js b/code/02-configuring-the-form-input-elements/components/ExpensesOutput/ExpensesSummary.js
new file mode 100644
index 0000000..27ec4ba
--- /dev/null
+++ b/code/02-configuring-the-form-input-elements/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-configuring-the-form-input-elements/components/ManageExpense/ExpenseForm.js b/code/02-configuring-the-form-input-elements/components/ManageExpense/ExpenseForm.js
new file mode 100644
index 0000000..2616a35
--- /dev/null
+++ b/code/02-configuring-the-form-input-elements/components/ManageExpense/ExpenseForm.js
@@ -0,0 +1,37 @@
+import { View } from 'react-native';
+
+import Input from './Input';
+
+function ExpenseForm() {
+ function amountChangedHandler() {}
+
+ return (
+
+
+ {},
+ }}
+ />
+
+
+ );
+}
+
+export default ExpenseForm;
diff --git a/code/02-configuring-the-form-input-elements/components/ManageExpense/Input.js b/code/02-configuring-the-form-input-elements/components/ManageExpense/Input.js
new file mode 100644
index 0000000..943027d
--- /dev/null
+++ b/code/02-configuring-the-form-input-elements/components/ManageExpense/Input.js
@@ -0,0 +1,12 @@
+import { Text, TextInput, View } from 'react-native';
+
+function Input({ label, textInputConfig }) {
+ return (
+
+ {label}
+
+
+ );
+}
+
+export default Input;
diff --git a/code/02-configuring-the-form-input-elements/components/UI/Button.js b/code/02-configuring-the-form-input-elements/components/UI/Button.js
new file mode 100644
index 0000000..48b6a1e
--- /dev/null
+++ b/code/02-configuring-the-form-input-elements/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-configuring-the-form-input-elements/components/UI/IconButton.js b/code/02-configuring-the-form-input-elements/components/UI/IconButton.js
new file mode 100644
index 0000000..cc717c9
--- /dev/null
+++ b/code/02-configuring-the-form-input-elements/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-configuring-the-form-input-elements/constants/styles.js b/code/02-configuring-the-form-input-elements/constants/styles.js
new file mode 100644
index 0000000..29faff1
--- /dev/null
+++ b/code/02-configuring-the-form-input-elements/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-configuring-the-form-input-elements/package.json b/code/02-configuring-the-form-input-elements/package.json
new file mode 100644
index 0000000..10eddad
--- /dev/null
+++ b/code/02-configuring-the-form-input-elements/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/02-configuring-the-form-input-elements/screens/AllExpenses.js b/code/02-configuring-the-form-input-elements/screens/AllExpenses.js
new file mode 100644
index 0000000..b0838e6
--- /dev/null
+++ b/code/02-configuring-the-form-input-elements/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-configuring-the-form-input-elements/screens/ManageExpense.js b/code/02-configuring-the-form-input-elements/screens/ManageExpense.js
new file mode 100644
index 0000000..b46be3c
--- /dev/null
+++ b/code/02-configuring-the-form-input-elements/screens/ManageExpense.js
@@ -0,0 +1,100 @@
+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;
+
+ useLayoutEffect(() => {
+ navigation.setOptions({
+ title: isEditing ? 'Edit Expense' : 'Add Expense',
+ });
+ }, [navigation, isEditing]);
+
+ function deleteExpenseHandler() {
+ expensesCtx.deleteExpense(editedExpenseId);
+ navigation.goBack();
+ }
+
+ function cancelHandler() {
+ navigation.goBack();
+ }
+
+ function confirmHandler() {
+ if (isEditing) {
+ expensesCtx.updateExpense(
+ editedExpenseId,
+ {
+ description: 'Test!!!!',
+ amount: 29.99,
+ date: new Date('2022-05-20'),
+ }
+ );
+ } else {
+ expensesCtx.addExpense({
+ description: 'Test',
+ amount: 19.99,
+ date: new Date('2022-05-19'),
+ });
+ }
+ navigation.goBack();
+ }
+
+ return (
+
+
+
+
+
+
+ {isEditing && (
+
+
+
+ )}
+
+ );
+}
+
+export default ManageExpense;
+
+const styles = StyleSheet.create({
+ container: {
+ flex: 1,
+ padding: 24,
+ backgroundColor: GlobalStyles.colors.primary800,
+ },
+ buttons: {
+ flexDirection: 'row',
+ justifyContent: 'center',
+ alignItems: 'center',
+ },
+ button: {
+ minWidth: 120,
+ marginHorizontal: 8,
+ },
+ deleteContainer: {
+ marginTop: 16,
+ paddingTop: 8,
+ borderTopWidth: 2,
+ borderTopColor: GlobalStyles.colors.primary200,
+ alignItems: 'center',
+ },
+});
diff --git a/code/02-configuring-the-form-input-elements/screens/RecentExpenses.js b/code/02-configuring-the-form-input-elements/screens/RecentExpenses.js
new file mode 100644
index 0000000..5ba4732
--- /dev/null
+++ b/code/02-configuring-the-form-input-elements/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-configuring-the-form-input-elements/store/expenses-context.js b/code/02-configuring-the-form-input-elements/store/expenses-context.js
new file mode 100644
index 0000000..8d69741
--- /dev/null
+++ b/code/02-configuring-the-form-input-elements/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-configuring-the-form-input-elements/util/date.js b/code/02-configuring-the-form-input-elements/util/date.js
new file mode 100644
index 0000000..c666432
--- /dev/null
+++ b/code/02-configuring-the-form-input-elements/util/date.js
@@ -0,0 +1,7 @@
+export function getFormattedDate(date) {
+ return `${date.getFullYear()}-${date.getMonth() + 1}-${date.getDate()}`;
+}
+
+export function getDateMinusDays(date, days) {
+ return new Date(date.getFullYear(), date.getMonth(), date.getDate() - days);
+}
diff --git a/code/03-added-styling/App.js b/code/03-added-styling/App.js
new file mode 100644
index 0000000..8c08ae1
--- /dev/null
+++ b/code/03-added-styling/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-added-styling/app.json b/code/03-added-styling/app.json
new file mode 100644
index 0000000..9a1223e
--- /dev/null
+++ b/code/03-added-styling/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-added-styling/assets/adaptive-icon.png b/code/03-added-styling/assets/adaptive-icon.png
new file mode 100644
index 0000000..03d6f6b
Binary files /dev/null and b/code/03-added-styling/assets/adaptive-icon.png differ
diff --git a/code/03-added-styling/assets/favicon.png b/code/03-added-styling/assets/favicon.png
new file mode 100644
index 0000000..e75f697
Binary files /dev/null and b/code/03-added-styling/assets/favicon.png differ
diff --git a/code/03-added-styling/assets/icon.png b/code/03-added-styling/assets/icon.png
new file mode 100644
index 0000000..a0b1526
Binary files /dev/null and b/code/03-added-styling/assets/icon.png differ
diff --git a/code/03-added-styling/assets/splash.png b/code/03-added-styling/assets/splash.png
new file mode 100644
index 0000000..0e89705
Binary files /dev/null and b/code/03-added-styling/assets/splash.png differ
diff --git a/code/03-added-styling/babel.config.js b/code/03-added-styling/babel.config.js
new file mode 100644
index 0000000..2900afe
--- /dev/null
+++ b/code/03-added-styling/babel.config.js
@@ -0,0 +1,6 @@
+module.exports = function(api) {
+ api.cache(true);
+ return {
+ presets: ['babel-preset-expo'],
+ };
+};
diff --git a/code/03-added-styling/components/ExpensesOutput/ExpenseItem.js b/code/03-added-styling/components/ExpensesOutput/ExpenseItem.js
new file mode 100644
index 0000000..e6b52bd
--- /dev/null
+++ b/code/03-added-styling/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-added-styling/components/ExpensesOutput/ExpensesList.js b/code/03-added-styling/components/ExpensesOutput/ExpensesList.js
new file mode 100644
index 0000000..771be21
--- /dev/null
+++ b/code/03-added-styling/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-added-styling/components/ExpensesOutput/ExpensesOutput.js b/code/03-added-styling/components/ExpensesOutput/ExpensesOutput.js
new file mode 100644
index 0000000..e070127
--- /dev/null
+++ b/code/03-added-styling/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-added-styling/components/ExpensesOutput/ExpensesSummary.js b/code/03-added-styling/components/ExpensesOutput/ExpensesSummary.js
new file mode 100644
index 0000000..27ec4ba
--- /dev/null
+++ b/code/03-added-styling/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-added-styling/components/ManageExpense/ExpenseForm.js b/code/03-added-styling/components/ManageExpense/ExpenseForm.js
new file mode 100644
index 0000000..2616a35
--- /dev/null
+++ b/code/03-added-styling/components/ManageExpense/ExpenseForm.js
@@ -0,0 +1,37 @@
+import { View } from 'react-native';
+
+import Input from './Input';
+
+function ExpenseForm() {
+ function amountChangedHandler() {}
+
+ return (
+
+
+ {},
+ }}
+ />
+
+
+ );
+}
+
+export default ExpenseForm;
diff --git a/code/03-added-styling/components/ManageExpense/Input.js b/code/03-added-styling/components/ManageExpense/Input.js
new file mode 100644
index 0000000..97e6222
--- /dev/null
+++ b/code/03-added-styling/components/ManageExpense/Input.js
@@ -0,0 +1,44 @@
+import { StyleSheet, Text, TextInput, View } from 'react-native';
+
+import { GlobalStyles } from '../../constants/styles';
+
+function Input({ label, textInputConfig }) {
+
+ const inputStyles = [styles.input];
+
+ if (textInputConfig && textInputConfig.multiline) {
+ inputStyles.push(styles.inputMultiline)
+ }
+
+ 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'
+ }
+});
diff --git a/code/03-added-styling/components/UI/Button.js b/code/03-added-styling/components/UI/Button.js
new file mode 100644
index 0000000..48b6a1e
--- /dev/null
+++ b/code/03-added-styling/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-added-styling/components/UI/IconButton.js b/code/03-added-styling/components/UI/IconButton.js
new file mode 100644
index 0000000..cc717c9
--- /dev/null
+++ b/code/03-added-styling/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-added-styling/constants/styles.js b/code/03-added-styling/constants/styles.js
new file mode 100644
index 0000000..29faff1
--- /dev/null
+++ b/code/03-added-styling/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-added-styling/package.json b/code/03-added-styling/package.json
new file mode 100644
index 0000000..10eddad
--- /dev/null
+++ b/code/03-added-styling/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/03-added-styling/screens/AllExpenses.js b/code/03-added-styling/screens/AllExpenses.js
new file mode 100644
index 0000000..b0838e6
--- /dev/null
+++ b/code/03-added-styling/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-added-styling/screens/ManageExpense.js b/code/03-added-styling/screens/ManageExpense.js
new file mode 100644
index 0000000..b46be3c
--- /dev/null
+++ b/code/03-added-styling/screens/ManageExpense.js
@@ -0,0 +1,100 @@
+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;
+
+ useLayoutEffect(() => {
+ navigation.setOptions({
+ title: isEditing ? 'Edit Expense' : 'Add Expense',
+ });
+ }, [navigation, isEditing]);
+
+ function deleteExpenseHandler() {
+ expensesCtx.deleteExpense(editedExpenseId);
+ navigation.goBack();
+ }
+
+ function cancelHandler() {
+ navigation.goBack();
+ }
+
+ function confirmHandler() {
+ if (isEditing) {
+ expensesCtx.updateExpense(
+ editedExpenseId,
+ {
+ description: 'Test!!!!',
+ amount: 29.99,
+ date: new Date('2022-05-20'),
+ }
+ );
+ } else {
+ expensesCtx.addExpense({
+ description: 'Test',
+ amount: 19.99,
+ date: new Date('2022-05-19'),
+ });
+ }
+ navigation.goBack();
+ }
+
+ return (
+
+
+
+
+
+
+ {isEditing && (
+
+
+
+ )}
+
+ );
+}
+
+export default ManageExpense;
+
+const styles = StyleSheet.create({
+ container: {
+ flex: 1,
+ padding: 24,
+ backgroundColor: GlobalStyles.colors.primary800,
+ },
+ buttons: {
+ flexDirection: 'row',
+ justifyContent: 'center',
+ alignItems: 'center',
+ },
+ button: {
+ minWidth: 120,
+ marginHorizontal: 8,
+ },
+ deleteContainer: {
+ marginTop: 16,
+ paddingTop: 8,
+ borderTopWidth: 2,
+ borderTopColor: GlobalStyles.colors.primary200,
+ alignItems: 'center',
+ },
+});
diff --git a/code/03-added-styling/screens/RecentExpenses.js b/code/03-added-styling/screens/RecentExpenses.js
new file mode 100644
index 0000000..5ba4732
--- /dev/null
+++ b/code/03-added-styling/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/03-added-styling/store/expenses-context.js b/code/03-added-styling/store/expenses-context.js
new file mode 100644
index 0000000..8d69741
--- /dev/null
+++ b/code/03-added-styling/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/03-added-styling/util/date.js b/code/03-added-styling/util/date.js
new file mode 100644
index 0000000..c666432
--- /dev/null
+++ b/code/03-added-styling/util/date.js
@@ -0,0 +1,7 @@
+export function getFormattedDate(date) {
+ return `${date.getFullYear()}-${date.getMonth() + 1}-${date.getDate()}`;
+}
+
+export function getDateMinusDays(date, days) {
+ return new Date(date.getFullYear(), date.getMonth(), date.getDate() - days);
+}
diff --git a/code/04-setting-the-form-layout/App.js b/code/04-setting-the-form-layout/App.js
new file mode 100644
index 0000000..8c08ae1
--- /dev/null
+++ b/code/04-setting-the-form-layout/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-setting-the-form-layout/app.json b/code/04-setting-the-form-layout/app.json
new file mode 100644
index 0000000..9a1223e
--- /dev/null
+++ b/code/04-setting-the-form-layout/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-setting-the-form-layout/assets/adaptive-icon.png b/code/04-setting-the-form-layout/assets/adaptive-icon.png
new file mode 100644
index 0000000..03d6f6b
Binary files /dev/null and b/code/04-setting-the-form-layout/assets/adaptive-icon.png differ
diff --git a/code/04-setting-the-form-layout/assets/favicon.png b/code/04-setting-the-form-layout/assets/favicon.png
new file mode 100644
index 0000000..e75f697
Binary files /dev/null and b/code/04-setting-the-form-layout/assets/favicon.png differ
diff --git a/code/04-setting-the-form-layout/assets/icon.png b/code/04-setting-the-form-layout/assets/icon.png
new file mode 100644
index 0000000..a0b1526
Binary files /dev/null and b/code/04-setting-the-form-layout/assets/icon.png differ
diff --git a/code/04-setting-the-form-layout/assets/splash.png b/code/04-setting-the-form-layout/assets/splash.png
new file mode 100644
index 0000000..0e89705
Binary files /dev/null and b/code/04-setting-the-form-layout/assets/splash.png differ
diff --git a/code/04-setting-the-form-layout/babel.config.js b/code/04-setting-the-form-layout/babel.config.js
new file mode 100644
index 0000000..2900afe
--- /dev/null
+++ b/code/04-setting-the-form-layout/babel.config.js
@@ -0,0 +1,6 @@
+module.exports = function(api) {
+ api.cache(true);
+ return {
+ presets: ['babel-preset-expo'],
+ };
+};
diff --git a/code/04-setting-the-form-layout/components/ExpensesOutput/ExpenseItem.js b/code/04-setting-the-form-layout/components/ExpensesOutput/ExpenseItem.js
new file mode 100644
index 0000000..e6b52bd
--- /dev/null
+++ b/code/04-setting-the-form-layout/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-setting-the-form-layout/components/ExpensesOutput/ExpensesList.js b/code/04-setting-the-form-layout/components/ExpensesOutput/ExpensesList.js
new file mode 100644
index 0000000..771be21
--- /dev/null
+++ b/code/04-setting-the-form-layout/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-setting-the-form-layout/components/ExpensesOutput/ExpensesOutput.js b/code/04-setting-the-form-layout/components/ExpensesOutput/ExpensesOutput.js
new file mode 100644
index 0000000..e070127
--- /dev/null
+++ b/code/04-setting-the-form-layout/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-setting-the-form-layout/components/ExpensesOutput/ExpensesSummary.js b/code/04-setting-the-form-layout/components/ExpensesOutput/ExpensesSummary.js
new file mode 100644
index 0000000..27ec4ba
--- /dev/null
+++ b/code/04-setting-the-form-layout/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-setting-the-form-layout/components/ManageExpense/ExpenseForm.js b/code/04-setting-the-form-layout/components/ManageExpense/ExpenseForm.js
new file mode 100644
index 0000000..5deba2a
--- /dev/null
+++ b/code/04-setting-the-form-layout/components/ManageExpense/ExpenseForm.js
@@ -0,0 +1,62 @@
+import { StyleSheet, Text, View } from 'react-native';
+
+import Input from './Input';
+
+function ExpenseForm() {
+ function amountChangedHandler() {}
+
+ return (
+
+ Your Expense
+
+
+ {},
+ }}
+ />
+
+
+
+ );
+}
+
+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,
+ },
+});
diff --git a/code/04-setting-the-form-layout/components/ManageExpense/Input.js b/code/04-setting-the-form-layout/components/ManageExpense/Input.js
new file mode 100644
index 0000000..3a889ad
--- /dev/null
+++ b/code/04-setting-the-form-layout/components/ManageExpense/Input.js
@@ -0,0 +1,44 @@
+import { StyleSheet, Text, TextInput, View } from 'react-native';
+
+import { GlobalStyles } from '../../constants/styles';
+
+function Input({ label, style, textInputConfig }) {
+
+ const inputStyles = [styles.input];
+
+ if (textInputConfig && textInputConfig.multiline) {
+ inputStyles.push(styles.inputMultiline)
+ }
+
+ 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'
+ }
+});
diff --git a/code/04-setting-the-form-layout/components/UI/Button.js b/code/04-setting-the-form-layout/components/UI/Button.js
new file mode 100644
index 0000000..48b6a1e
--- /dev/null
+++ b/code/04-setting-the-form-layout/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-setting-the-form-layout/components/UI/IconButton.js b/code/04-setting-the-form-layout/components/UI/IconButton.js
new file mode 100644
index 0000000..cc717c9
--- /dev/null
+++ b/code/04-setting-the-form-layout/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-setting-the-form-layout/constants/styles.js b/code/04-setting-the-form-layout/constants/styles.js
new file mode 100644
index 0000000..29faff1
--- /dev/null
+++ b/code/04-setting-the-form-layout/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-setting-the-form-layout/package.json b/code/04-setting-the-form-layout/package.json
new file mode 100644
index 0000000..10eddad
--- /dev/null
+++ b/code/04-setting-the-form-layout/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/04-setting-the-form-layout/screens/AllExpenses.js b/code/04-setting-the-form-layout/screens/AllExpenses.js
new file mode 100644
index 0000000..b0838e6
--- /dev/null
+++ b/code/04-setting-the-form-layout/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-setting-the-form-layout/screens/ManageExpense.js b/code/04-setting-the-form-layout/screens/ManageExpense.js
new file mode 100644
index 0000000..b46be3c
--- /dev/null
+++ b/code/04-setting-the-form-layout/screens/ManageExpense.js
@@ -0,0 +1,100 @@
+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;
+
+ useLayoutEffect(() => {
+ navigation.setOptions({
+ title: isEditing ? 'Edit Expense' : 'Add Expense',
+ });
+ }, [navigation, isEditing]);
+
+ function deleteExpenseHandler() {
+ expensesCtx.deleteExpense(editedExpenseId);
+ navigation.goBack();
+ }
+
+ function cancelHandler() {
+ navigation.goBack();
+ }
+
+ function confirmHandler() {
+ if (isEditing) {
+ expensesCtx.updateExpense(
+ editedExpenseId,
+ {
+ description: 'Test!!!!',
+ amount: 29.99,
+ date: new Date('2022-05-20'),
+ }
+ );
+ } else {
+ expensesCtx.addExpense({
+ description: 'Test',
+ amount: 19.99,
+ date: new Date('2022-05-19'),
+ });
+ }
+ navigation.goBack();
+ }
+
+ return (
+
+
+
+
+
+
+ {isEditing && (
+
+
+
+ )}
+
+ );
+}
+
+export default ManageExpense;
+
+const styles = StyleSheet.create({
+ container: {
+ flex: 1,
+ padding: 24,
+ backgroundColor: GlobalStyles.colors.primary800,
+ },
+ buttons: {
+ flexDirection: 'row',
+ justifyContent: 'center',
+ alignItems: 'center',
+ },
+ button: {
+ minWidth: 120,
+ marginHorizontal: 8,
+ },
+ deleteContainer: {
+ marginTop: 16,
+ paddingTop: 8,
+ borderTopWidth: 2,
+ borderTopColor: GlobalStyles.colors.primary200,
+ alignItems: 'center',
+ },
+});
diff --git a/code/04-setting-the-form-layout/screens/RecentExpenses.js b/code/04-setting-the-form-layout/screens/RecentExpenses.js
new file mode 100644
index 0000000..5ba4732
--- /dev/null
+++ b/code/04-setting-the-form-layout/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/04-setting-the-form-layout/store/expenses-context.js b/code/04-setting-the-form-layout/store/expenses-context.js
new file mode 100644
index 0000000..8d69741
--- /dev/null
+++ b/code/04-setting-the-form-layout/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/04-setting-the-form-layout/util/date.js b/code/04-setting-the-form-layout/util/date.js
new file mode 100644
index 0000000..c666432
--- /dev/null
+++ b/code/04-setting-the-form-layout/util/date.js
@@ -0,0 +1,7 @@
+export function getFormattedDate(date) {
+ return `${date.getFullYear()}-${date.getMonth() + 1}-${date.getDate()}`;
+}
+
+export function getDateMinusDays(date, days) {
+ return new Date(date.getFullYear(), date.getMonth(), date.getDate() - days);
+}
diff --git a/code/05-handling-user-input-generic/App.js b/code/05-handling-user-input-generic/App.js
new file mode 100644
index 0000000..8c08ae1
--- /dev/null
+++ b/code/05-handling-user-input-generic/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-handling-user-input-generic/app.json b/code/05-handling-user-input-generic/app.json
new file mode 100644
index 0000000..9a1223e
--- /dev/null
+++ b/code/05-handling-user-input-generic/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-handling-user-input-generic/assets/adaptive-icon.png b/code/05-handling-user-input-generic/assets/adaptive-icon.png
new file mode 100644
index 0000000..03d6f6b
Binary files /dev/null and b/code/05-handling-user-input-generic/assets/adaptive-icon.png differ
diff --git a/code/05-handling-user-input-generic/assets/favicon.png b/code/05-handling-user-input-generic/assets/favicon.png
new file mode 100644
index 0000000..e75f697
Binary files /dev/null and b/code/05-handling-user-input-generic/assets/favicon.png differ
diff --git a/code/05-handling-user-input-generic/assets/icon.png b/code/05-handling-user-input-generic/assets/icon.png
new file mode 100644
index 0000000..a0b1526
Binary files /dev/null and b/code/05-handling-user-input-generic/assets/icon.png differ
diff --git a/code/05-handling-user-input-generic/assets/splash.png b/code/05-handling-user-input-generic/assets/splash.png
new file mode 100644
index 0000000..0e89705
Binary files /dev/null and b/code/05-handling-user-input-generic/assets/splash.png differ
diff --git a/code/05-handling-user-input-generic/babel.config.js b/code/05-handling-user-input-generic/babel.config.js
new file mode 100644
index 0000000..2900afe
--- /dev/null
+++ b/code/05-handling-user-input-generic/babel.config.js
@@ -0,0 +1,6 @@
+module.exports = function(api) {
+ api.cache(true);
+ return {
+ presets: ['babel-preset-expo'],
+ };
+};
diff --git a/code/05-handling-user-input-generic/components/ExpensesOutput/ExpenseItem.js b/code/05-handling-user-input-generic/components/ExpensesOutput/ExpenseItem.js
new file mode 100644
index 0000000..e6b52bd
--- /dev/null
+++ b/code/05-handling-user-input-generic/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-handling-user-input-generic/components/ExpensesOutput/ExpensesList.js b/code/05-handling-user-input-generic/components/ExpensesOutput/ExpensesList.js
new file mode 100644
index 0000000..771be21
--- /dev/null
+++ b/code/05-handling-user-input-generic/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-handling-user-input-generic/components/ExpensesOutput/ExpensesOutput.js b/code/05-handling-user-input-generic/components/ExpensesOutput/ExpensesOutput.js
new file mode 100644
index 0000000..e070127
--- /dev/null
+++ b/code/05-handling-user-input-generic/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-handling-user-input-generic/components/ExpensesOutput/ExpensesSummary.js b/code/05-handling-user-input-generic/components/ExpensesOutput/ExpensesSummary.js
new file mode 100644
index 0000000..27ec4ba
--- /dev/null
+++ b/code/05-handling-user-input-generic/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-handling-user-input-generic/components/ManageExpense/ExpenseForm.js b/code/05-handling-user-input-generic/components/ManageExpense/ExpenseForm.js
new file mode 100644
index 0000000..78add88
--- /dev/null
+++ b/code/05-handling-user-input-generic/components/ManageExpense/ExpenseForm.js
@@ -0,0 +1,80 @@
+import { useState } from 'react';
+import { StyleSheet, Text, View } from 'react-native';
+
+import Input from './Input';
+
+function ExpenseForm() {
+ const [inputValues, setInputValues] = useState({
+ amount: '',
+ date: '',
+ description: '',
+ });
+
+ function inputChangedHandler(inputIdentifier, enteredValue) {
+ setInputValues((curInputValues) => {
+ return {
+ ...curInputValues,
+ [inputIdentifier]: enteredValue,
+ };
+ });
+ }
+
+ return (
+
+ Your Expense
+
+
+
+
+
+
+ );
+}
+
+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,
+ },
+});
diff --git a/code/05-handling-user-input-generic/components/ManageExpense/Input.js b/code/05-handling-user-input-generic/components/ManageExpense/Input.js
new file mode 100644
index 0000000..3a889ad
--- /dev/null
+++ b/code/05-handling-user-input-generic/components/ManageExpense/Input.js
@@ -0,0 +1,44 @@
+import { StyleSheet, Text, TextInput, View } from 'react-native';
+
+import { GlobalStyles } from '../../constants/styles';
+
+function Input({ label, style, textInputConfig }) {
+
+ const inputStyles = [styles.input];
+
+ if (textInputConfig && textInputConfig.multiline) {
+ inputStyles.push(styles.inputMultiline)
+ }
+
+ 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'
+ }
+});
diff --git a/code/05-handling-user-input-generic/components/UI/Button.js b/code/05-handling-user-input-generic/components/UI/Button.js
new file mode 100644
index 0000000..48b6a1e
--- /dev/null
+++ b/code/05-handling-user-input-generic/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-handling-user-input-generic/components/UI/IconButton.js b/code/05-handling-user-input-generic/components/UI/IconButton.js
new file mode 100644
index 0000000..cc717c9
--- /dev/null
+++ b/code/05-handling-user-input-generic/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-handling-user-input-generic/constants/styles.js b/code/05-handling-user-input-generic/constants/styles.js
new file mode 100644
index 0000000..29faff1
--- /dev/null
+++ b/code/05-handling-user-input-generic/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-handling-user-input-generic/package.json b/code/05-handling-user-input-generic/package.json
new file mode 100644
index 0000000..10eddad
--- /dev/null
+++ b/code/05-handling-user-input-generic/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/05-handling-user-input-generic/screens/AllExpenses.js b/code/05-handling-user-input-generic/screens/AllExpenses.js
new file mode 100644
index 0000000..b0838e6
--- /dev/null
+++ b/code/05-handling-user-input-generic/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-handling-user-input-generic/screens/ManageExpense.js b/code/05-handling-user-input-generic/screens/ManageExpense.js
new file mode 100644
index 0000000..b46be3c
--- /dev/null
+++ b/code/05-handling-user-input-generic/screens/ManageExpense.js
@@ -0,0 +1,100 @@
+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;
+
+ useLayoutEffect(() => {
+ navigation.setOptions({
+ title: isEditing ? 'Edit Expense' : 'Add Expense',
+ });
+ }, [navigation, isEditing]);
+
+ function deleteExpenseHandler() {
+ expensesCtx.deleteExpense(editedExpenseId);
+ navigation.goBack();
+ }
+
+ function cancelHandler() {
+ navigation.goBack();
+ }
+
+ function confirmHandler() {
+ if (isEditing) {
+ expensesCtx.updateExpense(
+ editedExpenseId,
+ {
+ description: 'Test!!!!',
+ amount: 29.99,
+ date: new Date('2022-05-20'),
+ }
+ );
+ } else {
+ expensesCtx.addExpense({
+ description: 'Test',
+ amount: 19.99,
+ date: new Date('2022-05-19'),
+ });
+ }
+ navigation.goBack();
+ }
+
+ return (
+
+
+
+
+
+
+ {isEditing && (
+
+
+
+ )}
+
+ );
+}
+
+export default ManageExpense;
+
+const styles = StyleSheet.create({
+ container: {
+ flex: 1,
+ padding: 24,
+ backgroundColor: GlobalStyles.colors.primary800,
+ },
+ buttons: {
+ flexDirection: 'row',
+ justifyContent: 'center',
+ alignItems: 'center',
+ },
+ button: {
+ minWidth: 120,
+ marginHorizontal: 8,
+ },
+ deleteContainer: {
+ marginTop: 16,
+ paddingTop: 8,
+ borderTopWidth: 2,
+ borderTopColor: GlobalStyles.colors.primary200,
+ alignItems: 'center',
+ },
+});
diff --git a/code/05-handling-user-input-generic/screens/RecentExpenses.js b/code/05-handling-user-input-generic/screens/RecentExpenses.js
new file mode 100644
index 0000000..5ba4732
--- /dev/null
+++ b/code/05-handling-user-input-generic/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/05-handling-user-input-generic/store/expenses-context.js b/code/05-handling-user-input-generic/store/expenses-context.js
new file mode 100644
index 0000000..8d69741
--- /dev/null
+++ b/code/05-handling-user-input-generic/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/05-handling-user-input-generic/util/date.js b/code/05-handling-user-input-generic/util/date.js
new file mode 100644
index 0000000..c666432
--- /dev/null
+++ b/code/05-handling-user-input-generic/util/date.js
@@ -0,0 +1,7 @@
+export function getFormattedDate(date) {
+ return `${date.getFullYear()}-${date.getMonth() + 1}-${date.getDate()}`;
+}
+
+export function getDateMinusDays(date, days) {
+ return new Date(date.getFullYear(), date.getMonth(), date.getDate() - days);
+}
diff --git a/code/06-managing-form-state-and-submission/App.js b/code/06-managing-form-state-and-submission/App.js
new file mode 100644
index 0000000..8c08ae1
--- /dev/null
+++ b/code/06-managing-form-state-and-submission/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-form-state-and-submission/app.json b/code/06-managing-form-state-and-submission/app.json
new file mode 100644
index 0000000..9a1223e
--- /dev/null
+++ b/code/06-managing-form-state-and-submission/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-form-state-and-submission/assets/adaptive-icon.png b/code/06-managing-form-state-and-submission/assets/adaptive-icon.png
new file mode 100644
index 0000000..03d6f6b
Binary files /dev/null and b/code/06-managing-form-state-and-submission/assets/adaptive-icon.png differ
diff --git a/code/06-managing-form-state-and-submission/assets/favicon.png b/code/06-managing-form-state-and-submission/assets/favicon.png
new file mode 100644
index 0000000..e75f697
Binary files /dev/null and b/code/06-managing-form-state-and-submission/assets/favicon.png differ
diff --git a/code/06-managing-form-state-and-submission/assets/icon.png b/code/06-managing-form-state-and-submission/assets/icon.png
new file mode 100644
index 0000000..a0b1526
Binary files /dev/null and b/code/06-managing-form-state-and-submission/assets/icon.png differ
diff --git a/code/06-managing-form-state-and-submission/assets/splash.png b/code/06-managing-form-state-and-submission/assets/splash.png
new file mode 100644
index 0000000..0e89705
Binary files /dev/null and b/code/06-managing-form-state-and-submission/assets/splash.png differ
diff --git a/code/06-managing-form-state-and-submission/babel.config.js b/code/06-managing-form-state-and-submission/babel.config.js
new file mode 100644
index 0000000..2900afe
--- /dev/null
+++ b/code/06-managing-form-state-and-submission/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-form-state-and-submission/components/ExpensesOutput/ExpenseItem.js b/code/06-managing-form-state-and-submission/components/ExpensesOutput/ExpenseItem.js
new file mode 100644
index 0000000..e6b52bd
--- /dev/null
+++ b/code/06-managing-form-state-and-submission/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-form-state-and-submission/components/ExpensesOutput/ExpensesList.js b/code/06-managing-form-state-and-submission/components/ExpensesOutput/ExpensesList.js
new file mode 100644
index 0000000..771be21
--- /dev/null
+++ b/code/06-managing-form-state-and-submission/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-form-state-and-submission/components/ExpensesOutput/ExpensesOutput.js b/code/06-managing-form-state-and-submission/components/ExpensesOutput/ExpensesOutput.js
new file mode 100644
index 0000000..e070127
--- /dev/null
+++ b/code/06-managing-form-state-and-submission/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-form-state-and-submission/components/ExpensesOutput/ExpensesSummary.js b/code/06-managing-form-state-and-submission/components/ExpensesOutput/ExpensesSummary.js
new file mode 100644
index 0000000..27ec4ba
--- /dev/null
+++ b/code/06-managing-form-state-and-submission/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-form-state-and-submission/components/ManageExpense/ExpenseForm.js b/code/06-managing-form-state-and-submission/components/ManageExpense/ExpenseForm.js
new file mode 100644
index 0000000..dcb5e8e
--- /dev/null
+++ b/code/06-managing-form-state-and-submission/components/ManageExpense/ExpenseForm.js
@@ -0,0 +1,108 @@
+import { useState } from 'react';
+import { StyleSheet, Text, View } from 'react-native';
+
+import Input from './Input';
+import Button from '../UI/Button';
+
+function ExpenseForm({ submitButtonLabel, onCancel, onSubmit }) {
+ const [inputValues, setInputValues] = useState({
+ amount: '',
+ date: '',
+ description: '',
+ });
+
+ function inputChangedHandler(inputIdentifier, enteredValue) {
+ setInputValues((curInputValues) => {
+ return {
+ ...curInputValues,
+ [inputIdentifier]: enteredValue,
+ };
+ });
+ }
+
+ function submitHandler() {
+ const expenseData = {
+ amount: +inputValues.amount,
+ date: new Date(inputValues.date),
+ description: inputValues.description
+ };
+
+ onSubmit(expenseData);
+ }
+
+ return (
+
+ Your Expense
+
+
+
+
+
+
+
+
+
+
+ );
+}
+
+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,
+ },
+ buttons: {
+ flexDirection: 'row',
+ justifyContent: 'center',
+ alignItems: 'center',
+ },
+ button: {
+ minWidth: 120,
+ marginHorizontal: 8,
+ },
+});
diff --git a/code/06-managing-form-state-and-submission/components/ManageExpense/Input.js b/code/06-managing-form-state-and-submission/components/ManageExpense/Input.js
new file mode 100644
index 0000000..3a889ad
--- /dev/null
+++ b/code/06-managing-form-state-and-submission/components/ManageExpense/Input.js
@@ -0,0 +1,44 @@
+import { StyleSheet, Text, TextInput, View } from 'react-native';
+
+import { GlobalStyles } from '../../constants/styles';
+
+function Input({ label, style, textInputConfig }) {
+
+ const inputStyles = [styles.input];
+
+ if (textInputConfig && textInputConfig.multiline) {
+ inputStyles.push(styles.inputMultiline)
+ }
+
+ 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'
+ }
+});
diff --git a/code/06-managing-form-state-and-submission/components/UI/Button.js b/code/06-managing-form-state-and-submission/components/UI/Button.js
new file mode 100644
index 0000000..48b6a1e
--- /dev/null
+++ b/code/06-managing-form-state-and-submission/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-form-state-and-submission/components/UI/IconButton.js b/code/06-managing-form-state-and-submission/components/UI/IconButton.js
new file mode 100644
index 0000000..cc717c9
--- /dev/null
+++ b/code/06-managing-form-state-and-submission/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-form-state-and-submission/constants/styles.js b/code/06-managing-form-state-and-submission/constants/styles.js
new file mode 100644
index 0000000..29faff1
--- /dev/null
+++ b/code/06-managing-form-state-and-submission/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-form-state-and-submission/package.json b/code/06-managing-form-state-and-submission/package.json
new file mode 100644
index 0000000..10eddad
--- /dev/null
+++ b/code/06-managing-form-state-and-submission/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/06-managing-form-state-and-submission/screens/AllExpenses.js b/code/06-managing-form-state-and-submission/screens/AllExpenses.js
new file mode 100644
index 0000000..b0838e6
--- /dev/null
+++ b/code/06-managing-form-state-and-submission/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-form-state-and-submission/screens/ManageExpense.js b/code/06-managing-form-state-and-submission/screens/ManageExpense.js
new file mode 100644
index 0000000..eb044c9
--- /dev/null
+++ b/code/06-managing-form-state-and-submission/screens/ManageExpense.js
@@ -0,0 +1,76 @@
+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;
+
+ 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/06-managing-form-state-and-submission/screens/RecentExpenses.js b/code/06-managing-form-state-and-submission/screens/RecentExpenses.js
new file mode 100644
index 0000000..5ba4732
--- /dev/null
+++ b/code/06-managing-form-state-and-submission/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/06-managing-form-state-and-submission/store/expenses-context.js b/code/06-managing-form-state-and-submission/store/expenses-context.js
new file mode 100644
index 0000000..8d69741
--- /dev/null
+++ b/code/06-managing-form-state-and-submission/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/06-managing-form-state-and-submission/util/date.js b/code/06-managing-form-state-and-submission/util/date.js
new file mode 100644
index 0000000..c666432
--- /dev/null
+++ b/code/06-managing-form-state-and-submission/util/date.js
@@ -0,0 +1,7 @@
+export function getFormattedDate(date) {
+ return `${date.getFullYear()}-${date.getMonth() + 1}-${date.getDate()}`;
+}
+
+export function getDateMinusDays(date, days) {
+ return new Date(date.getFullYear(), date.getMonth(), date.getDate() - days);
+}
diff --git a/code/07-setting-using-default-values/App.js b/code/07-setting-using-default-values/App.js
new file mode 100644
index 0000000..8c08ae1
--- /dev/null
+++ b/code/07-setting-using-default-values/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-setting-using-default-values/app.json b/code/07-setting-using-default-values/app.json
new file mode 100644
index 0000000..9a1223e
--- /dev/null
+++ b/code/07-setting-using-default-values/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-setting-using-default-values/assets/adaptive-icon.png b/code/07-setting-using-default-values/assets/adaptive-icon.png
new file mode 100644
index 0000000..03d6f6b
Binary files /dev/null and b/code/07-setting-using-default-values/assets/adaptive-icon.png differ
diff --git a/code/07-setting-using-default-values/assets/favicon.png b/code/07-setting-using-default-values/assets/favicon.png
new file mode 100644
index 0000000..e75f697
Binary files /dev/null and b/code/07-setting-using-default-values/assets/favicon.png differ
diff --git a/code/07-setting-using-default-values/assets/icon.png b/code/07-setting-using-default-values/assets/icon.png
new file mode 100644
index 0000000..a0b1526
Binary files /dev/null and b/code/07-setting-using-default-values/assets/icon.png differ
diff --git a/code/07-setting-using-default-values/assets/splash.png b/code/07-setting-using-default-values/assets/splash.png
new file mode 100644
index 0000000..0e89705
Binary files /dev/null and b/code/07-setting-using-default-values/assets/splash.png differ
diff --git a/code/07-setting-using-default-values/babel.config.js b/code/07-setting-using-default-values/babel.config.js
new file mode 100644
index 0000000..2900afe
--- /dev/null
+++ b/code/07-setting-using-default-values/babel.config.js
@@ -0,0 +1,6 @@
+module.exports = function(api) {
+ api.cache(true);
+ return {
+ presets: ['babel-preset-expo'],
+ };
+};
diff --git a/code/07-setting-using-default-values/components/ExpensesOutput/ExpenseItem.js b/code/07-setting-using-default-values/components/ExpensesOutput/ExpenseItem.js
new file mode 100644
index 0000000..e6b52bd
--- /dev/null
+++ b/code/07-setting-using-default-values/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-setting-using-default-values/components/ExpensesOutput/ExpensesList.js b/code/07-setting-using-default-values/components/ExpensesOutput/ExpensesList.js
new file mode 100644
index 0000000..771be21
--- /dev/null
+++ b/code/07-setting-using-default-values/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-setting-using-default-values/components/ExpensesOutput/ExpensesOutput.js b/code/07-setting-using-default-values/components/ExpensesOutput/ExpensesOutput.js
new file mode 100644
index 0000000..e070127
--- /dev/null
+++ b/code/07-setting-using-default-values/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-setting-using-default-values/components/ExpensesOutput/ExpensesSummary.js b/code/07-setting-using-default-values/components/ExpensesOutput/ExpensesSummary.js
new file mode 100644
index 0000000..27ec4ba
--- /dev/null
+++ b/code/07-setting-using-default-values/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-setting-using-default-values/components/ManageExpense/ExpenseForm.js b/code/07-setting-using-default-values/components/ManageExpense/ExpenseForm.js
new file mode 100644
index 0000000..6f9f491
--- /dev/null
+++ b/code/07-setting-using-default-values/components/ManageExpense/ExpenseForm.js
@@ -0,0 +1,109 @@
+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';
+
+function ExpenseForm({ submitButtonLabel, onCancel, onSubmit, defaultValues }) {
+ const [inputValues, setInputValues] = useState({
+ amount: defaultValues ? defaultValues.amount.toString() : '',
+ date: defaultValues ? getFormattedDate(defaultValues.date) : '',
+ description: defaultValues ? defaultValues.description : '',
+ });
+
+ function inputChangedHandler(inputIdentifier, enteredValue) {
+ setInputValues((curInputValues) => {
+ return {
+ ...curInputValues,
+ [inputIdentifier]: enteredValue,
+ };
+ });
+ }
+
+ function submitHandler() {
+ const expenseData = {
+ amount: +inputValues.amount,
+ date: new Date(inputValues.date),
+ description: inputValues.description,
+ };
+
+ onSubmit(expenseData);
+ }
+
+ return (
+
+ Your Expense
+
+
+
+
+
+
+
+
+
+
+ );
+}
+
+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,
+ },
+ buttons: {
+ flexDirection: 'row',
+ justifyContent: 'center',
+ alignItems: 'center',
+ },
+ button: {
+ minWidth: 120,
+ marginHorizontal: 8,
+ },
+});
diff --git a/code/07-setting-using-default-values/components/ManageExpense/Input.js b/code/07-setting-using-default-values/components/ManageExpense/Input.js
new file mode 100644
index 0000000..3a889ad
--- /dev/null
+++ b/code/07-setting-using-default-values/components/ManageExpense/Input.js
@@ -0,0 +1,44 @@
+import { StyleSheet, Text, TextInput, View } from 'react-native';
+
+import { GlobalStyles } from '../../constants/styles';
+
+function Input({ label, style, textInputConfig }) {
+
+ const inputStyles = [styles.input];
+
+ if (textInputConfig && textInputConfig.multiline) {
+ inputStyles.push(styles.inputMultiline)
+ }
+
+ 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'
+ }
+});
diff --git a/code/07-setting-using-default-values/components/UI/Button.js b/code/07-setting-using-default-values/components/UI/Button.js
new file mode 100644
index 0000000..48b6a1e
--- /dev/null
+++ b/code/07-setting-using-default-values/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-setting-using-default-values/components/UI/IconButton.js b/code/07-setting-using-default-values/components/UI/IconButton.js
new file mode 100644
index 0000000..cc717c9
--- /dev/null
+++ b/code/07-setting-using-default-values/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-setting-using-default-values/constants/styles.js b/code/07-setting-using-default-values/constants/styles.js
new file mode 100644
index 0000000..29faff1
--- /dev/null
+++ b/code/07-setting-using-default-values/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-setting-using-default-values/package.json b/code/07-setting-using-default-values/package.json
new file mode 100644
index 0000000..10eddad
--- /dev/null
+++ b/code/07-setting-using-default-values/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/07-setting-using-default-values/screens/AllExpenses.js b/code/07-setting-using-default-values/screens/AllExpenses.js
new file mode 100644
index 0000000..b0838e6
--- /dev/null
+++ b/code/07-setting-using-default-values/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-setting-using-default-values/screens/ManageExpense.js b/code/07-setting-using-default-values/screens/ManageExpense.js
new file mode 100644
index 0000000..80e90ad
--- /dev/null
+++ b/code/07-setting-using-default-values/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/07-setting-using-default-values/screens/RecentExpenses.js b/code/07-setting-using-default-values/screens/RecentExpenses.js
new file mode 100644
index 0000000..5ba4732
--- /dev/null
+++ b/code/07-setting-using-default-values/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/07-setting-using-default-values/store/expenses-context.js b/code/07-setting-using-default-values/store/expenses-context.js
new file mode 100644
index 0000000..8d69741
--- /dev/null
+++ b/code/07-setting-using-default-values/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/07-setting-using-default-values/util/date.js b/code/07-setting-using-default-values/util/date.js
new file mode 100644
index 0000000..28185a4
--- /dev/null
+++ b/code/07-setting-using-default-values/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/08-adding-validation/App.js b/code/08-adding-validation/App.js
new file mode 100644
index 0000000..8c08ae1
--- /dev/null
+++ b/code/08-adding-validation/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/08-adding-validation/app.json b/code/08-adding-validation/app.json
new file mode 100644
index 0000000..9a1223e
--- /dev/null
+++ b/code/08-adding-validation/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/08-adding-validation/assets/adaptive-icon.png b/code/08-adding-validation/assets/adaptive-icon.png
new file mode 100644
index 0000000..03d6f6b
Binary files /dev/null and b/code/08-adding-validation/assets/adaptive-icon.png differ
diff --git a/code/08-adding-validation/assets/favicon.png b/code/08-adding-validation/assets/favicon.png
new file mode 100644
index 0000000..e75f697
Binary files /dev/null and b/code/08-adding-validation/assets/favicon.png differ
diff --git a/code/08-adding-validation/assets/icon.png b/code/08-adding-validation/assets/icon.png
new file mode 100644
index 0000000..a0b1526
Binary files /dev/null and b/code/08-adding-validation/assets/icon.png differ
diff --git a/code/08-adding-validation/assets/splash.png b/code/08-adding-validation/assets/splash.png
new file mode 100644
index 0000000..0e89705
Binary files /dev/null and b/code/08-adding-validation/assets/splash.png differ
diff --git a/code/08-adding-validation/babel.config.js b/code/08-adding-validation/babel.config.js
new file mode 100644
index 0000000..2900afe
--- /dev/null
+++ b/code/08-adding-validation/babel.config.js
@@ -0,0 +1,6 @@
+module.exports = function(api) {
+ api.cache(true);
+ return {
+ presets: ['babel-preset-expo'],
+ };
+};
diff --git a/code/08-adding-validation/components/ExpensesOutput/ExpenseItem.js b/code/08-adding-validation/components/ExpensesOutput/ExpenseItem.js
new file mode 100644
index 0000000..e6b52bd
--- /dev/null
+++ b/code/08-adding-validation/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/08-adding-validation/components/ExpensesOutput/ExpensesList.js b/code/08-adding-validation/components/ExpensesOutput/ExpensesList.js
new file mode 100644
index 0000000..771be21
--- /dev/null
+++ b/code/08-adding-validation/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/08-adding-validation/components/ExpensesOutput/ExpensesOutput.js b/code/08-adding-validation/components/ExpensesOutput/ExpensesOutput.js
new file mode 100644
index 0000000..e070127
--- /dev/null
+++ b/code/08-adding-validation/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/08-adding-validation/components/ExpensesOutput/ExpensesSummary.js b/code/08-adding-validation/components/ExpensesOutput/ExpensesSummary.js
new file mode 100644
index 0000000..27ec4ba
--- /dev/null
+++ b/code/08-adding-validation/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/08-adding-validation/components/ManageExpense/ExpenseForm.js b/code/08-adding-validation/components/ManageExpense/ExpenseForm.js
new file mode 100644
index 0000000..3b13de0
--- /dev/null
+++ b/code/08-adding-validation/components/ManageExpense/ExpenseForm.js
@@ -0,0 +1,145 @@
+import { useState } from 'react';
+import { StyleSheet, Text, View, Alert } from 'react-native';
+
+import Input from './Input';
+import Button from '../UI/Button';
+import { getFormattedDate } from '../../util/date';
+
+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,
+ },
+ buttons: {
+ flexDirection: 'row',
+ justifyContent: 'center',
+ alignItems: 'center',
+ },
+ button: {
+ minWidth: 120,
+ marginHorizontal: 8,
+ },
+});
diff --git a/code/08-adding-validation/components/ManageExpense/Input.js b/code/08-adding-validation/components/ManageExpense/Input.js
new file mode 100644
index 0000000..3a889ad
--- /dev/null
+++ b/code/08-adding-validation/components/ManageExpense/Input.js
@@ -0,0 +1,44 @@
+import { StyleSheet, Text, TextInput, View } from 'react-native';
+
+import { GlobalStyles } from '../../constants/styles';
+
+function Input({ label, style, textInputConfig }) {
+
+ const inputStyles = [styles.input];
+
+ if (textInputConfig && textInputConfig.multiline) {
+ inputStyles.push(styles.inputMultiline)
+ }
+
+ 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'
+ }
+});
diff --git a/code/08-adding-validation/components/UI/Button.js b/code/08-adding-validation/components/UI/Button.js
new file mode 100644
index 0000000..48b6a1e
--- /dev/null
+++ b/code/08-adding-validation/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/08-adding-validation/components/UI/IconButton.js b/code/08-adding-validation/components/UI/IconButton.js
new file mode 100644
index 0000000..cc717c9
--- /dev/null
+++ b/code/08-adding-validation/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/08-adding-validation/constants/styles.js b/code/08-adding-validation/constants/styles.js
new file mode 100644
index 0000000..29faff1
--- /dev/null
+++ b/code/08-adding-validation/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/08-adding-validation/package.json b/code/08-adding-validation/package.json
new file mode 100644
index 0000000..10eddad
--- /dev/null
+++ b/code/08-adding-validation/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/08-adding-validation/screens/AllExpenses.js b/code/08-adding-validation/screens/AllExpenses.js
new file mode 100644
index 0000000..b0838e6
--- /dev/null
+++ b/code/08-adding-validation/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/08-adding-validation/screens/ManageExpense.js b/code/08-adding-validation/screens/ManageExpense.js
new file mode 100644
index 0000000..80e90ad
--- /dev/null
+++ b/code/08-adding-validation/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/08-adding-validation/screens/RecentExpenses.js b/code/08-adding-validation/screens/RecentExpenses.js
new file mode 100644
index 0000000..5ba4732
--- /dev/null
+++ b/code/08-adding-validation/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/08-adding-validation/store/expenses-context.js b/code/08-adding-validation/store/expenses-context.js
new file mode 100644
index 0000000..8d69741
--- /dev/null
+++ b/code/08-adding-validation/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/08-adding-validation/util/date.js b/code/08-adding-validation/util/date.js
new file mode 100644
index 0000000..28185a4
--- /dev/null
+++ b/code/08-adding-validation/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/09-adding-error-styling/App.js b/code/09-adding-error-styling/App.js
new file mode 100644
index 0000000..8c08ae1
--- /dev/null
+++ b/code/09-adding-error-styling/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/09-adding-error-styling/app.json b/code/09-adding-error-styling/app.json
new file mode 100644
index 0000000..9a1223e
--- /dev/null
+++ b/code/09-adding-error-styling/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/09-adding-error-styling/assets/adaptive-icon.png b/code/09-adding-error-styling/assets/adaptive-icon.png
new file mode 100644
index 0000000..03d6f6b
Binary files /dev/null and b/code/09-adding-error-styling/assets/adaptive-icon.png differ
diff --git a/code/09-adding-error-styling/assets/favicon.png b/code/09-adding-error-styling/assets/favicon.png
new file mode 100644
index 0000000..e75f697
Binary files /dev/null and b/code/09-adding-error-styling/assets/favicon.png differ
diff --git a/code/09-adding-error-styling/assets/icon.png b/code/09-adding-error-styling/assets/icon.png
new file mode 100644
index 0000000..a0b1526
Binary files /dev/null and b/code/09-adding-error-styling/assets/icon.png differ
diff --git a/code/09-adding-error-styling/assets/splash.png b/code/09-adding-error-styling/assets/splash.png
new file mode 100644
index 0000000..0e89705
Binary files /dev/null and b/code/09-adding-error-styling/assets/splash.png differ
diff --git a/code/09-adding-error-styling/babel.config.js b/code/09-adding-error-styling/babel.config.js
new file mode 100644
index 0000000..2900afe
--- /dev/null
+++ b/code/09-adding-error-styling/babel.config.js
@@ -0,0 +1,6 @@
+module.exports = function(api) {
+ api.cache(true);
+ return {
+ presets: ['babel-preset-expo'],
+ };
+};
diff --git a/code/09-adding-error-styling/components/ExpensesOutput/ExpenseItem.js b/code/09-adding-error-styling/components/ExpensesOutput/ExpenseItem.js
new file mode 100644
index 0000000..e6b52bd
--- /dev/null
+++ b/code/09-adding-error-styling/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/09-adding-error-styling/components/ExpensesOutput/ExpensesList.js b/code/09-adding-error-styling/components/ExpensesOutput/ExpensesList.js
new file mode 100644
index 0000000..771be21
--- /dev/null
+++ b/code/09-adding-error-styling/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/09-adding-error-styling/components/ExpensesOutput/ExpensesOutput.js b/code/09-adding-error-styling/components/ExpensesOutput/ExpensesOutput.js
new file mode 100644
index 0000000..e070127
--- /dev/null
+++ b/code/09-adding-error-styling/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/09-adding-error-styling/components/ExpensesOutput/ExpensesSummary.js b/code/09-adding-error-styling/components/ExpensesOutput/ExpensesSummary.js
new file mode 100644
index 0000000..27ec4ba
--- /dev/null
+++ b/code/09-adding-error-styling/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/09-adding-error-styling/components/ManageExpense/ExpenseForm.js b/code/09-adding-error-styling/components/ManageExpense/ExpenseForm.js
new file mode 100644
index 0000000..a4147da
--- /dev/null
+++ b/code/09-adding-error-styling/components/ManageExpense/ExpenseForm.js
@@ -0,0 +1,156 @@
+import { useState } from 'react';
+import { StyleSheet, Text, View, Alert } 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/09-adding-error-styling/components/ManageExpense/Input.js b/code/09-adding-error-styling/components/ManageExpense/Input.js
new file mode 100644
index 0000000..8c0537f
--- /dev/null
+++ b/code/09-adding-error-styling/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/09-adding-error-styling/components/UI/Button.js b/code/09-adding-error-styling/components/UI/Button.js
new file mode 100644
index 0000000..48b6a1e
--- /dev/null
+++ b/code/09-adding-error-styling/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/09-adding-error-styling/components/UI/IconButton.js b/code/09-adding-error-styling/components/UI/IconButton.js
new file mode 100644
index 0000000..cc717c9
--- /dev/null
+++ b/code/09-adding-error-styling/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/09-adding-error-styling/constants/styles.js b/code/09-adding-error-styling/constants/styles.js
new file mode 100644
index 0000000..29faff1
--- /dev/null
+++ b/code/09-adding-error-styling/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/09-adding-error-styling/package.json b/code/09-adding-error-styling/package.json
new file mode 100644
index 0000000..10eddad
--- /dev/null
+++ b/code/09-adding-error-styling/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/09-adding-error-styling/screens/AllExpenses.js b/code/09-adding-error-styling/screens/AllExpenses.js
new file mode 100644
index 0000000..b0838e6
--- /dev/null
+++ b/code/09-adding-error-styling/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/09-adding-error-styling/screens/ManageExpense.js b/code/09-adding-error-styling/screens/ManageExpense.js
new file mode 100644
index 0000000..80e90ad
--- /dev/null
+++ b/code/09-adding-error-styling/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/09-adding-error-styling/screens/RecentExpenses.js b/code/09-adding-error-styling/screens/RecentExpenses.js
new file mode 100644
index 0000000..5ba4732
--- /dev/null
+++ b/code/09-adding-error-styling/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/09-adding-error-styling/store/expenses-context.js b/code/09-adding-error-styling/store/expenses-context.js
new file mode 100644
index 0000000..8d69741
--- /dev/null
+++ b/code/09-adding-error-styling/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/09-adding-error-styling/util/date.js b/code/09-adding-error-styling/util/date.js
new file mode 100644
index 0000000..28185a4
--- /dev/null
+++ b/code/09-adding-error-styling/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/10-finished/App.js b/code/10-finished/App.js
new file mode 100644
index 0000000..8c08ae1
--- /dev/null
+++ b/code/10-finished/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/10-finished/app.json b/code/10-finished/app.json
new file mode 100644
index 0000000..9a1223e
--- /dev/null
+++ b/code/10-finished/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/10-finished/assets/adaptive-icon.png b/code/10-finished/assets/adaptive-icon.png
new file mode 100644
index 0000000..03d6f6b
Binary files /dev/null and b/code/10-finished/assets/adaptive-icon.png differ
diff --git a/code/10-finished/assets/favicon.png b/code/10-finished/assets/favicon.png
new file mode 100644
index 0000000..e75f697
Binary files /dev/null and b/code/10-finished/assets/favicon.png differ
diff --git a/code/10-finished/assets/icon.png b/code/10-finished/assets/icon.png
new file mode 100644
index 0000000..a0b1526
Binary files /dev/null and b/code/10-finished/assets/icon.png differ
diff --git a/code/10-finished/assets/splash.png b/code/10-finished/assets/splash.png
new file mode 100644
index 0000000..0e89705
Binary files /dev/null and b/code/10-finished/assets/splash.png differ
diff --git a/code/10-finished/babel.config.js b/code/10-finished/babel.config.js
new file mode 100644
index 0000000..2900afe
--- /dev/null
+++ b/code/10-finished/babel.config.js
@@ -0,0 +1,6 @@
+module.exports = function(api) {
+ api.cache(true);
+ return {
+ presets: ['babel-preset-expo'],
+ };
+};
diff --git a/code/10-finished/components/ExpensesOutput/ExpenseItem.js b/code/10-finished/components/ExpensesOutput/ExpenseItem.js
new file mode 100644
index 0000000..e6b52bd
--- /dev/null
+++ b/code/10-finished/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/10-finished/components/ExpensesOutput/ExpensesList.js b/code/10-finished/components/ExpensesOutput/ExpensesList.js
new file mode 100644
index 0000000..771be21
--- /dev/null
+++ b/code/10-finished/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/10-finished/components/ExpensesOutput/ExpensesOutput.js b/code/10-finished/components/ExpensesOutput/ExpensesOutput.js
new file mode 100644
index 0000000..e070127
--- /dev/null
+++ b/code/10-finished/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/10-finished/components/ExpensesOutput/ExpensesSummary.js b/code/10-finished/components/ExpensesOutput/ExpensesSummary.js
new file mode 100644
index 0000000..27ec4ba
--- /dev/null
+++ b/code/10-finished/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/10-finished/components/ManageExpense/ExpenseForm.js b/code/10-finished/components/ManageExpense/ExpenseForm.js
new file mode 100644
index 0000000..9a2f4cc
--- /dev/null
+++ b/code/10-finished/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/10-finished/components/ManageExpense/Input.js b/code/10-finished/components/ManageExpense/Input.js
new file mode 100644
index 0000000..8c0537f
--- /dev/null
+++ b/code/10-finished/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/10-finished/components/UI/Button.js b/code/10-finished/components/UI/Button.js
new file mode 100644
index 0000000..48b6a1e
--- /dev/null
+++ b/code/10-finished/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/10-finished/components/UI/IconButton.js b/code/10-finished/components/UI/IconButton.js
new file mode 100644
index 0000000..cc717c9
--- /dev/null
+++ b/code/10-finished/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/10-finished/constants/styles.js b/code/10-finished/constants/styles.js
new file mode 100644
index 0000000..29faff1
--- /dev/null
+++ b/code/10-finished/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/10-finished/package.json b/code/10-finished/package.json
new file mode 100644
index 0000000..10eddad
--- /dev/null
+++ b/code/10-finished/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/10-finished/screens/AllExpenses.js b/code/10-finished/screens/AllExpenses.js
new file mode 100644
index 0000000..b0838e6
--- /dev/null
+++ b/code/10-finished/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/10-finished/screens/ManageExpense.js b/code/10-finished/screens/ManageExpense.js
new file mode 100644
index 0000000..80e90ad
--- /dev/null
+++ b/code/10-finished/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/10-finished/screens/RecentExpenses.js b/code/10-finished/screens/RecentExpenses.js
new file mode 100644
index 0000000..5ba4732
--- /dev/null
+++ b/code/10-finished/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/10-finished/store/expenses-context.js b/code/10-finished/store/expenses-context.js
new file mode 100644
index 0000000..8d69741
--- /dev/null
+++ b/code/10-finished/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/10-finished/util/date.js b/code/10-finished/util/date.js
new file mode 100644
index 0000000..28185a4
--- /dev/null
+++ b/code/10-finished/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);
+}