Skip to content

Commit

Permalink
feature(app): full scale app starter implemented
Browse files Browse the repository at this point in the history
  • Loading branch information
jankapunkt committed Oct 24, 2022
1 parent c577f53 commit 0949be0
Show file tree
Hide file tree
Showing 11 changed files with 277 additions and 87 deletions.
2 changes: 1 addition & 1 deletion app/config.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"backend": {
"url": "ws://xxx.xxx.xxx.xxx:8000/websocket"
"url": "ws://192.168.178.49:8000/websocket"
}
}
13 changes: 13 additions & 0 deletions app/src/components/ErrorMessage.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import React from 'react'
import { Text, View } from 'react-native'
import { defaultStyles } from '../styles/defaultStyles'

export const ErrorMessage = ({ error, message }) => {
if (!error && !message) { return null }

return (
<View style={defaultStyles.container}>
<Text style={defaultStyles.danger}>{message || error.message}</Text>
</View>
)
}
24 changes: 24 additions & 0 deletions app/src/components/NavigateButton.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { Button } from 'react-native'
import { useNavigation } from '@react-navigation/native'
import { defaultColors } from '../styles/defaultStyles'

/**
* Renders a button wih a route binding.
* On press triggers the given route by name.
*
* @param title {string}
* @param route {string}
* @return {JSX.Element}
* @component
*/
export const NavigateButton = ({ title, route }) => {
const navigation = useNavigation()

return (
<Button
title={title}
color={defaultColors.primary}
onPress={() => navigation.navigate(route)}
/>
)
}
27 changes: 27 additions & 0 deletions app/src/hooks/useAccount.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import Meteor from '@meteorrn/core'
import { useMemo, useState } from 'react'

const { useTracker } = Meteor

export const useAccount = () => {
const [user, setUser] = useState(Meteor.user())

useTracker(() => {
const reactiveUser = Meteor.user()
if (reactiveUser !== user) {
setUser(reactiveUser)
}
})

const api = useMemo(() => ({
updateProfile: ({ options, onError, onSuccess }) => {
Meteor.call('updateUserProfile', options, (err) => {
return err
? onError(err)
: onSuccess()
})
}
}), [])

return { user, ...api }
}
77 changes: 39 additions & 38 deletions app/src/hooks/useLogin.js → app/src/hooks/useAuth.js
Original file line number Diff line number Diff line change
@@ -1,14 +1,12 @@
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':
Expand All @@ -32,34 +30,18 @@ const reducer = (state, action) => {
}
}

