Skip to content

Commit 0949be0

Browse files
committed
feature(app): full scale app starter implemented
1 parent c577f53 commit 0949be0

File tree

11 files changed

+277
-87
lines changed

11 files changed

+277
-87
lines changed

app/config.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
{
22
"backend": {
3-
"url": "ws://xxx.xxx.xxx.xxx:8000/websocket"
3+
"url": "ws://192.168.178.49:8000/websocket"
44
}
55
}

app/src/components/ErrorMessage.js

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import React from 'react'
2+
import { Text, View } from 'react-native'
3+
import { defaultStyles } from '../styles/defaultStyles'
4+
5+
export const ErrorMessage = ({ error, message }) => {
6+
if (!error && !message) { return null }
7+
8+
return (
9+
<View style={defaultStyles.container}>
10+
<Text style={defaultStyles.danger}>{message || error.message}</Text>
11+
</View>
12+
)
13+
}
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import { Button } from 'react-native'
2+
import { useNavigation } from '@react-navigation/native'
3+
import { defaultColors } from '../styles/defaultStyles'
4+
5+
/**
6+
* Renders a button wih a route binding.
7+
* On press triggers the given route by name.
8+
*
9+
* @param title {string}
10+
* @param route {string}
11+
* @return {JSX.Element}
12+
* @component
13+
*/
14+
export const NavigateButton = ({ title, route }) => {
15+
const navigation = useNavigation()
16+
17+
return (
18+
<Button
19+
title={title}
20+
color={defaultColors.primary}
21+
onPress={() => navigation.navigate(route)}
22+
/>
23+
)
24+
}

app/src/hooks/useAccount.js

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import Meteor from '@meteorrn/core'
2+
import { useMemo, useState } from 'react'
3+
4+
const { useTracker } = Meteor
5+
6+
export const useAccount = () => {
7+
const [user, setUser] = useState(Meteor.user())
8+
9+
useTracker(() => {
10+
const reactiveUser = Meteor.user()
11+
if (reactiveUser !== user) {
12+
setUser(reactiveUser)
13+
}
14+
})
15+
16+
const api = useMemo(() => ({
17+
updateProfile: ({ options, onError, onSuccess }) => {
18+
Meteor.call('updateUserProfile', options, (err) => {
19+
return err
20+
? onError(err)
21+
: onSuccess()
22+
})
23+
}
24+
}), [])
25+
26+
return { user, ...api }
27+
}
Lines changed: 39 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,12 @@
11
import { useReducer, useEffect, useMemo } from 'react'
22
import Meteor from '@meteorrn/core'
33

4-
/** @private */
54
const initialState = {
65
isLoading: true,
76
isSignout: false,
87
userToken: null
98
}
109

