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); +}