/** @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 = () => {
export const useAuth = () => {
const [state, dispatch] = useReducer(reducer, initialState, undefined)

// Case 1: restore token already exists
Expand All @@ -71,8 +53,17 @@ export const useLogin = () => {
return () => Data.off('onLogin', handleOnLogin)
}, [])

// the auth can be referenced via useContext in the several
// screens later on
/**
* Bridge between the backend endpoints and client.
* Get them via `const { signIn } = useContext(AuthContext)`
*
* @type {{
* signIn: function({email: *, password: *, onError: *}): void,
* signOut: function({onError: *}): void,
* signUp: function({email: *, password: *, onError: *}): void,
* deleteAccount: function({ onError: * });void
* }}
*/
const authContext = useMemo(() => ({
signIn: ({ email, password, onError }) => {
Meteor.loginWithPassword(email, password, async (err) => {
Expand All @@ -87,32 +78,42 @@ export const useLogin = () => {
dispatch({ type, token })
})
},
signOut: () => {
signOut: ({ onError }) => {
Meteor.logout(err => {
if (err) {
// TODO display error, merge into the above workflow
return console.error(err)
return onError(err)
}
dispatch({ type: 'SIGN_OUT' })
})
},
signUp: ({ email, password, onError }) => {
Meteor.call('register', { email, password }, (err, res) => {
signUp: ({ email, password, firstName, lastName, onError }) => {
const signupArgs = { email, password, firstName, lastName, loginImmediately: true }

Meteor.call('registerNewUser', signupArgs, (err, credentials) => {
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 })
})

// this sets the { id, token } values internally to make sure
// our calls to Meteor endpoints will be authenticated
Meteor._handleLoginCallback(err, credentials)

// from here this is the same routine as in signIn
const token = Meteor.getAuthToken()
const type = 'SIGN_IN'
dispatch({ type, token })
})
},
deleteAccount: ({ onError }) => {
Meteor.call('deleteAccount', (err) => {
if (err) {
return onError(err)
}

// removes all auth-based data from client
// as if we would call signOut
Meteor.handleLogout()
dispatch({ type: 'SIGN_OUT' })
})
}
}), [])
Expand Down
21 changes: 7 additions & 14 deletions app/src/screens/LoginScreen.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import React, { useState, useContext } from 'react'
import { View, Text, TextInput, Button } from 'react-native'
import { AuthContext } from '../contexts/AuthContext'
import { inputStyles } from '../styles/inputStyles'
import { defaultStyles } from '../styles/defaultStyles'
import { ErrorMessage } from '../components/ErrorMessage'

/**
* Provides a login form and links to RegisterScreen
Expand All @@ -18,39 +19,31 @@ export const LoginScreen = ({ navigation }) => {
// handlers
const onError = err => setError(err)
const onSignIn = () => signIn({ email, password, onError })
const renderError = () => {
if (!error) { return null }
return (
<View style={{ alignItems: 'center', padding: 15 }}>
<Text style={{ color: 'red' }}>{error.message}</Text>
</View>
)
}

// render login form
return (
<View>
<View style={defaultStyles.container}>
<TextInput
placeholder='Your Email'
placeholderTextColor='#8a8a8a'
style={inputStyles.text}
style={defaultStyles.text}
value={email}
onChangeText={setEmail}
/>
<TextInput
placeholder='Password'
placeholderTextColor='#8a8a8a'
style={inputStyles.text}
style={defaultStyles.text}
value={password}
onChangeText={setPassword}
secureTextEntry
/>
{renderError()}
<ErrorMessage error={error} />
<Button title='Sign in' onPress={onSignIn} />
<View style={{ alignItems: 'center', padding: 15 }}>
<Text>or</Text>
</View>
<Button title='Sign up' onPress={() => navigation.navigate('SignUp')} />
<Button title='Sign up' onPress={() => navigation.navigate('SignUp')} color='#a4a4a4' />
</View>
)
}
16 changes: 12 additions & 4 deletions app/src/screens/MainNavigator.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,12 @@ import { CardStyleInterpolators } from '@react-navigation/stack'
import { AuthContext } from '../contexts/AuthContext'
import { NavigationContainer } from '@react-navigation/native'
import { createNativeStackNavigator } from '@react-navigation/native-stack'
import { useLogin } from '../hooks/useLogin'
import { useAuth } from '../hooks/useAuth'
import { HomeScreen } from './HomeScreen'
import { LoginScreen } from './LoginScreen'
import { RegistrationScreen } from './RegistrationScreen'
import { ProfileScreen } from './ProfileScreen'
import { NavigateButton } from '../components/NavigateButton'

/**
* Provides a "push/pop" animation when switching between screens.
Expand All @@ -19,13 +21,19 @@ const Stack = createNativeStackNavigator()
* @return {JSX.Element}
*/
export const MainNavigator = () => {
const { state, authContext } = useLogin()
const { state, authContext } = useAuth()
const { userToken } = state

const renderScreens = () => {
if (userToken) {
// only authenticated users can visit the home screen
return (<Stack.Screen name='Home' component={HomeScreen} />)
// only authenticated users can visit these screens
const headerRight = () => (<NavigateButton title='My profile' route='Profile' />)
return (
<>
<Stack.Screen name='Home' component={HomeScreen} options={{ title: 'Welcome home', headerRight }} />
<Stack.Screen name='Profile' component={ProfileScreen} options={{ title: 'Your profile' }} />
</>
)
}

// non authenticated users need to sign in or register
Expand Down
89 changes: 89 additions & 0 deletions app/src/screens/ProfileScreen.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import { AuthContext } from '../contexts/AuthContext'
import { defaultColors, defaultStyles } from '../styles/defaultStyles'
import { Button, Text, TextInput, View } from 'react-native'
import { useContext, useState } from 'react'
import { ErrorMessage } from '../components/ErrorMessage'
import { useAccount } from '../hooks/useAccount'

export const ProfileScreen = () => {
const [editMode, setEditMode] = useState('')
const [editValue, setEditValue] = useState('')
const [error, setError] = useState(null)
const { signOut, deleteAccount } = useContext(AuthContext)
const { user, updateProfile } = useAccount()

const onError = err => setError(err)

/**
* Updates a profile field from given text input state
* by sending update data to the server and let hooks
* reactively sync with the updated user document. *magic*
* @param fieldName {string} name of the field to update
*/
const updateField = ({ fieldName }) => {
const options = {}
options[fieldName] = editValue
const onSuccess = () => {
setError(null)
setEditValue('')
setEditMode('')
}
updateProfile({ options, onError, onSuccess })
}

const renderField = ({ title, fieldName }) => {
const value = user[fieldName] || ''

if (editMode === fieldName) {
return (
<>
<Text style={defaultStyles.bold}>{title}</Text>
<View style={defaultStyles.row}>
<TextInput
placeholder={title}
autoFocus
placeholderTextColor={defaultColors.placeholder}
style={{ ...defaultStyles.text, ...defaultStyles.flex1 }}
value={editValue}
onChangeText={setEditValue}
/>
<ErrorMessage error={error} />
<Button title='Update' onPress={() => updateField({ fieldName })} />
</View>
</>
)
}

return (
<>
<Text style={defaultStyles.bold}>{title}</Text>
<View style={defaultStyles.row}>
<Text style={defaultStyles.flex1}>{user[fieldName] || 'Not yet defined'}</Text>
<Button
title='Edit' onPress={() => {
setEditValue(value)
setEditMode(fieldName)
}}
/>
</View>
</>
)
}

return (
<View style={defaultStyles.container}>
{renderField({ title: 'First Name', fieldName: 'firstName' })}
{renderField({ title: 'Last Name', fieldName: 'lastName' })}

<Text style={defaultStyles.bold}>Email</Text>
<Text>{user.emails[0].address}</Text>

<View style={{ ...defaultStyles.dangerBorder, padding: 10, marginTop: 10 }}>
<Text style={defaultStyles.bold}>Danger Zone</Text>
<Button title='Sign out' color={defaultColors.danger} onPress={() => signOut({ onError })} />
<Button title='Delete account' color={defaultColors.danger} onPress={() => deleteAccount({ onError })} />
<ErrorMessage error={error} />
</View>
</View>
)
}
Loading

0 comments on commit 0949be0

Please sign in to comment.