11-
/** @private */
1210
const reducer = (state, action) => {
1311
switch (action.type) {
1412
case 'RESTORE_TOKEN':
@@ -32,34 +30,18 @@ const reducer = (state, action) => {
3230
}
3331
}
3432

35-
/** @private */
3633
const Data = Meteor.getData()
3734

3835
/**
3936
* Provides a state and authentication context for components to decide, whether
4037
* the user is authenticated and also to run several authentication actions.
4138
*
42-
* The returned state contains the following structure:
43-
* {{
44-
* isLoading: boolean,
45-
* isSignout: boolean,
46-
* userToken: string|null
47-
* }
48-
* }}
49-
*
50-
* the authcontext provides the following methods:
51-
* {{
52-
* signIn: function,
53-
* signOut: function,
54-
* signUp: function
55-
* }}
56-
*
5739
* @returns {{
5840
* state:object,
5941
* authContext: object
6042
* }}
6143
*/
62-
export const useLogin = () => {
44+
export const useAuth = () => {
6345
const [state, dispatch] = useReducer(reducer, initialState, undefined)
6446

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

74-
// the auth can be referenced via useContext in the several
75-
// screens later on
56+
/**
57+
* Bridge between the backend endpoints and client.
58+
* Get them via `const { signIn } = useContext(AuthContext)`
59+
*
60+
* @type {{
61+
* signIn: function({email: *, password: *, onError: *}): void,
62+
* signOut: function({onError: *}): void,
63+
* signUp: function({email: *, password: *, onError: *}): void,
64+
* deleteAccount: function({ onError: * });void
65+
* }}
66+
*/
7667
const authContext = useMemo(() => ({
7768
signIn: ({ email, password, onError }) => {
7869
Meteor.loginWithPassword(email, password, async (err) => {
@@ -87,32 +78,42 @@ export const useLogin = () => {
8778
dispatch({ type, token })
8879
})
8980
},
90-
signOut: () => {
81+
signOut: ({ onError }) => {
9182
Meteor.logout(err => {
9283
if (err) {
93-
// TODO display error, merge into the above workflow
94-
return console.error(err)
84+
return onError(err)
9585
}
9686
dispatch({ type: 'SIGN_OUT' })
9787
})
9888
},
99-
signUp: ({ email, password, onError }) => {
100-
Meteor.call('register', { email, password }, (err, res) => {
89+
signUp: ({ email, password, firstName, lastName, onError }) => {
90+
const signupArgs = { email, password, firstName, lastName, loginImmediately: true }
91+
92+
Meteor.call('registerNewUser', signupArgs, (err, credentials) => {
10193
if (err) {
10294
return onError(err)
10395
}
104-
// TODO move the below code and the code from signIn into an own function
105-
Meteor.loginWithPassword(email, password, async (err) => {
106-
if (err) {
107-
if (err.message === 'Match failed [400]') {
108-
err.message = 'Login failed, please check your credentials and retry.'
109-
}
110-
return onError(err)
111-
}
112-
const token = Meteor.getAuthToken()
113-
const type = 'SIGN_IN'
114-
dispatch({ type, token })
115-
})
96+
97+
// this sets the { id, token } values internally to make sure
98+
// our calls to Meteor endpoints will be authenticated
99+
Meteor._handleLoginCallback(err, credentials)
100+
101+
// from here this is the same routine as in signIn
102+
const token = Meteor.getAuthToken()
103+
const type = 'SIGN_IN'
104+
dispatch({ type, token })
105+
})
106+
},
107+
deleteAccount: ({ onError }) => {
108+
Meteor.call('deleteAccount', (err) => {
109+
if (err) {
110+
return onError(err)
111+
}
112+
113+
// removes all auth-based data from client
114+
// as if we would call signOut
115+
Meteor.handleLogout()
116+
dispatch({ type: 'SIGN_OUT' })
116117
})
117118
}
118119
}), [])

app/src/screens/LoginScreen.js

Lines changed: 7 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
import React, { useState, useContext } from 'react'
22
import { View, Text, TextInput, Button } from 'react-native'
33
import { AuthContext } from '../contexts/AuthContext'
4-
import { inputStyles } from '../styles/inputStyles'
4+
import { defaultStyles } from '../styles/defaultStyles'
5+
import { ErrorMessage } from '../components/ErrorMessage'
56

67
/**
78
* Provides a login form and links to RegisterScreen
@@ -18,39 +19,31 @@ export const LoginScreen = ({ navigation }) => {
1819
// handlers
1920
const onError = err => setError(err)
2021
const onSignIn = () => signIn({ email, password, onError })
21-
const renderError = () => {
22-
if (!error) { return null }
23-
return (
24-
<View style={{ alignItems: 'center', padding: 15 }}>
25-
<Text style={{ color: 'red' }}>{error.message}</Text>
26-
</View>
27-
)
28-
}
2922

3023
// render login form
3124
return (
32-
<View>
25+
<View style={defaultStyles.container}>
3326
<TextInput
3427
placeholder='Your Email'
3528
placeholderTextColor='#8a8a8a'
36-
style={inputStyles.text}
29+
style={defaultStyles.text}
3730
value={email}
3831
onChangeText={setEmail}
3932
/>
4033
<TextInput
4134
placeholder='Password'
4235
placeholderTextColor='#8a8a8a'
43-
style={inputStyles.text}
36+
style={defaultStyles.text}
4437
value={password}
4538
onChangeText={setPassword}
4639
secureTextEntry
4740
/>
48-
{renderError()}
41+
<ErrorMessage error={error} />
4942
<Button title='Sign in' onPress={onSignIn} />
5043
<View style={{ alignItems: 'center', padding: 15 }}>
5144
<Text>or</Text>
5245
</View>
53-
<Button title='Sign up' onPress={() => navigation.navigate('SignUp')} />
46+
<Button title='Sign up' onPress={() => navigation.navigate('SignUp')} color='#a4a4a4' />
5447
</View>
5548
)
5649
}

app/src/screens/MainNavigator.js

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,12 @@ import { CardStyleInterpolators } from '@react-navigation/stack'
33
import { AuthContext } from '../contexts/AuthContext'
44
import { NavigationContainer } from '@react-navigation/native'
55
import { createNativeStackNavigator } from '@react-navigation/native-stack'
6-
import { useLogin } from '../hooks/useLogin'
6+
import { useAuth } from '../hooks/useAuth'
77
import { HomeScreen } from './HomeScreen'
88
import { LoginScreen } from './LoginScreen'
99
import { RegistrationScreen } from './RegistrationScreen'
10+
import { ProfileScreen } from './ProfileScreen'
11+
import { NavigateButton } from '../components/NavigateButton'
1012

1113
/**
1214
* Provides a "push/pop" animation when switching between screens.
@@ -19,13 +21,19 @@ const Stack = createNativeStackNavigator()
1921
* @return {JSX.Element}
2022
*/
2123
export const MainNavigator = () => {
22-
const { state, authContext } = useLogin()
24+
const { state, authContext } = useAuth()
2325
const { userToken } = state
2426

2527
const renderScreens = () => {
2628
if (userToken) {
27-
// only authenticated users can visit the home screen
28-
return (<Stack.Screen name='Home' component={HomeScreen} />)
29+
// only authenticated users can visit these screens
30+
const headerRight = () => (<NavigateButton title='My profile' route='Profile' />)
31+
return (
32+
<>
33+
<Stack.Screen name='Home' component={HomeScreen} options={{ title: 'Welcome home', headerRight }} />
34+
<Stack.Screen name='Profile' component={ProfileScreen} options={{ title: 'Your profile' }} />
35+
</>
36+
)
2937
}
3038

3139
// non authenticated users need to sign in or register

app/src/screens/ProfileScreen.js

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
import { AuthContext } from '../contexts/AuthContext'
2+
import { defaultColors, defaultStyles } from '../styles/defaultStyles'
3+
import { Button, Text, TextInput, View } from 'react-native'
4+
import { useContext, useState } from 'react'
5+
import { ErrorMessage } from '../components/ErrorMessage'
6+
import { useAccount } from '../hooks/useAccount'
7+
8+
export const ProfileScreen = () => {
9+
const [editMode, setEditMode] = useState('')
10+
const [editValue, setEditValue] = useState('')
11+
const [error, setError] = useState(null)
12+
const { signOut, deleteAccount } = useContext(AuthContext)
13+
const { user, updateProfile } = useAccount()
14+
15+
const onError = err => setError(err)
16+
17+
/**
18+
* Updates a profile field from given text input state
19+
* by sending update data to the server and let hooks
20+
* reactively sync with the updated user document. *magic*
21+
* @param fieldName {string} name of the field to update
22+
*/
23+
const updateField = ({ fieldName }) => {
24+
const options = {}
25+
options[fieldName] = editValue
26+
const onSuccess = () => {
27+
setError(null)
28+
setEditValue('')
29+
setEditMode('')
30+
}
31+
updateProfile({ options, onError, onSuccess })
32+
}
33+
34+
const renderField = ({ title, fieldName }) => {
35+
const value = user[fieldName] || ''
36+
37+
if (editMode === fieldName) {
38+
return (
39+
<>
40+
<Text style={defaultStyles.bold}>{title}</Text>
41+
<View style={defaultStyles.row}>
42+
<TextInput
43+
placeholder={title}
44+
autoFocus
45+
placeholderTextColor={defaultColors.placeholder}
46+
style={{ ...defaultStyles.text, ...defaultStyles.flex1 }}
47+
value={editValue}
48+
onChangeText={setEditValue}
49+
/>
50+
<ErrorMessage error={error} />
51+
<Button title='Update' onPress={() => updateField({ fieldName })} />
52+
</View>
53+
</>
54+
)
55+
}
56+
57+
return (
58+
<>
59+
<Text style={defaultStyles.bold}>{title}</Text>
60+
<View style={defaultStyles.row}>
61+
<Text style={defaultStyles.flex1}>{user[fieldName] || 'Not yet defined'}</Text>
62+
<Button
63+
title='Edit' onPress={() => {
64+
setEditValue(value)
65+
setEditMode(fieldName)
66+
}}
67+
/>
68+
</View>
69+
</>
70+
)
71+
}
72+
73+
return (
74+
<View style={defaultStyles.container}>
75+
{renderField({ title: 'First Name', fieldName: 'firstName' })}
76+
{renderField({ title: 'Last Name', fieldName: 'lastName' })}
77+
78+
<Text style={defaultStyles.bold}>Email</Text>
79+
<Text>{user.emails[0].address}</Text>
80+
81+
<View style={{ ...defaultStyles.dangerBorder, padding: 10, marginTop: 10 }}>
82+
<Text style={defaultStyles.bold}>Danger Zone</Text>
83+
<Button title='Sign out' color={defaultColors.danger} onPress={() => signOut({ onError })} />
84+
<Button title='Delete account' color={defaultColors.danger} onPress={() => deleteAccount({ onError })} />
85+
<ErrorMessage error={error} />
86+
</View>
87+
</View>
88+
)
89+
}

0 commit comments

Comments
 (0)