diff --git a/app/App.js b/app/App.js index 2e57661..63f2f42 100644 --- a/app/App.js +++ b/app/App.js @@ -1,12 +1,32 @@ import React from 'react' -import { View, Text, StyleSheet } from 'react-native' +import { MainNavigator } from './src/screens/MainNavigator' +import { StyleSheet, View, Text, ActivityIndicator } from 'react-native' +import { useConnection } from './src/hooks/useConnection' export default function App () { - return ( - - Welcome to the workshop! - - ) + const { connected, connectionError } = useConnection() + + // use splashscreen here, if you like + if (!connected) { + return ( + + + Connecting to our servers... + + ) + } + + // use alert or other things here, if you like + if (connectionError) { + return ( + + Error, while connecting to our servers! + {connectionError.message} + + ) + } + + return () } const styles = StyleSheet.create({ @@ -16,4 +36,4 @@ const styles = StyleSheet.create({ alignItems: 'center', justifyContent: 'center' } -}); +}) diff --git a/app/babel.config.js b/app/babel.config.js index 167ecad..fb6b196 100644 --- a/app/babel.config.js +++ b/app/babel.config.js @@ -1,7 +1,7 @@ -module.exports = function(api) { - api.cache(true); +module.exports = function (api) { + api.cache(true) return { presets: ['babel-preset-expo'], - plugins: ["module:react-native-dotenv"] - }; -}; + plugins: ['module:react-native-dotenv'] + } +} diff --git a/app/index.js b/app/index.js index 1d6e981..a8644b1 100644 --- a/app/index.js +++ b/app/index.js @@ -1,8 +1,8 @@ -import { registerRootComponent } from 'expo'; +import { registerRootComponent } from 'expo' -import App from './App'; +import App from './App' // registerRootComponent calls AppRegistry.registerComponent('main', () => App); // It also ensures that whether you load the app in Expo Go or in a native build, // the environment is set up appropriately -registerRootComponent(App); +registerRootComponent(App) diff --git a/app/metro.config.js b/app/metro.config.js index 9430b0f..4872a8d 100644 --- a/app/metro.config.js +++ b/app/metro.config.js @@ -1,4 +1,4 @@ // Learn more https://docs.expo.io/guides/customizing-metro -const { getDefaultConfig } = require('expo/metro-config'); +const { getDefaultConfig } = require('expo/metro-config') -module.exports = getDefaultConfig(__dirname); +module.exports = getDefaultConfig(__dirname) diff --git a/app/src/components/MyTasks.js b/app/src/components/MyTasks.js new file mode 100644 index 0000000..f787bd3 --- /dev/null +++ b/app/src/components/MyTasks.js @@ -0,0 +1,11 @@ +import React from 'react' +import { Text } from 'react-native' + +/** + * Here you can implement the logic to subscribe to your tasks and CRUD them. + * See: https://github.com/meteorrn/sample + * @param props + * @returns {JSX.Element} + * @constructor + */ +export const MyTasks = () => (My Tasks not yet implemented) diff --git a/app/src/contexts/AuthContext.js b/app/src/contexts/AuthContext.js new file mode 100644 index 0000000..dac991d --- /dev/null +++ b/app/src/contexts/AuthContext.js @@ -0,0 +1,11 @@ +import { createContext } from 'react' + +/** + * Our authentication context provides an API for our components + * that allows them to communicate with the servers in a decoupled way. + * @method signIn + * @method signUp + * @method signOut + * @type {React.Context} + */ +export const AuthContext = createContext() diff --git a/app/src/hooks/useConnection.js b/app/src/hooks/useConnection.js new file mode 100644 index 0000000..cb0bd57 --- /dev/null +++ b/app/src/hooks/useConnection.js @@ -0,0 +1,59 @@ +import { useEffect, useState } from 'react' +import Meteor from '@meteorrn/core' +import * as SecureStore from 'expo-secure-store' +import config from '../../config.json' + +// get detailed info about internals +Meteor.isVerbose = true + +// connect with Meteor and use a secure store +// to persist our received login token, so it's encrypted +// and only readable for this very app +// read more at: https://docs.expo.dev/versions/latest/sdk/securestore/ +Meteor.connect(config.backend.url, { + AsyncStorage: { + getItem: SecureStore.getItemAsync, + setItem: SecureStore.setItemAsync, + removeItem: SecureStore.deleteItemAsync + } +}) + +/** + * Hook that handle auto-reconnect and updates state accordingly. + * @return {{connected: boolean|null, connectionError: Error|null}} + */ +export const useConnection = () => { + const [connected, setConnected] = useState(null) + const [connectionError, setConnectionError] = useState(null) + + // we use separate functions as the handlers, so they get removed + // on unmount, which happens on auto-reload and would cause errors + // if not handled + useEffect(() => { + const onError = (e) => setConnectionError(e) + Meteor.ddp.on('error', onError) + + const onConnected = () => connected !== true && setConnected(true) + Meteor.ddp.on('connected', onConnected) + + // if the connection is lost, we not only switch the state + // but also force to reconnect to the server + const onDisconnected = () => { + Meteor.ddp.autoConnect = true + if (connected !== false) { + setConnected(false) + } + Meteor.reconnect() + } + Meteor.ddp.on('disconnected', onDisconnected) + + // remove all of these listeners on unmount + return () => { + Meteor.ddp.off('error', onError) + Meteor.ddp.off('connected', onConnected) + Meteor.ddp.off('disconnected', onDisconnected) + } + }, []) + + return { connected, connectionError } +} diff --git a/app/src/hooks/useLogin.js b/app/src/hooks/useLogin.js new file mode 100644 index 0000000..0c202ae --- /dev/null +++ b/app/src/hooks/useLogin.js @@ -0,0 +1,121 @@ +import { useReducer, useEffect, useMemo } from 'react' +import Meteor from '@meteorrn/core' + +/** @private */ +const initialState = { + isLoading: true, + isSignout: false, + userToken: null +} + +/** @private */ +const reducer = (state, action) => { + switch (action.type) { + case 'RESTORE_TOKEN': + return { + ...state, + userToken: action.token, + isLoading: false + } + case 'SIGN_IN': + return { + ...state, + isSignOut: false, + userToken: action.token + } + case 'SIGN_OUT': + return { + ...state, + isSignout: true, + userToken: null + } + } +} + +/** @private */ +const Data = Meteor.getData() + +/** + * Provides a state and authentication context for components to decide, whether + * the user is authenticated and also to run several authentication actions. + * + * The returned state contains the following structure: + * {{ + * isLoading: boolean, + * isSignout: boolean, + * userToken: string|null + * } + * }} + * + * the authcontext provides the following methods: + * {{ + * signIn: function, + * signOut: function, + * signUp: function + * }} + * + * @returns {{ + * state:object, + * authContext: object + * }} + */ +export const useLogin = () => { + const [state, dispatch] = useReducer(reducer, initialState, undefined) + + // Case 1: restore token already exists + // MeteorRN loads the token on connection automatically, + // in case it exists, but we need to "know" that for our auth workflow + useEffect(() => { + const handleOnLogin = () => dispatch({ type: 'RESTORE_TOKEN', token: Meteor.getAuthToken() }) + Data.on('onLogin', handleOnLogin) + return () => Data.off('onLogin', handleOnLogin) + }, []) + + // the auth can be referenced via useContext in the several + // screens later on + const authContext = useMemo(() => ({ + signIn: ({ email, password, onError }) => { + Meteor.loginWithPassword(email, password, async (err) => { + if (err) { + if (err.message === 'Match failed [400]') { + err.message = 'Login failed, please check your credentials and retry.' + } + return onError(err) + } + const token = Meteor.getAuthToken() + const type = 'SIGN_IN' + dispatch({ type, token }) + }) + }, + signOut: () => { + Meteor.logout(err => { + if (err) { + // TODO display error, merge into the above workflow + return console.error(err) + } + dispatch({ type: 'SIGN_OUT' }) + }) + }, + signUp: ({ email, password, onError }) => { + Meteor.call('register', { email, password }, (err, res) => { + if (err) { + return onError(err) + } + // TODO move the below code and the code from signIn into an own function + Meteor.loginWithPassword(email, password, async (err) => { + if (err) { + if (err.message === 'Match failed [400]') { + err.message = 'Login failed, please check your credentials and retry.' + } + return onError(err) + } + const token = Meteor.getAuthToken() + const type = 'SIGN_IN' + dispatch({ type, token }) + }) + }) + } + }), []) + + return { state, authContext } +} diff --git a/app/src/screens/HomeScreen.js b/app/src/screens/HomeScreen.js new file mode 100644 index 0000000..5c1d7e7 --- /dev/null +++ b/app/src/screens/HomeScreen.js @@ -0,0 +1,37 @@ +import React, { useContext, useState } from 'react' +import { View, Text, Button, StyleSheet } from 'react-native' +import { AuthContext } from '../contexts/AuthContext' +import { MyTasks } from '../components/MyTasks' + +export const HomeScreen = () => { + const [error, setError] = useState(null) + const { signOut } = useContext(AuthContext) + const onError = err => setError(err) + const handleSignOut = () => signOut({ onError }) + + const renderError = () => { + if (!error) { return null } + return ( + + {error.message} + + ) + } + + return ( + + + {renderError()} +