Showing
58 changed files
with
4270 additions
and
0 deletions
.gitignore
0 → 100644
React/inbox/api-routes/index.ts
0 → 100644
1 | +import composeApiRouteFor from '../../../helpers/composeApiRouteFor'; | ||
2 | + | ||
3 | +const communicationsBase = composeApiRouteFor('communication-threads'); | ||
4 | + | ||
5 | +export const threadContentsRoute = (threadId: number) => `${communicationsBase}/${threadId}/contents`; | ||
6 | + | ||
7 | +// TODO: should be replaced by threadContentsRoute once API has been adjusted | ||
8 | +export const threadMessagesRoute = (threadId: number) => `${communicationsBase}/${threadId}/messages`; |
React/inbox/components/ChatInput/index.tsx
0 → 100644
1 | +import React, {useState} from 'react'; | ||
2 | +import {useDebounce} from 'use-debounce'; | ||
3 | +import {size, trim} from 'lodash/fp'; | ||
4 | +import {useDispatch, useSelector} from 'react-redux'; | ||
5 | +import { postThreadContent } from '../../middlewares/thread-contents'; | ||
6 | +import {getCurrentThreadId} from '../../../selectors/thread'; | ||
7 | + | ||
8 | +const ChatInput = () => { | ||
9 | + const dispatch = useDispatch(); | ||
10 | + const [inputValue, setInputValue] = useState(''); | ||
11 | + const [message] = useDebounce(trim(inputValue), 300); | ||
12 | + const isBlankMessage = size(message) === 0; | ||
13 | + const submitDisabled = isBlankMessage; | ||
14 | + const threadId = useSelector(getCurrentThreadId); | ||
15 | + const handleSubmit = () => { | ||
16 | + dispatch(postThreadContent({threadId, message: inputValue})); | ||
17 | + }; | ||
18 | + const handleInputChange = (value: string) => { | ||
19 | + setInputValue(value); | ||
20 | + return value; | ||
21 | + }; | ||
22 | + | ||
23 | + return ( | ||
24 | + <div className='chat-input-container'> | ||
25 | + <form onSubmit={handleSubmit}> | ||
26 | + <div className='collapse row align-middle mb-1'> | ||
27 | + <div className='auto columns'> | ||
28 | + <textarea placeholder={`What's on your mind?`} onChange={({target}) => handleInputChange(target.value)} /> | ||
29 | + </div> | ||
30 | + {/* DZ */} | ||
31 | + <div className='shrink columns'> | ||
32 | + {/* TODO: Insert <Dropzone>*/} | ||
33 | + <button data-tip data-for='attachment-criteria' className='button secondary hollow large clear pull-left'> | ||
34 | + <i className='fa fa-paperclip'/> | ||
35 | + </button> | ||
36 | + {/* TODO: Insert <ReactTooltip>*/} | ||
37 | + <button disabled={submitDisabled} type='button' onClick={handleSubmit} className='button success hollow large clear pull-right'> | ||
38 | + <i className='fa fa-paper-plane'/> | ||
39 | + </button> | ||
40 | + </div> | ||
41 | + </div> | ||
42 | + </form> | ||
43 | + </div> | ||
44 | + ); | ||
45 | +}; | ||
46 | + | ||
47 | +export default ChatInput; |
1 | +import React from 'react'; | ||
2 | +import {map, getOr} from 'lodash/fp'; | ||
3 | +import {useSelector} from 'react-redux'; | ||
4 | +import {plainToClass} from 'class-transformer'; | ||
5 | +import ThreadContent from '../../../../../models/ThreadContent'; | ||
6 | +import ThreadAttachment, {getAttachmentFileTypeCssClass} from '../../../../../models/ThreadAttachment'; | ||
7 | +import {downloadAttachmentRoute} from '../../../../../api-routes'; | ||
8 | +import {getCurrentThreadId} from '../../../../selectors/thread'; | ||
9 | + | ||
10 | +interface Props { | ||
11 | + attachments: ThreadContent[]; | ||
12 | +} | ||
13 | + | ||
14 | +const FileAttachments = ({attachments}: Props) => { | ||
15 | + if (!attachments.length) { | ||
16 | + return null; | ||
17 | + } | ||
18 | + const threadId = useSelector(getCurrentThreadId); | ||
19 | + return ( | ||
20 | + <ul className='attachments inline-list'> | ||
21 | + {map(({id, contentInstance}) => { | ||
22 | + const obj = plainToClass(ThreadAttachment, contentInstance); | ||
23 | + const caption = getOr('', 'media.caption', obj); | ||
24 | + return ( | ||
25 | + <li key={`file_attachment_${id}`} className={getAttachmentFileTypeCssClass(obj)}> | ||
26 | + <a href={downloadAttachmentRoute(threadId, obj.id)} target='_blank' className='btn-dl' > | ||
27 | + <i className='fa fa-download'/> | ||
28 | + </a> | ||
29 | + <span className='filename' data-tooltip title={caption}> | ||
30 | + {caption} | ||
31 | + </span> | ||
32 | + </li>); | ||
33 | + }, | ||
34 | + attachments)} | ||
35 | + </ul> | ||
36 | + ); | ||
37 | +}; | ||
38 | +export default FileAttachments; |
1 | +import React from 'react'; | ||
2 | +import { getOr } from 'lodash/fp'; | ||
3 | +import ThreadContent from '../../../../../models/ThreadContent'; | ||
4 | +import isCurrentUser from '../../../../helpers/isCurrentUser'; | ||
5 | +interface Props { | ||
6 | + threadContent: ThreadContent; | ||
7 | +} | ||
8 | + | ||
9 | +const Info = ({threadContent}: Props) => { | ||
10 | + const readBy = getOr(0, 'contentInstance.readBy', threadContent); | ||
11 | + const authorId = getOr(0, 'author.id', threadContent); | ||
12 | + const isSeen = !isCurrentUser(authorId) && readBy; | ||
13 | + return ( | ||
14 | + <span className='subheader timestamp'> | ||
15 | + {isSeen && <small className='pull-left mr-1'> | ||
16 | + <i className='fa fa-check-circle'/> Seen | ||
17 | + </small>} | ||
18 | + <small>{threadContent.getLocalTimeString()}</small> | ||
19 | + </span> | ||
20 | + ); | ||
21 | +}; | ||
22 | + | ||
23 | +export default Info; |
1 | +import React from 'react'; | ||
2 | +import {get} from 'lodash/fp'; | ||
3 | +import ThreadContent from '../../../../../models/ThreadContent'; | ||
4 | +import Text from '../Text'; | ||
5 | +import Info from '../Info'; | ||
6 | + | ||
7 | +interface Props { | ||
8 | + threadContent: ThreadContent; | ||
9 | +} | ||
10 | + | ||
11 | +export default ({threadContent}: Props) => { | ||
12 | + return ( | ||
13 | + <div className='text-align-right' data-msg-key={get('contentInstance.id', threadContent)}> | ||
14 | + <div className='wrapper inline-block'> | ||
15 | + <div className='media-object pull-right' data-animate='slide-in-right slide-out-right'> | ||
16 | + <div className='small white-bg talk-bubble border tri-right right-in'> | ||
17 | + <Text threadContent={threadContent} /> | ||
18 | + </div> | ||
19 | + <Info threadContent={threadContent} /> | ||
20 | + </div> | ||
21 | + </div> | ||
22 | + </div> | ||
23 | + ); | ||
24 | +}; |
1 | +import React from 'react'; | ||
2 | +import {getOr} from 'lodash/fp'; | ||
3 | +import FileAttachments from '../FileAttachments'; | ||
4 | +import ThreadContent from '../../../../../models/ThreadContent'; | ||
5 | +import {fileAttachments} from '../../../../../helpers/threadContent'; | ||
6 | + | ||
7 | +interface Props { | ||
8 | + threadContent: ThreadContent; | ||
9 | +} | ||
10 | + | ||
11 | +const Text = ({threadContent}: Props) => { | ||
12 | + | ||
13 | + return ( | ||
14 | + <div className='talktext'> | ||
15 | + <p dangerouslySetInnerHTML={{__html: getOr('', 'contentInstance.contentText', threadContent)}}/> | ||
16 | + <FileAttachments attachments={fileAttachments(threadContent)} /> | ||
17 | + </div> | ||
18 | + ); | ||
19 | +}; | ||
20 | + | ||
21 | +export default Text; |
1 | +import React from 'react'; | ||
2 | +import {get} from 'lodash/fp'; | ||
3 | +import ThreadContent from '../../../../../models/ThreadContent'; | ||
4 | +import Text from '../Text'; | ||
5 | +import Info from '../Info'; | ||
6 | +import UserImageBubble from '../../../../../app/components/UserImageBubble'; | ||
7 | +import {displayName, profilePicture} from '../../../../../helpers/user'; | ||
8 | + | ||
9 | +interface Props { | ||
10 | + threadContent: ThreadContent; | ||
11 | +} | ||
12 | +const TheirMessage = ({threadContent}: Props) => { | ||
13 | + | ||
14 | + const {author} = threadContent; | ||
15 | + const imageProps = { | ||
16 | + displayName: displayName(author), | ||
17 | + profilePictureUrl: profilePicture(author) | ||
18 | + }; | ||
19 | + | ||
20 | + return ( | ||
21 | + <div className='wrapper' data-msg-key={get('contentInstance.id', threadContent)}> | ||
22 | + <div className='media-object' data-animate='slide-in-right slide-out-right'> | ||
23 | + <UserImageBubble {...imageProps} /> | ||
24 | + <div className='media-object-section middle'> | ||
25 | + <div className='small talk-bubble border tri-right left-in'> | ||
26 | + <Text threadContent={threadContent} /> | ||
27 | + </div> | ||
28 | + <Info threadContent={threadContent} /> | ||
29 | + </div> | ||
30 | + </div> | ||
31 | + </div> | ||
32 | + ); | ||
33 | +}; | ||
34 | +export default TheirMessage; |
React/inbox/components/ChatMessage/index.tsx
0 → 100644
1 | +import React from 'react'; | ||
2 | +import {getOr} from 'lodash/fp'; | ||
3 | +import ThreadContent from '../../../../models/ThreadContent'; | ||
4 | +import TheirMessage from './TheirMessage'; | ||
5 | +import isCurrentUser from '../../../helpers/isCurrentUser'; | ||
6 | +import MyMessage from './MyMessage'; | ||
7 | + | ||
8 | +interface Props { | ||
9 | + threadContent: ThreadContent; | ||
10 | +} | ||
11 | + | ||
12 | +const ChatMessage = ({threadContent}: Props) => { | ||
13 | + return isCurrentUser(getOr(0, 'author.id', threadContent)) | ||
14 | + ? <MyMessage threadContent={threadContent} /> | ||
15 | + : <TheirMessage threadContent={threadContent} />; | ||
16 | +}; | ||
17 | + | ||
18 | +export default ChatMessage; |
React/inbox/components/Thread/index.tsx
0 → 100644
1 | +/** | ||
2 | + * Inbox tab main chat container component | ||
3 | + */ | ||
4 | + | ||
5 | +import React, { useEffect } from 'react'; | ||
6 | +import { useDispatch, useSelector } from 'react-redux'; | ||
7 | +import { map } from 'lodash/fp'; | ||
8 | +import {didSetActiveTab} from '../../../tabs/actions'; | ||
9 | +import {COMMUNICATIONS} from '../../../tabs/constants'; | ||
10 | +import {LoadingRipple} from '../../../../utilities/components/LoadingRipple'; | ||
11 | +import PageLayout from '../../../components/PageLayout'; | ||
12 | +import {getCurrentThreadId} from '../../../selectors/thread'; | ||
13 | +import {fetchThreadContents} from '../../middlewares/thread-contents'; | ||
14 | +import {getIsFetchingThreadContents, getThreadContentsCollection} from '../../selectors'; | ||
15 | +import ChatMessage from '../ChatMessage'; | ||
16 | +import ThreadContent, {ThreadContentTypes} from '../../../../models/ThreadContent'; | ||
17 | +import ChatInput from '../ChatInput'; | ||
18 | + | ||
19 | +const Thread = () => { | ||
20 | + const dispatch = useDispatch(); | ||
21 | + useEffect(() => { | ||
22 | + dispatch(didSetActiveTab(COMMUNICATIONS)); | ||
23 | + }, []); | ||
24 | + | ||
25 | + const threadId = useSelector(getCurrentThreadId); | ||
26 | + const isFetching = useSelector(getIsFetchingThreadContents); | ||
27 | + const collection = useSelector(getThreadContentsCollection); | ||
28 | + | ||
29 | + useEffect(() => { | ||
30 | + dispatch(fetchThreadContents(threadId)); | ||
31 | + }, [threadId]); | ||
32 | + | ||
33 | + return ( | ||
34 | + <PageLayout> | ||
35 | + <div id='chat-container' style={{overflowY: 'hidden'}}> | ||
36 | + <div className={`chat-body-container`}> | ||
37 | + {isFetching && <LoadingRipple isLoading />} | ||
38 | + {!isFetching && | ||
39 | + <div className='chat-body'> | ||
40 | + {map( | ||
41 | + (each: ThreadContent) => ( | ||
42 | + ThreadContentTypes.MESSAGE === each.type ? <ChatMessage | ||
43 | + threadContent={each} | ||
44 | + key={`thread_content_${each.id}`} | ||
45 | + /> : null | ||
46 | + ), collection | ||
47 | + )} | ||
48 | + </div> | ||
49 | + } | ||
50 | + </div> | ||
51 | + <ChatInput /> | ||
52 | + </div> | ||
53 | + </PageLayout> | ||
54 | + ); | ||
55 | +}; | ||
56 | + | ||
57 | +export default Thread; |
1 | +import { getThunkActionCreator } from 'redux-thunk-routine'; | ||
2 | +import { plainToClass } from 'class-transformer'; | ||
3 | +import { fetchThreadContents as fetchThreadContentsRoutine, postThreadContent as postThreadContentRoutine } from './routines'; | ||
4 | +import AlertifyHelper from '../../../../helpers/AlertifyHelper'; | ||
5 | +import {threadContentsRoute, threadMessagesRoute} from '../../api-routes'; | ||
6 | +import {apiGet, apiPost} from '../../../../helpers/api'; | ||
7 | +import ThreadContent from '../../../../models/ThreadContent'; | ||
8 | + | ||
9 | +export const fetchThreadContents = getThunkActionCreator( | ||
10 | + fetchThreadContentsRoutine, | ||
11 | + async (threadId: number, page: number = 1) => { | ||
12 | + try { | ||
13 | + const { threadContents, meta } = await apiGet( | ||
14 | + threadContentsRoute(threadId), | ||
15 | + { page } | ||
16 | + ); | ||
17 | + | ||
18 | + return {collection: plainToClass(ThreadContent, threadContents), meta}; | ||
19 | + } catch (e) { | ||
20 | + AlertifyHelper.error('Unable to fetch thread contents'); | ||
21 | + throw e; | ||
22 | + } | ||
23 | + } | ||
24 | +); | ||
25 | + | ||
26 | +export const postThreadContent = getThunkActionCreator( | ||
27 | + postThreadContentRoutine, | ||
28 | + async ({threadId, message}: {threadId: number, message: string}) => { | ||
29 | + try { | ||
30 | + // todo: append attachment ids | ||
31 | + const attachmentIds: number[] = []; | ||
32 | + const { threadContent } = await apiPost(threadMessagesRoute(threadId), { | ||
33 | + message, | ||
34 | + threadAttachments: attachmentIds | ||
35 | + }); | ||
36 | + return plainToClass(ThreadContent, threadContent); | ||
37 | + } catch (e) { | ||
38 | + AlertifyHelper.error('Failed to send your message.'); | ||
39 | + throw e; | ||
40 | + } | ||
41 | + } | ||
42 | +); |
React/inbox/page-paths.ts
0 → 100644
React/inbox/reducers.ts
0 → 100644
1 | +import { sortBy, unionBy, get, set } from 'lodash/fp'; | ||
2 | +import { createReducer } from '../../helpers/Reducer'; | ||
3 | +import {fetchThreadContents as fetchThreadContentsRoutine, postThreadContent} from './middlewares/thread-contents/routines'; | ||
4 | +import {ThreadContentsState} from '../../definitions/portal-interfaces'; | ||
5 | +import ThreadContent from '../../models/ThreadContent'; | ||
6 | + | ||
7 | +const initialState = { | ||
8 | + collection: [], | ||
9 | + meta: null, | ||
10 | + isFetching: false, | ||
11 | + isFetched: false | ||
12 | +}; | ||
13 | + | ||
14 | +const fetchThreadContentsHandler = () => ({ | ||
15 | + [fetchThreadContentsRoutine.REQUEST]: (state: ThreadContentsState) => ({ | ||
16 | + ...state, | ||
17 | + isFetching: true, | ||
18 | + isFetched: false | ||
19 | + }), | ||
20 | + [fetchThreadContentsRoutine.SUCCESS]: ( | ||
21 | + state: ThreadContentsState, | ||
22 | + {payload: {collection, meta}}: {payload: ThreadContentsState}) => { | ||
23 | + const newCollection = unionBy((each: ThreadContent) => get('id', each), collection, state.collection); | ||
24 | + const orderedCollection = sortBy('createdAt', newCollection); | ||
25 | + return { | ||
26 | + ...state, | ||
27 | + collection: orderedCollection, | ||
28 | + meta, | ||
29 | + isFetching: false, | ||
30 | + isFetched: true | ||
31 | + }; | ||
32 | + }, | ||
33 | + [fetchThreadContentsRoutine.FAILURE]: (state: ThreadContentsState) => ({ | ||
34 | + ...state, | ||
35 | + isFetching: false, | ||
36 | + isFetched: true | ||
37 | + }) | ||
38 | +}); | ||
39 | + | ||
40 | +const postThreadContentHandler = () => ({ | ||
41 | + [postThreadContent.SUCCESS]: ( | ||
42 | + state: ThreadContentsState, | ||
43 | + { payload: threadContent }: { payload: ThreadContent }) => { | ||
44 | + return set('collection', [...state.collection, threadContent], state); | ||
45 | + } | ||
46 | +}); | ||
47 | + | ||
48 | +export default createReducer(initialState, { | ||
49 | + ...fetchThreadContentsHandler(), | ||
50 | + ...postThreadContentHandler() | ||
51 | +}); |
React/inbox/selectors.ts
0 → 100644
1 | +import { createSelector } from 'reselect'; | ||
2 | +import { get } from 'lodash/fp'; | ||
3 | +import { PortalState } from '../../definitions/portal-interfaces'; | ||
4 | + | ||
5 | +const threadContents = ({threadContents}: PortalState) => threadContents; | ||
6 | + | ||
7 | +export const getThreadContentsCollection = createSelector( | ||
8 | + threadContents, | ||
9 | + get('collection') | ||
10 | +); | ||
11 | + | ||
12 | +export const getIsFetchingThreadContents = createSelector( | ||
13 | + threadContents, | ||
14 | + get('isFetching') | ||
15 | +); |
1 | +import React from 'react'; | ||
2 | +import { StyleSheet } from 'react-native'; | ||
3 | +import ListGrid, { | ||
4 | + HeaderRow, | ||
5 | + ItemRow, | ||
6 | + PlayerColumn, | ||
7 | + PPGColumn | ||
8 | +} from '../../ListGrid'; | ||
9 | +import HeaderColumn from '../../ListGrid/HeaderColumn'; | ||
10 | +import { BorderRadius, CardHeight } from '../constants'; | ||
11 | + | ||
12 | +const styles = StyleSheet.create({ | ||
13 | + itemWrapper: { | ||
14 | + height: CardHeight, | ||
15 | + borderRadius: BorderRadius | ||
16 | + }, | ||
17 | + player: { | ||
18 | + justifyContent: 'center' | ||
19 | + }, | ||
20 | + stats: { | ||
21 | + justifyContent: 'center', | ||
22 | + alignItems: 'center' | ||
23 | + } | ||
24 | +}); | ||
25 | + | ||
26 | +const AveragesList: React.FC<any> = () => { | ||
27 | + return ( | ||
28 | + <ListGrid style={{ marginTop: 10 }}> | ||
29 | + <HeaderRow style={{ marginBottom: 5 }}> | ||
30 | + <HeaderColumn align="center" size={3} fontSize="sm" label="PLAYER" /> | ||
31 | + <HeaderColumn align="center" size={1.5} fontSize="sm" label="1-week" /> | ||
32 | + <HeaderColumn align="center" size={1.5} fontSize="sm" label="2-week" /> | ||
33 | + <HeaderColumn align="center" size={1.5} fontSize="sm" label="4-week" /> | ||
34 | + <HeaderColumn align="center" size={1.5} fontSize="sm" label="Season" /> | ||
35 | + </HeaderRow> | ||
36 | + <ItemRow style={styles.itemWrapper}> | ||
37 | + <PlayerColumn | ||
38 | + name="EZEKEL ELLIOT" | ||
39 | + position="RB" | ||
40 | + team="DAL" | ||
41 | + rating={4.3} | ||
42 | + size={3} | ||
43 | + style={styles.player} | ||
44 | + /> | ||
45 | + <PPGColumn size={1.7} style={styles.stats}> | ||
46 | + 24.5 | ||
47 | + </PPGColumn> | ||
48 | + <PPGColumn size={1.5} style={styles.stats}> | ||
49 | + 14.9 | ||
50 | + </PPGColumn> | ||
51 | + <PPGColumn size={1.5} style={styles.stats}> | ||
52 | + 15.8 | ||
53 | + </PPGColumn> | ||
54 | + <PPGColumn size={1.5} style={styles.stats}> | ||
55 | + 24.5 | ||
56 | + </PPGColumn> | ||
57 | + </ItemRow> | ||
58 | + <ItemRow style={styles.itemWrapper}> | ||
59 | + <PlayerColumn | ||
60 | + name="MICHAEL THOMAS" | ||
61 | + position="WR" | ||
62 | + team="NO" | ||
63 | + rating={3.5} | ||
64 | + size={3} | ||
65 | + style={styles.player} | ||
66 | + /> | ||
67 | + <PPGColumn size={1.5} style={styles.stats}> | ||
68 | + 33.2 | ||
69 | + </PPGColumn> | ||
70 | + <PPGColumn size={1.5} style={styles.stats}> | ||
71 | + 31.6 | ||
72 | + </PPGColumn> | ||
73 | + <PPGColumn size={1.5} style={styles.stats}> | ||
74 | + 16.9 | ||
75 | + </PPGColumn> | ||
76 | + <PPGColumn size={1.5} style={styles.stats}> | ||
77 | + 33.2 | ||
78 | + </PPGColumn> | ||
79 | + </ItemRow> | ||
80 | + <ItemRow style={styles.itemWrapper}> | ||
81 | + <PlayerColumn | ||
82 | + name="JULIO JONES" | ||
83 | + position="WR" | ||
84 | + team="ATL" | ||
85 | + rating={4} | ||
86 | + size={3} | ||
87 | + style={styles.player} | ||
88 | + /> | ||
89 | + <PPGColumn size={1.5} style={styles.stats}> | ||
90 | + 33.2 | ||
91 | + </PPGColumn> | ||
92 | + <PPGColumn size={1.5} style={styles.stats}> | ||
93 | + 17.7 | ||
94 | + </PPGColumn> | ||
95 | + <PPGColumn size={1.5} style={styles.stats}> | ||
96 | + 25.9 | ||
97 | + </PPGColumn> | ||
98 | + <PPGColumn size={1.5} style={styles.stats}> | ||
99 | + 33.2 | ||
100 | + </PPGColumn> | ||
101 | + </ItemRow> | ||
102 | + <ItemRow style={styles.itemWrapper}> | ||
103 | + <PlayerColumn | ||
104 | + name="DESEAN WATSON" | ||
105 | + position="QB" | ||
106 | + team="HOU" | ||
107 | + rating={4} | ||
108 | + size={3} | ||
109 | + style={styles.player} | ||
110 | + /> | ||
111 | + <PPGColumn size={1.5} style={styles.stats}> | ||
112 | + 24.5 | ||
113 | + </PPGColumn> | ||
114 | + <PPGColumn size={1.5} style={styles.stats}> | ||
115 | + 19.9 | ||
116 | + </PPGColumn> | ||
117 | + <PPGColumn size={1.5} style={styles.stats}> | ||
118 | + 15.6 | ||
119 | + </PPGColumn> | ||
120 | + <PPGColumn size={1.5} style={styles.stats}> | ||
121 | + 24.5 | ||
122 | + </PPGColumn> | ||
123 | + </ItemRow> | ||
124 | + <ItemRow style={styles.itemWrapper}> | ||
125 | + <PlayerColumn | ||
126 | + owned | ||
127 | + name="COOPER KUPP" | ||
128 | + position="WR" | ||
129 | + team="STL" | ||
130 | + rating={3} | ||
131 | + size={3} | ||
132 | + style={styles.player} | ||
133 | + /> | ||
134 | + <PPGColumn size={1.5} style={styles.stats}> | ||
135 | + 33.2 | ||
136 | + </PPGColumn> | ||
137 | + <PPGColumn size={1.5} style={styles.stats}> | ||
138 | + 21.8 | ||
139 | + </PPGColumn> | ||
140 | + <PPGColumn size={1.5} style={styles.stats}> | ||
141 | + 15.8 | ||
142 | + </PPGColumn> | ||
143 | + <PPGColumn size={1.5} style={styles.stats}> | ||
144 | + 33.2 | ||
145 | + </PPGColumn> | ||
146 | + </ItemRow> | ||
147 | + </ListGrid> | ||
148 | + ); | ||
149 | +}; | ||
150 | + | ||
151 | +export default AveragesList; |
1 | +import { get } from 'lodash/fp'; | ||
2 | +import React from 'react'; | ||
3 | +import { StyleSheet } from 'react-native'; | ||
4 | +import { TouchableOpacity } from 'react-native-gesture-handler'; | ||
5 | +import abbreviatePlayerPositon from '../../../../helpers/abbreviatePlayerPositon'; | ||
6 | +import { | ||
7 | + Column, | ||
8 | + ItemRow, | ||
9 | + PlayerColumn, | ||
10 | + PriceWithTrendColumn | ||
11 | +} from '../../../ListGrid'; | ||
12 | +import { CardHeight } from '../../../player-card/constants'; | ||
13 | +import { BorderRadius } from '../../constants'; | ||
14 | + | ||
15 | +interface Props { | ||
16 | + data: PlayerEdge; | ||
17 | + isOwned: boolean; | ||
18 | + activeSort: string; | ||
19 | + onPress: () => any; | ||
20 | +} | ||
21 | + | ||
22 | +const styles = StyleSheet.create({ | ||
23 | + itemWrapper: { | ||
24 | + height: CardHeight, | ||
25 | + borderRadius: BorderRadius | ||
26 | + }, | ||
27 | + player: { | ||
28 | + justifyContent: 'center', | ||
29 | + width: '100%' | ||
30 | + }, | ||
31 | + column: { | ||
32 | + justifyContent: 'center', | ||
33 | + alignItems: 'center' | ||
34 | + }, | ||
35 | + priceTrend: { | ||
36 | + justifyContent: 'center', | ||
37 | + alignItems: 'center' | ||
38 | + } | ||
39 | +}); | ||
40 | + | ||
41 | +const Item: React.FC<Props> = ({ | ||
42 | + data: { node: playerNode }, | ||
43 | + onPress, | ||
44 | + activeSort, | ||
45 | + isOwned | ||
46 | +}: Props) => ( | ||
47 | + <TouchableOpacity onPress={onPress}> | ||
48 | + <ItemRow style={styles.itemWrapper} key={playerNode?.id}> | ||
49 | + <PlayerColumn | ||
50 | + isOwned={isOwned} | ||
51 | + name={`${playerNode?.firstName} ${playerNode?.lastName}`} | ||
52 | + position={abbreviatePlayerPositon(playerNode?.playerPositions)} | ||
53 | + team={get('team.abbreviation', playerNode)} | ||
54 | + rating={4.3} | ||
55 | + size={4} | ||
56 | + style={styles.player} | ||
57 | + /> | ||
58 | + <Column | ||
59 | + size={2} | ||
60 | + style={styles.column} | ||
61 | + color={activeSort === 'rank' && 'info'} | ||
62 | + > | ||
63 | + {playerNode?.rank} | ||
64 | + </Column> | ||
65 | + <Column | ||
66 | + size={2.5} | ||
67 | + style={styles.column} | ||
68 | + color={activeSort === 'fantasyPoints' && 'info'} | ||
69 | + > | ||
70 | + {playerNode?.fantasyPoints} | ||
71 | + </Column> | ||
72 | + <Column | ||
73 | + size={2.5} | ||
74 | + style={styles.column} | ||
75 | + color={activeSort === 'fantasyPointsPerGame' && 'info'} | ||
76 | + > | ||
77 | + {playerNode?.fantasyPointsPerGame} | ||
78 | + </Column> | ||
79 | + <PriceWithTrendColumn | ||
80 | + rise | ||
81 | + size={2.3} | ||
82 | + style={styles.priceTrend} | ||
83 | + height={45} | ||
84 | + current={playerNode?.value} | ||
85 | + color={activeSort === 'value' && 'info'} | ||
86 | + /> | ||
87 | + </ItemRow> | ||
88 | + </TouchableOpacity> | ||
89 | +); | ||
90 | + | ||
91 | +export default Item; |
1 | +import { StackActions, useNavigation } from '@react-navigation/native'; | ||
2 | +import { get, isEmpty } from 'lodash/fp'; | ||
3 | +import { Spinner as NativeSpinner, View } from 'native-base'; | ||
4 | +import React, { useCallback, useEffect, useState } from 'react'; | ||
5 | +import { | ||
6 | + DataProvider, | ||
7 | + LayoutProvider, | ||
8 | + RecyclerListView | ||
9 | +} from 'recyclerlistview'; | ||
10 | +import { TabNavigation } from '../../../types/navigation'; | ||
11 | +import ListGrid, { HeaderRow } from '../../ListGrid'; | ||
12 | +import HeaderColumn from '../../ListGrid/HeaderColumn'; | ||
13 | +import { CardHeight } from '../../player-card/constants'; | ||
14 | +import { COLUMNS, DEFAULT_SORT } from '../constants'; | ||
15 | +import Item from './Item'; | ||
16 | + | ||
17 | +interface Props { | ||
18 | + players: PlayerEdge[]; | ||
19 | + franchisePlayers: Dictionary<FranchisePlayer>; | ||
20 | + onSort: (field: string, order: string) => void; | ||
21 | + onFetchMore: VoidFunction; | ||
22 | + isFetching: boolean; | ||
23 | +} | ||
24 | + | ||
25 | +const layoutProvider = new LayoutProvider( | ||
26 | + () => 'MARKET_LIST', | ||
27 | + (_, dim) => { | ||
28 | + // eslint-disable-next-line no-param-reassign | ||
29 | + dim.width = 500; | ||
30 | + // eslint-disable-next-line no-param-reassign | ||
31 | + dim.height = CardHeight + 1; | ||
32 | + } | ||
33 | +); | ||
34 | + | ||
35 | +const MainList: React.FC<Props> = ({ | ||
36 | + isFetching, | ||
37 | + onFetchMore, | ||
38 | + franchisePlayers, | ||
39 | + onSort, | ||
40 | + players | ||
41 | +}: Props) => { | ||
42 | + const navigation = useNavigation<TabNavigation>(); | ||
43 | + const [sortBy, setSortBy] = useState<string>(DEFAULT_SORT.COLUMN); | ||
44 | + const [sortDirection, setSortDirection] = useState<SortType>( | ||
45 | + DEFAULT_SORT.DIRECTION | ||
46 | + ); | ||
47 | + const [dataProvider, setDataProvider] = useState<DataProvider>( | ||
48 | + new DataProvider((r1, r2) => r1 !== r2) | ||
49 | + ); | ||
50 | + | ||
51 | + useEffect(() => { | ||
52 | + if (sortBy && sortDirection) { | ||
53 | + onSort(sortBy, sortDirection); | ||
54 | + } | ||
55 | + // eslint-disable-next-line react-hooks/exhaustive-deps | ||
56 | + }, [sortBy, sortDirection]); | ||
57 | + | ||
58 | + useEffect(() => { | ||
59 | + if (!isEmpty(players)) { | ||
60 | + setDataProvider(dataProvider.cloneWithRows(players)); | ||
61 | + } | ||
62 | + // eslint-disable-next-line react-hooks/exhaustive-deps | ||
63 | + }, [players]); | ||
64 | + | ||
65 | + const handleOnSort = (colName: string) => () => { | ||
66 | + if (colName === sortBy) { | ||
67 | + setSortDirection(sortDirection === 'ASC' ? 'DESC' : 'ASC'); | ||
68 | + } else { | ||
69 | + setSortBy(colName); | ||
70 | + setSortDirection('ASC'); | ||
71 | + } | ||
72 | + }; | ||
73 | + | ||
74 | + const getColumnSortDirection = (colName: string) => | ||
75 | + colName === sortBy ? sortDirection : ''; | ||
76 | + | ||
77 | + const renderFooter = useCallback( | ||
78 | + () => (isFetching ? <NativeSpinner /> : <View />), | ||
79 | + [isFetching] | ||
80 | + ); | ||
81 | + | ||
82 | + const navigateToProfile = (selectedIndex: number) => () => { | ||
83 | + navigation.dispatch( | ||
84 | + StackActions.push('PlayerView', { | ||
85 | + players, | ||
86 | + selectedIndex, | ||
87 | + franchisePlayers | ||
88 | + }) | ||
89 | + ); | ||
90 | + }; | ||
91 | + | ||
92 | + const renderRow = useCallback( | ||
93 | + (_, data: PlayerEdge, index: number) => { | ||
94 | + return ( | ||
95 | + <Item | ||
96 | + data={data} | ||
97 | + activeSort={sortBy} | ||
98 | + isOwned={!isEmpty(get(data.node.id, franchisePlayers))} | ||
99 | + onPress={navigateToProfile(index)} | ||
100 | + /> | ||
101 | + ); | ||
102 | + }, | ||
103 | + // eslint-disable-next-line react-hooks/exhaustive-deps | ||
104 | + [dataProvider] | ||
105 | + ); | ||
106 | + | ||
107 | + const hasPlayerData = dataProvider.getSize() > 0; | ||
108 | + return ( | ||
109 | + <ListGrid style={{ marginHorizontal: 6 }}> | ||
110 | + <HeaderRow style={{ height: 45, marginBottom: 20 }}> | ||
111 | + <HeaderColumn align="center" size={4} fontSize="sm" label="PLAYER" /> | ||
112 | + <HeaderColumn | ||
113 | + sortable | ||
114 | + size={2} | ||
115 | + fontSize="sm" | ||
116 | + label="Rank" | ||
117 | + onSort={handleOnSort(COLUMNS.RANK)} | ||
118 | + sortDirection={getColumnSortDirection(COLUMNS.RANK)} | ||
119 | + /> | ||
120 | + <HeaderColumn | ||
121 | + sortable | ||
122 | + size={2.5} | ||
123 | + fontSize="sm" | ||
124 | + label="FP" | ||
125 | + helpText="FANTASY PTS" | ||
126 | + onSort={handleOnSort(COLUMNS.FP)} | ||
127 | + sortDirection={getColumnSortDirection(COLUMNS.FP)} | ||
128 | + /> | ||
129 | + <HeaderColumn | ||
130 | + sortable | ||
131 | + size={2.5} | ||
132 | + fontSize="sm" | ||
133 | + label="FPPG" | ||
134 | + helpText={'FANTASY PTS\n(per game)'} | ||
135 | + onSort={handleOnSort(COLUMNS.FPPG)} | ||
136 | + sortDirection={getColumnSortDirection(COLUMNS.FPPG)} | ||
137 | + /> | ||
138 | + <HeaderColumn | ||
139 | + sortable | ||
140 | + size={2.3} | ||
141 | + fontSize="sm" | ||
142 | + label="Price" | ||
143 | + helpText={'TREND\n(24hrs)'} | ||
144 | + onSort={handleOnSort(COLUMNS.PRICE)} | ||
145 | + sortDirection={getColumnSortDirection(COLUMNS.PRICE)} | ||
146 | + /> | ||
147 | + </HeaderRow> | ||
148 | + {hasPlayerData && ( | ||
149 | + <RecyclerListView | ||
150 | + style={{ flex: 1 }} | ||
151 | + dataProvider={dataProvider} | ||
152 | + layoutProvider={layoutProvider} | ||
153 | + rowRenderer={renderRow} | ||
154 | + initialRenderIndex={0} | ||
155 | + renderAheadOffset={1500} | ||
156 | + renderFooter={renderFooter} | ||
157 | + onEndReached={onFetchMore} | ||
158 | + onEndReachedThreshold={2000} | ||
159 | + /> | ||
160 | + )} | ||
161 | + </ListGrid> | ||
162 | + ); | ||
163 | +}; | ||
164 | + | ||
165 | +export default MainList; |
1 | +import React from 'react'; | ||
2 | +import { StyleSheet } from 'react-native'; | ||
3 | +import ListGrid, { | ||
4 | + HeaderRow, | ||
5 | + ItemRow, | ||
6 | + PassingColumn, | ||
7 | + PlayerColumn, | ||
8 | + ReceivingColumn, | ||
9 | + RushingColumn | ||
10 | +} from '../../ListGrid'; | ||
11 | +import HeaderColumn from '../../ListGrid/HeaderColumn'; | ||
12 | +import { BorderRadius } from '../constants'; | ||
13 | + | ||
14 | +const styles = StyleSheet.create({ | ||
15 | + itemWrapper: { | ||
16 | + height: 50, | ||
17 | + borderRadius: BorderRadius | ||
18 | + }, | ||
19 | + player: { | ||
20 | + justifyContent: 'center' | ||
21 | + }, | ||
22 | + stats: { | ||
23 | + justifyContent: 'center', | ||
24 | + alignItems: 'center' | ||
25 | + } | ||
26 | +}); | ||
27 | + | ||
28 | +const StatsList: React.FC<any> = () => { | ||
29 | + return ( | ||
30 | + <ListGrid style={{ marginTop: 10 }}> | ||
31 | + <HeaderRow style={{ marginBottom: 5 }}> | ||
32 | + <HeaderColumn align="center" size={3} fontSize="sm" label="Player" /> | ||
33 | + <HeaderColumn | ||
34 | + align="center" | ||
35 | + size={2} | ||
36 | + fontSize="sm" | ||
37 | + label="Passing" | ||
38 | + helpText="PaYD / PaTD" | ||
39 | + /> | ||
40 | + <HeaderColumn | ||
41 | + align="center" | ||
42 | + size={2.5} | ||
43 | + fontSize="sm" | ||
44 | + label="Rushing" | ||
45 | + helpText="Att / RuYd / RuTD" | ||
46 | + /> | ||
47 | + <HeaderColumn | ||
48 | + align="center" | ||
49 | + size={2.5} | ||
50 | + fontSize="sm" | ||
51 | + label="Receiving" | ||
52 | + helpText="Rec / ReYd / ReTD" | ||
53 | + /> | ||
54 | + </HeaderRow> | ||
55 | + <ItemRow style={styles.itemWrapper}> | ||
56 | + <PlayerColumn | ||
57 | + owned | ||
58 | + name="EZEKEL ELLIOT" | ||
59 | + position="RB" | ||
60 | + team="DAL" | ||
61 | + rating={4.3} | ||
62 | + size={3} | ||
63 | + style={styles.player} | ||
64 | + /> | ||
65 | + <PassingColumn size={2} yards="0.0" td="0.0" style={styles.stats} /> | ||
66 | + <RushingColumn | ||
67 | + size={2.5} | ||
68 | + attempts="16.3" | ||
69 | + yards="87.5" | ||
70 | + td="0.7" | ||
71 | + style={styles.stats} | ||
72 | + /> | ||
73 | + <ReceivingColumn | ||
74 | + size={2.5} | ||
75 | + receptions="4.1" | ||
76 | + yards="56.7" | ||
77 | + td="0.3" | ||
78 | + style={styles.stats} | ||
79 | + /> | ||
80 | + </ItemRow> | ||
81 | + <ItemRow style={styles.itemWrapper}> | ||
82 | + <PlayerColumn | ||
83 | + name="MICHAEL THOMAS" | ||
84 | + position="WR" | ||
85 | + team="NO" | ||
86 | + rating={3.5} | ||
87 | + size={3} | ||
88 | + style={styles.player} | ||
89 | + /> | ||
90 | + <PassingColumn size={2} yards="0.0" td="0.0" style={styles.stats} /> | ||
91 | + <RushingColumn | ||
92 | + size={2.5} | ||
93 | + attempts="16.3" | ||
94 | + yards="87.5" | ||
95 | + td="0.7" | ||
96 | + style={styles.stats} | ||
97 | + /> | ||
98 | + <ReceivingColumn | ||
99 | + size={2.5} | ||
100 | + receptions="4.1" | ||
101 | + yards="56.7" | ||
102 | + td="0.3" | ||
103 | + style={styles.stats} | ||
104 | + /> | ||
105 | + </ItemRow> | ||
106 | + <ItemRow style={styles.itemWrapper}> | ||
107 | + <PlayerColumn | ||
108 | + name="JULIO JONES" | ||
109 | + position="WR" | ||
110 | + team="ATL" | ||
111 | + rating={4} | ||
112 | + size={3} | ||
113 | + style={styles.player} | ||
114 | + /> | ||
115 | + <PassingColumn size={2} yards="0.0" td="0.0" style={styles.stats} /> | ||
116 | + <RushingColumn | ||
117 | + size={2.5} | ||
118 | + attempts="16.3" | ||
119 | + yards="87.5" | ||
120 | + td="0.7" | ||
121 | + style={styles.stats} | ||
122 | + /> | ||
123 | + <ReceivingColumn | ||
124 | + size={2.5} | ||
125 | + receptions="4.1" | ||
126 | + yards="56.7" | ||
127 | + td="0.3" | ||
128 | + style={styles.stats} | ||
129 | + /> | ||
130 | + </ItemRow> | ||
131 | + <ItemRow style={styles.itemWrapper}> | ||
132 | + <PlayerColumn | ||
133 | + name="DESEAN WATSON" | ||
134 | + position="QB" | ||
135 | + team="HOU" | ||
136 | + rating={4} | ||
137 | + size={3} | ||
138 | + style={styles.player} | ||
139 | + /> | ||
140 | + <PassingColumn size={2} yards="0.0" td="0.0" style={styles.stats} /> | ||
141 | + <RushingColumn | ||
142 | + size={2.5} | ||
143 | + attempts="16.3" | ||
144 | + yards="87.5" | ||
145 | + td="0.7" | ||
146 | + style={styles.stats} | ||
147 | + /> | ||
148 | + <ReceivingColumn | ||
149 | + size={2.5} | ||
150 | + receptions="4.1" | ||
151 | + yards="56.7" | ||
152 | + td="0.3" | ||
153 | + style={styles.stats} | ||
154 | + /> | ||
155 | + </ItemRow> | ||
156 | + <ItemRow style={styles.itemWrapper}> | ||
157 | + <PlayerColumn | ||
158 | + name="COOPER KUPP" | ||
159 | + position="WR" | ||
160 | + team="STL" | ||
161 | + rating={3} | ||
162 | + size={3} | ||
163 | + style={styles.player} | ||
164 | + /> | ||
165 | + <PassingColumn size={2} yards="0.0" td="0.0" style={styles.stats} /> | ||
166 | + <RushingColumn | ||
167 | + size={2.5} | ||
168 | + attempts="16.3" | ||
169 | + yards="87.5" | ||
170 | + td="0.7" | ||
171 | + style={styles.stats} | ||
172 | + /> | ||
173 | + <ReceivingColumn | ||
174 | + size={2.5} | ||
175 | + receptions="4.1" | ||
176 | + yards="56.7" | ||
177 | + td="0.3" | ||
178 | + style={styles.stats} | ||
179 | + /> | ||
180 | + </ItemRow> | ||
181 | + </ListGrid> | ||
182 | + ); | ||
183 | +}; | ||
184 | + | ||
185 | +export default StatsList; |
1 | +import React from 'react'; | ||
2 | +import { StyleSheet } from 'react-native'; | ||
3 | +import ListGrid, { | ||
4 | + FPColumn, | ||
5 | + HeaderRow, | ||
6 | + ItemRow, | ||
7 | + PlayerColumn | ||
8 | +} from '../../ListGrid'; | ||
9 | +import HeaderColumn from '../../ListGrid/HeaderColumn'; | ||
10 | +import { BorderRadius, CardHeight } from '../constants'; | ||
11 | + | ||
12 | +const styles = StyleSheet.create({ | ||
13 | + itemWrapper: { | ||
14 | + height: CardHeight, | ||
15 | + borderRadius: BorderRadius | ||
16 | + }, | ||
17 | + player: { | ||
18 | + justifyContent: 'center' | ||
19 | + }, | ||
20 | + stats: { | ||
21 | + justifyContent: 'center', | ||
22 | + alignItems: 'center' | ||
23 | + } | ||
24 | +}); | ||
25 | + | ||
26 | +const TotalsList: React.FC<any> = () => { | ||
27 | + return ( | ||
28 | + <ListGrid style={{ marginTop: 10 }}> | ||
29 | + <HeaderRow style={{ marginBottom: 5 }}> | ||
30 | + <HeaderColumn align="center" size={3} fontSize="sm" label="PLAYER" /> | ||
31 | + <HeaderColumn align="center" size={1.5} fontSize="sm" label="1-week" /> | ||
32 | + <HeaderColumn align="center" size={1.5} fontSize="sm" label="2-week" /> | ||
33 | + <HeaderColumn align="center" size={1.5} fontSize="sm" label="4-week" /> | ||
34 | + <HeaderColumn align="center" size={1.5} fontSize="sm" label="Season" /> | ||
35 | + </HeaderRow> | ||
36 | + <ItemRow style={styles.itemWrapper}> | ||
37 | + <PlayerColumn | ||
38 | + name="EZEKEL ELLIOT" | ||
39 | + position="RB" | ||
40 | + team="DAL" | ||
41 | + rating={4.3} | ||
42 | + size={3} | ||
43 | + style={styles.player} | ||
44 | + /> | ||
45 | + <FPColumn size={1.7} style={styles.stats}> | ||
46 | + 24.5 | ||
47 | + </FPColumn> | ||
48 | + <FPColumn size={1.5} style={styles.stats}> | ||
49 | + 14.9 | ||
50 | + </FPColumn> | ||
51 | + <FPColumn size={1.5} style={styles.stats}> | ||
52 | + 15.8 | ||
53 | + </FPColumn> | ||
54 | + <FPColumn size={1.5} style={styles.stats}> | ||
55 | + 24.5 | ||
56 | + </FPColumn> | ||
57 | + </ItemRow> | ||
58 | + <ItemRow style={styles.itemWrapper}> | ||
59 | + <PlayerColumn | ||
60 | + name="MICHAEL THOMAS" | ||
61 | + position="WR" | ||
62 | + team="NO" | ||
63 | + rating={3.5} | ||
64 | + size={3} | ||
65 | + style={styles.player} | ||
66 | + /> | ||
67 | + <FPColumn size={1.5} style={styles.stats}> | ||
68 | + 33.2 | ||
69 | + </FPColumn> | ||
70 | + <FPColumn size={1.5} style={styles.stats}> | ||
71 | + 31.6 | ||
72 | + </FPColumn> | ||
73 | + <FPColumn size={1.5} style={styles.stats}> | ||
74 | + 16.9 | ||
75 | + </FPColumn> | ||
76 | + <FPColumn size={1.5} style={styles.stats}> | ||
77 | + 33.2 | ||
78 | + </FPColumn> | ||
79 | + </ItemRow> | ||
80 | + <ItemRow style={styles.itemWrapper}> | ||
81 | + <PlayerColumn | ||
82 | + name="JULIO JONES" | ||
83 | + position="WR" | ||
84 | + team="ATL" | ||
85 | + rating={4} | ||
86 | + size={3} | ||
87 | + style={styles.player} | ||
88 | + /> | ||
89 | + <FPColumn size={1.5} style={styles.stats}> | ||
90 | + 33.2 | ||
91 | + </FPColumn> | ||
92 | + <FPColumn size={1.5} style={styles.stats}> | ||
93 | + 17.7 | ||
94 | + </FPColumn> | ||
95 | + <FPColumn size={1.5} style={styles.stats}> | ||
96 | + 25.9 | ||
97 | + </FPColumn> | ||
98 | + <FPColumn size={1.5} style={styles.stats}> | ||
99 | + 33.2 | ||
100 | + </FPColumn> | ||
101 | + </ItemRow> | ||
102 | + <ItemRow style={styles.itemWrapper}> | ||
103 | + <PlayerColumn | ||
104 | + name="DESEAN WATSON" | ||
105 | + position="QB" | ||
106 | + team="HOU" | ||
107 | + rating={4} | ||
108 | + size={3} | ||
109 | + style={styles.player} | ||
110 | + /> | ||
111 | + <FPColumn size={1.5} style={styles.stats}> | ||
112 | + 24.5 | ||
113 | + </FPColumn> | ||
114 | + <FPColumn size={1.5} style={styles.stats}> | ||
115 | + 19.9 | ||
116 | + </FPColumn> | ||
117 | + <FPColumn size={1.5} style={styles.stats}> | ||
118 | + 15.6 | ||
119 | + </FPColumn> | ||
120 | + <FPColumn size={1.5} style={styles.stats}> | ||
121 | + 24.5 | ||
122 | + </FPColumn> | ||
123 | + </ItemRow> | ||
124 | + <ItemRow style={styles.itemWrapper}> | ||
125 | + <PlayerColumn | ||
126 | + owned | ||
127 | + name="COOPER KUPP" | ||
128 | + position="WR" | ||
129 | + team="STL" | ||
130 | + rating={3} | ||
131 | + size={3} | ||
132 | + style={styles.player} | ||
133 | + /> | ||
134 | + <FPColumn size={1.5} style={styles.stats}> | ||
135 | + 33.2 | ||
136 | + </FPColumn> | ||
137 | + <FPColumn size={1.5} style={styles.stats}> | ||
138 | + 21.8 | ||
139 | + </FPColumn> | ||
140 | + <FPColumn size={1.5} style={styles.stats}> | ||
141 | + 15.8 | ||
142 | + </FPColumn> | ||
143 | + <FPColumn size={1.5} style={styles.stats}> | ||
144 | + 33.2 | ||
145 | + </FPColumn> | ||
146 | + </ItemRow> | ||
147 | + </ListGrid> | ||
148 | + ); | ||
149 | +}; | ||
150 | + | ||
151 | +export default TotalsList; |
1 | +import React from 'react'; | ||
2 | +import { ViewProps } from 'react-native'; | ||
3 | +import ListGrid from '../../ListGrid'; | ||
4 | +import WeeklyPlayerRow from '../../WeeklyPlayerRow'; | ||
5 | + | ||
6 | +interface ComponentProps { | ||
7 | + data: Player; | ||
8 | + active: boolean; | ||
9 | +} | ||
10 | + | ||
11 | +type Props = ComponentProps & ViewProps; | ||
12 | + | ||
13 | +const AverageList: React.FC<Props> = ({ data, active, ...props }: Props) => { | ||
14 | + const rowLabel = 'PPG'; | ||
15 | + return ( | ||
16 | + // loop here | ||
17 | + <ListGrid {...props}> | ||
18 | + <WeeklyPlayerRow | ||
19 | + key="dummy1" | ||
20 | + player={data} | ||
21 | + active={active} | ||
22 | + rowLabel={rowLabel} | ||
23 | + /> | ||
24 | + <WeeklyPlayerRow | ||
25 | + key="dummy2" | ||
26 | + player={data} | ||
27 | + active={active} | ||
28 | + rowLabel={rowLabel} | ||
29 | + /> | ||
30 | + <WeeklyPlayerRow | ||
31 | + key="dummy3" | ||
32 | + player={data} | ||
33 | + active={active} | ||
34 | + rowLabel={rowLabel} | ||
35 | + /> | ||
36 | + <WeeklyPlayerRow | ||
37 | + key="dummy4" | ||
38 | + player={data} | ||
39 | + active={active} | ||
40 | + rowLabel={rowLabel} | ||
41 | + /> | ||
42 | + <WeeklyPlayerRow | ||
43 | + key="dummy5" | ||
44 | + player={data} | ||
45 | + active={active} | ||
46 | + rowLabel={rowLabel} | ||
47 | + /> | ||
48 | + <WeeklyPlayerRow | ||
49 | + key="dummy6" | ||
50 | + player={data} | ||
51 | + active={active} | ||
52 | + rowLabel={rowLabel} | ||
53 | + /> | ||
54 | + <WeeklyPlayerRow | ||
55 | + key="dummy7" | ||
56 | + player={data} | ||
57 | + active={active} | ||
58 | + rowLabel={rowLabel} | ||
59 | + /> | ||
60 | + <WeeklyPlayerRow | ||
61 | + key="dummy8" | ||
62 | + player={data} | ||
63 | + active={active} | ||
64 | + rowLabel={rowLabel} | ||
65 | + /> | ||
66 | + <WeeklyPlayerRow | ||
67 | + key="dummy9" | ||
68 | + player={data} | ||
69 | + active={active} | ||
70 | + rowLabel={rowLabel} | ||
71 | + /> | ||
72 | + <WeeklyPlayerRow | ||
73 | + key="dummy10" | ||
74 | + player={data} | ||
75 | + active={active} | ||
76 | + rowLabel={rowLabel} | ||
77 | + /> | ||
78 | + <WeeklyPlayerRow | ||
79 | + key="dummy11" | ||
80 | + player={data} | ||
81 | + active={active} | ||
82 | + rowLabel={rowLabel} | ||
83 | + /> | ||
84 | + <WeeklyPlayerRow | ||
85 | + key="dummy12" | ||
86 | + player={data} | ||
87 | + active={active} | ||
88 | + rowLabel={rowLabel} | ||
89 | + /> | ||
90 | + </ListGrid> | ||
91 | + ); | ||
92 | +}; | ||
93 | + | ||
94 | +export default AverageList; |
1 | +import React from 'react'; | ||
2 | +import { ViewProps } from 'react-native'; | ||
3 | +import ListGrid from '../../ListGrid'; | ||
4 | +import StatsPlayerRow from '../../StatsPlayerRow'; | ||
5 | + | ||
6 | +interface ComponentProps { | ||
7 | + data: Player; | ||
8 | + active: boolean; | ||
9 | +} | ||
10 | + | ||
11 | +type Props = ComponentProps & ViewProps; | ||
12 | + | ||
13 | +const StatsList: React.FC<Props> = ({ data, active, ...props }: Props) => ( | ||
14 | + <ListGrid {...props}> | ||
15 | + <StatsPlayerRow key="dummy1" player={data} active={active} /> | ||
16 | + <StatsPlayerRow key="dummy2" player={data} active={active} /> | ||
17 | + <StatsPlayerRow key="dummy3" player={data} active={active} /> | ||
18 | + <StatsPlayerRow key="dummy4" player={data} active={active} /> | ||
19 | + <StatsPlayerRow key="dummy5" player={data} active={active} /> | ||
20 | + <StatsPlayerRow key="dummy6" player={data} active={active} /> | ||
21 | + <StatsPlayerRow key="dummy7" player={data} active={active} /> | ||
22 | + <StatsPlayerRow key="dummy8" player={data} active={active} /> | ||
23 | + <StatsPlayerRow key="dummy9" player={data} active={active} /> | ||
24 | + <StatsPlayerRow key="dummy10" player={data} active={active} /> | ||
25 | + <StatsPlayerRow key="dummy11" player={data} active={active} /> | ||
26 | + <StatsPlayerRow key="dummy12" player={data} active={active} /> | ||
27 | + </ListGrid> | ||
28 | +); | ||
29 | + | ||
30 | +export default StatsList; |
1 | +import React from 'react'; | ||
2 | +import { ViewProps } from 'react-native'; | ||
3 | +import ListGrid from '../../ListGrid'; | ||
4 | +import WeeklyPlayerRow from '../../WeeklyPlayerRow'; | ||
5 | + | ||
6 | +interface ComponentProps { | ||
7 | + data: Player; | ||
8 | + active: boolean; | ||
9 | +} | ||
10 | + | ||
11 | +type Props = ComponentProps & ViewProps; | ||
12 | + | ||
13 | +const TotalsList: React.FC<Props> = ({ data, active, ...props }: Props) => { | ||
14 | + const rowLabel = 'FP'; | ||
15 | + return ( | ||
16 | + // loop here | ||
17 | + <ListGrid {...props}> | ||
18 | + <WeeklyPlayerRow | ||
19 | + key="dummy1" | ||
20 | + player={data} | ||
21 | + active={active} | ||
22 | + rowLabel={rowLabel} | ||
23 | + /> | ||
24 | + <WeeklyPlayerRow | ||
25 | + key="dummy2" | ||
26 | + player={data} | ||
27 | + active={active} | ||
28 | + rowLabel={rowLabel} | ||
29 | + /> | ||
30 | + <WeeklyPlayerRow | ||
31 | + key="dummy3" | ||
32 | + player={data} | ||
33 | + active={active} | ||
34 | + rowLabel={rowLabel} | ||
35 | + /> | ||
36 | + <WeeklyPlayerRow | ||
37 | + key="dummy4" | ||
38 | + player={data} | ||
39 | + active={active} | ||
40 | + rowLabel={rowLabel} | ||
41 | + /> | ||
42 | + <WeeklyPlayerRow | ||
43 | + key="dummy5" | ||
44 | + player={data} | ||
45 | + active={active} | ||
46 | + rowLabel={rowLabel} | ||
47 | + /> | ||
48 | + <WeeklyPlayerRow | ||
49 | + key="dummy6" | ||
50 | + player={data} | ||
51 | + active={active} | ||
52 | + rowLabel={rowLabel} | ||
53 | + /> | ||
54 | + <WeeklyPlayerRow | ||
55 | + key="dummy7" | ||
56 | + player={data} | ||
57 | + active={active} | ||
58 | + rowLabel={rowLabel} | ||
59 | + /> | ||
60 | + <WeeklyPlayerRow | ||
61 | + key="dummy8" | ||
62 | + player={data} | ||
63 | + active={active} | ||
64 | + rowLabel={rowLabel} | ||
65 | + /> | ||
66 | + <WeeklyPlayerRow | ||
67 | + key="dummy9" | ||
68 | + player={data} | ||
69 | + active={active} | ||
70 | + rowLabel={rowLabel} | ||
71 | + /> | ||
72 | + <WeeklyPlayerRow | ||
73 | + key="dummy10" | ||
74 | + player={data} | ||
75 | + active={active} | ||
76 | + rowLabel={rowLabel} | ||
77 | + /> | ||
78 | + <WeeklyPlayerRow | ||
79 | + key="dummy11" | ||
80 | + player={data} | ||
81 | + active={active} | ||
82 | + rowLabel={rowLabel} | ||
83 | + /> | ||
84 | + <WeeklyPlayerRow | ||
85 | + key="dummy12" | ||
86 | + player={data} | ||
87 | + active={active} | ||
88 | + rowLabel={rowLabel} | ||
89 | + /> | ||
90 | + </ListGrid> | ||
91 | + ); | ||
92 | +}; | ||
93 | + | ||
94 | +export default TotalsList; |
ReactNative/components/RosterList/index.tsx
0 → 100644
1 | +import { get, getOr, isEqual, keyBy, sortBy, findIndex } from 'lodash/fp'; | ||
2 | +import { View } from 'native-base'; | ||
3 | +import React from 'react'; | ||
4 | +import { ViewProps } from 'react-native'; | ||
5 | +import { StackActions, useNavigation } from '@react-navigation/native'; | ||
6 | +import { TouchableOpacity } from 'react-native-gesture-handler'; | ||
7 | +import { ABBREV, POSITIONS } from '../../constants/roster-position'; | ||
8 | +import BenchDivider from '../BenchDivider'; | ||
9 | +import DefaultPlayerRow from '../DefaultPlayerRow'; | ||
10 | +import ListGrid from '../ListGrid'; | ||
11 | +import { TAB } from '../../constants/tab'; | ||
12 | +import { TabNavigation, StackNavigation } from '../../types/navigation'; | ||
13 | + | ||
14 | +interface ComponentProps { | ||
15 | + players: FranchisePlayer[]; | ||
16 | + rosterSpots: string[]; | ||
17 | + // eslint-disable-next-line react/require-default-props | ||
18 | + showMarketTrend?: boolean; | ||
19 | +} | ||
20 | + | ||
21 | +type Props = ComponentProps & ViewProps; | ||
22 | + | ||
23 | +const RosterList: React.FC<Props> = ({ | ||
24 | + players, | ||
25 | + rosterSpots, | ||
26 | + showMarketTrend = false, | ||
27 | + ...props | ||
28 | +}: Props) => { | ||
29 | + const navigation = useNavigation<TabNavigation | StackNavigation>(); | ||
30 | + const benchIndex = rosterSpots.indexOf('BENCH'); | ||
31 | + const franchisePlayers = keyBy('rosterSpotIndex', players); | ||
32 | + const sortedPlayersSpots = sortBy('rosterSpotIndex', players); | ||
33 | + | ||
34 | + const navigateToProfile = (index: number) => { | ||
35 | + navigation.dispatch( | ||
36 | + StackActions.push('PlayerView', { | ||
37 | + players: sortedPlayersSpots, | ||
38 | + selectedIndex: index | ||
39 | + }) | ||
40 | + ); | ||
41 | + }; | ||
42 | + | ||
43 | + const handleOnPressItem = (index: number) => () => { | ||
44 | + const rosterSpotIndex = get( | ||
45 | + [`${index}`, 'rosterSpotIndex'], | ||
46 | + franchisePlayers | ||
47 | + ); | ||
48 | + const playerIndex = findIndex({ rosterSpotIndex }, sortedPlayersSpots); | ||
49 | + if (playerIndex > -1) { | ||
50 | + navigateToProfile(playerIndex); | ||
51 | + } else { | ||
52 | + const position = getOr('', rosterSpots[index], ABBREV); | ||
53 | + navigation.navigate(TAB.Market, { | ||
54 | + position: rosterSpots[index] !== POSITIONS.BENCH ? position : null | ||
55 | + }); | ||
56 | + } | ||
57 | + }; | ||
58 | + | ||
59 | + return ( | ||
60 | + <ListGrid {...props}> | ||
61 | + {rosterSpots.map( | ||
62 | + (position: string, index: number) => ( | ||
63 | + <TouchableOpacity | ||
64 | + // eslint-disable-next-line react/no-array-index-key | ||
65 | + key={`player-${position}-${index}`} | ||
66 | + onPress={handleOnPressItem(index)} | ||
67 | + > | ||
68 | + <View> | ||
69 | + {index === benchIndex && <BenchDivider />} | ||
70 | + <DefaultPlayerRow | ||
71 | + active={!isEqual(rosterSpots[index], POSITIONS.BENCH)} | ||
72 | + position={get(position, ABBREV)} | ||
73 | + player={get(index, franchisePlayers)} | ||
74 | + showMarketTrend={showMarketTrend} | ||
75 | + /> | ||
76 | + </View> | ||
77 | + </TouchableOpacity> | ||
78 | + ), | ||
79 | + rosterSpots | ||
80 | + )} | ||
81 | + </ListGrid> | ||
82 | + ); | ||
83 | +}; | ||
84 | + | ||
85 | +export default RosterList; |
1 | +import { gql } from '@apollo/client'; | ||
2 | + | ||
3 | +// eslint-disable-next-line import/prefer-default-export | ||
4 | +export const BUY_FRANCHISE_PLAYER = gql` | ||
5 | + mutation CreateFranchisePlayers( | ||
6 | + $franchiseId: String! | ||
7 | + $playerId: String! | ||
8 | + $rosterSpotIndex: Int! | ||
9 | + ) { | ||
10 | + createNflFranchisePlayer( | ||
11 | + data: { | ||
12 | + franchiseId: $franchiseId | ||
13 | + playerId: $playerId | ||
14 | + rosterSpotIndex: $rosterSpotIndex | ||
15 | + } | ||
16 | + ) { | ||
17 | + id | ||
18 | + playerId | ||
19 | + franchiseId | ||
20 | + rosterSpotIndex | ||
21 | + buyPrice | ||
22 | + } | ||
23 | + } | ||
24 | +`; |
1 | +import { gql } from '@apollo/client'; | ||
2 | +import apolloClient from '../../../store/apolloClient'; | ||
3 | + | ||
4 | +// eslint-disable-next-line import/prefer-default-export | ||
5 | +export const FIND_FRANCHISE_PLAYERS = gql` | ||
6 | + query FindFranchisePlayers($franchiseId: String!) { | ||
7 | + findNflFranchisePlayers(filter: { franchiseId: { eq: $franchiseId } }) { | ||
8 | + nodes { | ||
9 | + id | ||
10 | + playerId | ||
11 | + franchiseId | ||
12 | + rosterSpotIndex | ||
13 | + buyPrice | ||
14 | + } | ||
15 | + } | ||
16 | + } | ||
17 | +`; | ||
18 | + | ||
19 | +export default async (franchiseId: string) => { | ||
20 | + try { | ||
21 | + await apolloClient.query({ | ||
22 | + query: FIND_FRANCHISE_PLAYERS, | ||
23 | + variables: { franchiseId }, | ||
24 | + fetchPolicy: 'network-only' | ||
25 | + }); | ||
26 | + return null; | ||
27 | + } catch (e) { | ||
28 | + return null; | ||
29 | + } | ||
30 | +}; |
1 | +import { gql } from '@apollo/client'; | ||
2 | +import { get } from 'lodash/fp'; | ||
3 | +import apolloClient from '../../../store/apolloClient'; | ||
4 | + | ||
5 | +export const FIND_USER_CURRENT_NFL_SEASON_FRANCHISE = gql` | ||
6 | + query FindNflSeasonFranchises($userId: String!) { | ||
7 | + currentNflSeason { | ||
8 | + id | ||
9 | + franchises(filter: { ownerId: { eq: $userId } }) { | ||
10 | + nodes { | ||
11 | + id | ||
12 | + name | ||
13 | + fzCash | ||
14 | + } | ||
15 | + } | ||
16 | + } | ||
17 | + } | ||
18 | +`; | ||
19 | + | ||
20 | +export default async (userId: string) => { | ||
21 | + try { | ||
22 | + const { data } = await apolloClient.query({ | ||
23 | + query: FIND_USER_CURRENT_NFL_SEASON_FRANCHISE, | ||
24 | + variables: { userId }, | ||
25 | + fetchPolicy: 'network-only' | ||
26 | + }); | ||
27 | + return get('currentNflSeason.franchises.nodes.0', data); | ||
28 | + } catch (e) { | ||
29 | + return null; | ||
30 | + } | ||
31 | +}; |
1 | +import { gql } from '@apollo/client'; | ||
2 | + | ||
3 | +// eslint-disable-next-line import/prefer-default-export | ||
4 | +export const FIND_USER_CURRENT_NFL_SEASON_PLAYER = gql` | ||
5 | + query FindCurrentNflSeasonPlayer($playerId: String!) { | ||
6 | + currentNflSeason { | ||
7 | + id | ||
8 | + player(id: $playerId) { | ||
9 | + id | ||
10 | + lastName | ||
11 | + firstName | ||
12 | + fantasyPointsPerGame | ||
13 | + playerPositions | ||
14 | + value | ||
15 | + details { | ||
16 | + profilePhotos | ||
17 | + } | ||
18 | + team { | ||
19 | + abbreviation | ||
20 | + } | ||
21 | + } | ||
22 | + } | ||
23 | + } | ||
24 | +`; |
1 | +<?php | ||
2 | + | ||
3 | +namespace NexmoBundle\Command; | ||
4 | + | ||
5 | +use AppBundle\Entity\AdminUser; | ||
6 | +use AppBundle\Entity\OpentokSessionTypes; | ||
7 | +use AppBundle\Entity\Patient; | ||
8 | +use AppBundle\Entity\VoiceCall; | ||
9 | +use Doctrine\Bundle\DoctrineBundle\Registry; | ||
10 | +use Monolog\Logger; | ||
11 | +use NexmoBundle\Manager\InboundCallManager; | ||
12 | +use OpenTokBundle\Pool\GlobalAdminOpenTokPool; | ||
13 | +use Symfony\Bundle\FrameworkBundle\Command\ContainerAwareCommand; | ||
14 | +use Symfony\Component\Console\Input\InputArgument; | ||
15 | +use Symfony\Component\Console\Input\InputInterface; | ||
16 | +use Symfony\Component\Console\Input\InputOption; | ||
17 | +use Symfony\Component\Console\Output\OutputInterface; | ||
18 | + | ||
19 | +class NexmoBroadcastInboundCallCommand extends ContainerAwareCommand | ||
20 | +{ | ||
21 | + /** | ||
22 | + * @var Logger | ||
23 | + */ | ||
24 | + private $logger; | ||
25 | + | ||
26 | + protected function configure() | ||
27 | + { | ||
28 | + $this | ||
29 | + ->setName('nexmo:broadcast-inbound-call') | ||
30 | + ->setDescription('Broadcast inbound voice call') | ||
31 | + ->addArgument('voice_call', InputArgument::REQUIRED, 'Voice call id') | ||
32 | + ->addOption("cobrand", "c", InputOption::VALUE_OPTIONAL, 'Cobrand code of executing context') | ||
33 | + ; | ||
34 | + } | ||
35 | + | ||
36 | + private function logString($s) | ||
37 | + { | ||
38 | + return "[{$this->getName()}]: {$s}"; | ||
39 | + } | ||
40 | + | ||
41 | + /** | ||
42 | + * @param InputInterface $input | ||
43 | + * @param OutputInterface $output | ||
44 | + * @return int|void|null | ||
45 | + * @throws \Exception | ||
46 | + */ | ||
47 | + protected function execute(InputInterface $input, OutputInterface $output) | ||
48 | + { | ||
49 | + $this->logger = $this->getContainer()->get("logger"); | ||
50 | + | ||
51 | + /** @var InboundCallManager $manager */ | ||
52 | + $manager = $this->getContainer()->get("nexmo.manager.inbound_call"); | ||
53 | + /** @var VoiceCall $voiceCall */ | ||
54 | + $voiceCall = $manager->getObjectOrHalt($input->getArgument('voice_call')); | ||
55 | + $manager->initThreadContent($voiceCall); | ||
56 | + $opentokData = $manager->asOpenTokSignalData($voiceCall); | ||
57 | + | ||
58 | + if ($voiceCall->getRecipient() instanceof AdminUser) { | ||
59 | + /** @var GlobalAdminOpenTokPool $pool */ | ||
60 | + $pool = $this->getContainer()->get("opentok_pool_factory")->get(OpentokSessionTypes::ADMIN_POOL); | ||
61 | + // send opentok signal to admin user | ||
62 | + $pool->sendSignal("inbound-call", $opentokData); | ||
63 | + } | ||
64 | + | ||
65 | + $this->logger->info($this->logString("Broadcasting voice call #{$voiceCall->getId()}")); | ||
66 | + } | ||
67 | +} |
1 | +<?php | ||
2 | + | ||
3 | +namespace NexmoBundle\Controller; | ||
4 | + | ||
5 | +use AppBundle\Entity\Patient; | ||
6 | +use AppBundle\Entity\VoiceCall; | ||
7 | +use AppBundle\Event\VoiceCallEvent; | ||
8 | +use AppBundle\Event\VoiceCallEvents; | ||
9 | +use CobrandBundle\CobrandInstance; | ||
10 | +use FOS\RestBundle\Controller\Annotations\Get; | ||
11 | +use FOS\RestBundle\Controller\Annotations\Post; | ||
12 | +use JMS\DiExtraBundle\Annotation\Inject; | ||
13 | +use NexmoBundle\VoiceCall\VoiceCallOrigins; | ||
14 | +use NexmoBundle\VoiceCall\VoiceCallStatus; | ||
15 | +use NexmoBundle\WebHook\OnAnswerInboundWebHook; | ||
16 | +use NexmoBundle\WebHook\OnEventInboundWebHook; | ||
17 | +use Sentry\SentryBundle\SentrySymfonyClient; | ||
18 | +use Symfony\Component\HttpFoundation\Request; | ||
19 | +use Symfony\Component\HttpFoundation\Response; | ||
20 | + | ||
21 | +class InboundCallWebHookController extends NexmoWebHookController | ||
22 | +{ | ||
23 | + /** | ||
24 | + * @var OnAnswerInboundWebHook | ||
25 | + * @Inject("nexmo.webhooks.answer_inbound") | ||
26 | + */ | ||
27 | + private $answerHandler; | ||
28 | + | ||
29 | + /** | ||
30 | + * @var OnEventInboundWebHook | ||
31 | + * @Inject("nexmo.webhooks.event_inbound") | ||
32 | + */ | ||
33 | + private $eventHookHandler; | ||
34 | + | ||
35 | + /** | ||
36 | + * Inbound call answer url | ||
37 | + * @Get("/answer-url", name="answer_webhook") | ||
38 | + * @param Request $request | ||
39 | + * @return \Symfony\Component\HttpFoundation\JsonResponse | ||
40 | + * @throws \Doctrine\DBAL\ConnectionException | ||
41 | + */ | ||
42 | + public function answerUrlAction(Request $request) | ||
43 | + { | ||
44 | + return $this->answerHandler->handle($request); | ||
45 | + } | ||
46 | + | ||
47 | + /** | ||
48 | + * @Post("/event-url", name="event_webhook") | ||
49 | + * @param Request $request | ||
50 | + * @return \Symfony\Component\HttpFoundation\JsonResponse | ||
51 | + * @throws \Exception | ||
52 | + */ | ||
53 | + public function eventUrlAction(Request $request) | ||
54 | + { | ||
55 | + return $this->eventHookHandler->handle($request); | ||
56 | + } | ||
57 | +} |
1 | +<?php | ||
2 | + | ||
3 | +namespace NexmoBundle\Controller; | ||
4 | + | ||
5 | +use AppBundle\Controller\SpokeControllerTrait; | ||
6 | +use AppBundle\Entity\VoiceCall; | ||
7 | +use CobrandBundle\CobrandInstance; | ||
8 | +use Symfony\Bundle\FrameworkBundle\Controller\Controller; | ||
9 | + | ||
10 | +class NexmoWebHookController extends Controller | ||
11 | +{ | ||
12 | + use SpokeControllerTrait; | ||
13 | + | ||
14 | + /** | ||
15 | + * @param $s | ||
16 | + * @return string | ||
17 | + */ | ||
18 | + protected function logString($s) | ||
19 | + { | ||
20 | + return "[nexmo_web_hook] => {$s}"; | ||
21 | + } | ||
22 | + | ||
23 | + /** | ||
24 | + * @return string | ||
25 | + */ | ||
26 | + protected function getCurrentAppName() | ||
27 | + { | ||
28 | + try { | ||
29 | + $appName = $this->getCurrentCobrand()->getApp()->getName(); | ||
30 | + } catch (\Exception $exception) { | ||
31 | + $appName = "Spoke Health"; | ||
32 | + } | ||
33 | + | ||
34 | + return $appName; | ||
35 | + } | ||
36 | + | ||
37 | + | ||
38 | + /** | ||
39 | + * @param $uuid | ||
40 | + * @return VoiceCall | ||
41 | + */ | ||
42 | + protected function getVoiceCallByUUID($uuid) | ||
43 | + { | ||
44 | + | ||
45 | + return $this->getDoctrine()->getRepository("AppBundle:VoiceCall")->findOneBy(["uuid" => $uuid]); | ||
46 | + } | ||
47 | + | ||
48 | + /** | ||
49 | + * @param $uuid | ||
50 | + * @return VoiceCall | ||
51 | + * @throws \Exception | ||
52 | + */ | ||
53 | + protected function findOrCreateVoiceCallByUuid($uuid) | ||
54 | + { | ||
55 | + $voiceCall = $this->getVoiceCallByUUID($uuid); | ||
56 | + if (!$voiceCall instanceof VoiceCall) { | ||
57 | + $this->get("logger")->info($this->logString("Creating new VoiceCall - UUID: {$uuid}")); | ||
58 | + // create voice call for this inbound call | ||
59 | + $voiceCall = new VoiceCall(); | ||
60 | + $voiceCall->setCreatedAt(new \DateTime("now")); | ||
61 | + } | ||
62 | + | ||
63 | + return $voiceCall; | ||
64 | + } | ||
65 | +} |
1 | +<?php | ||
2 | + | ||
3 | +namespace NexmoBundle\Controller; | ||
4 | + | ||
5 | +use AppBundle\Controller\SpokeControllerTrait; | ||
6 | +use AppBundle\Entity\User; | ||
7 | +use AppBundle\Entity\VoiceCall; | ||
8 | +use AppBundle\Event\VoiceCallEvent; | ||
9 | +use AppBundle\Event\VoiceCallEvents; | ||
10 | +use CobrandBundle\CobrandInstance; | ||
11 | +use Doctrine\ORM\ORMException; | ||
12 | +use FOS\RestBundle\Controller\Annotations\Get; | ||
13 | +use FOS\RestBundle\Controller\Annotations\Post; | ||
14 | + | ||
15 | +use NexmoBundle\VoiceCall\VoiceCallOrigins; | ||
16 | +use NexmoBundle\VoiceCall\VoiceCallStatus; | ||
17 | +use Symfony\Bundle\FrameworkBundle\Controller\Controller; | ||
18 | +use Symfony\Component\HttpFoundation\Request; | ||
19 | +use Symfony\Component\HttpFoundation\Response; | ||
20 | + | ||
21 | +class OutboundCallWebHookController extends NexmoWebHookController | ||
22 | +{ | ||
23 | + /** | ||
24 | + * @Post("/event-url", name="event_webhook") | ||
25 | + * | ||
26 | + * @param Request $request | ||
27 | + * @return \Symfony\Component\HttpFoundation\JsonResponse | ||
28 | + */ | ||
29 | + public function postEventAction(Request $request) | ||
30 | + { | ||
31 | + $uuid = $request->get("uuid"); | ||
32 | + $voiceCall = $this->getDoctrine()->getRepository("AppBundle:VoiceCall")->findOneBy([ | ||
33 | + "uuid" => $uuid | ||
34 | + ]); | ||
35 | + | ||
36 | + if (!$voiceCall instanceof VoiceCall) { | ||
37 | + return $this->jsonResponse(["success" => false], Response::HTTP_OK); | ||
38 | + } | ||
39 | + | ||
40 | + $delegates = []; | ||
41 | + $requestMap = [ | ||
42 | + "status" => function ($val) use ($voiceCall, &$delegates) { | ||
43 | + $voiceCall->setStatus($val); | ||
44 | + // add delegate for changing statuses | ||
45 | + $delegates[] = function () use ($voiceCall) { | ||
46 | + $event = new VoiceCallEvent($voiceCall); | ||
47 | + | ||
48 | + switch($voiceCall->getStatus()) { | ||
49 | + case VoiceCallStatus::STARTED: | ||
50 | + case VoiceCallStatus::RINGING: | ||
51 | + $this->get("event_dispatcher")->dispatch(VoiceCallEvents::STARTED_VOICE_CALL, $event); | ||
52 | + break; | ||
53 | + case VoiceCallStatus::ANSWERED: | ||
54 | + $this->get("event_dispatcher")->dispatch(VoiceCallEvents::ANSWERED_VOICE_CALL, $event); | ||
55 | + break; | ||
56 | + case VoiceCallStatus::BUSY: | ||
57 | + case VoiceCallStatus::CANCELLED: | ||
58 | + case VoiceCallStatus::REJECTED: | ||
59 | + case VoiceCallStatus::FAILED: | ||
60 | + $this->get("event_dispatcher")->dispatch(VoiceCallEvents::FAILED_VOICE_CALL, $event); | ||
61 | + break; | ||
62 | + case VoiceCallStatus::COMPLETED: | ||
63 | + $this->get("event_dispatcher")->dispatch(VoiceCallEvents::COMPLETED_VOICE_CALL, $event); | ||
64 | + break; | ||
65 | + } | ||
66 | + }; // end delegate func | ||
67 | + }, | ||
68 | + "timestamp" => function ($val) use ($voiceCall) { | ||
69 | + $dt = new \DateTime($val); | ||
70 | + $voiceCall->setUpdatedAt($dt); | ||
71 | + }, | ||
72 | + "rate" => function ($val) use ($voiceCall) { | ||
73 | + $voiceCall->setRate($val); | ||
74 | + }, | ||
75 | + "price" => function ($val) use ($voiceCall) { | ||
76 | + $voiceCall->setPrice($val); | ||
77 | + }, | ||
78 | + "duration" => function ($val) use ($voiceCall) { | ||
79 | + $voiceCall->setDuration($val); | ||
80 | + }, | ||
81 | + ]; | ||
82 | + | ||
83 | + foreach ($request->request->all() as $key => $val) { | ||
84 | + if (array_key_exists($key, $requestMap)) { | ||
85 | + $requestMap[$key]($val); | ||
86 | + } | ||
87 | + } | ||
88 | + | ||
89 | + $em = $this->getEntityManager(); | ||
90 | + $em->persist($voiceCall); | ||
91 | + | ||
92 | + try { | ||
93 | + $em->flush(); | ||
94 | + if (!empty($delegates)) { | ||
95 | + foreach ($delegates as $key => $eachDelegate) { | ||
96 | + $eachDelegate(); | ||
97 | + } | ||
98 | + } | ||
99 | + | ||
100 | + return $this->jsonResponse(["success" => true], Response::HTTP_OK); | ||
101 | + } catch (\Exception $exception) { | ||
102 | + // webhooks need 200 response | ||
103 | + return $this->jsonResponse(["success" => false], Response::HTTP_OK); | ||
104 | + } | ||
105 | + } | ||
106 | + | ||
107 | + | ||
108 | + /** | ||
109 | + * @Get("/answer-url", name="answer_webhook") | ||
110 | + * | ||
111 | + * @param Request $request | ||
112 | + * @return \Symfony\Component\HttpFoundation\JsonResponse | ||
113 | + */ | ||
114 | + public function getAnswerWebHookAction(Request $request) | ||
115 | + { | ||
116 | + $callerUser = $this->getDoctrine()->getRepository("AppBundle:User") | ||
117 | + ->find($request->get("caller_uid", 0)); | ||
118 | + | ||
119 | + $eventUrl = base64_decode($request->get('event_url')); | ||
120 | + | ||
121 | + if ($callerUser instanceof User) { | ||
122 | + $proxyNumber = (string) $callerUser->getProfile()->getPrimaryContactNumber(); | ||
123 | + } | ||
124 | + | ||
125 | + $appName = "Spoke Health"; | ||
126 | + $currentCobrand = $this->get("cobrand.factory")->current(); | ||
127 | + if ($currentCobrand instanceof CobrandInstance) { | ||
128 | + $appName = $currentCobrand->getApp()->getName(); | ||
129 | + } | ||
130 | + | ||
131 | + $ncoo = [ | ||
132 | + [ | ||
133 | + "action" => "talk", | ||
134 | + "text" => "Hi! Please stay on the line while we connect you to your assigned employee on {$appName}." | ||
135 | + ], | ||
136 | + [ | ||
137 | + "action" => "connect", | ||
138 | + "timeout" => 45, | ||
139 | + "from" => $this->getParameter("nexmo_provisioned_number"), | ||
140 | + "eventUrl" => [$eventUrl], | ||
141 | + "endpoint" => [[ | ||
142 | + "type" => "phone", | ||
143 | + "number" => $proxyNumber | ||
144 | + ]] | ||
145 | + ] | ||
146 | + ]; | ||
147 | + | ||
148 | + return $this->jsonResponse($ncoo, Response::HTTP_OK); | ||
149 | + } | ||
150 | +} |
1 | +<?php | ||
2 | + | ||
3 | +namespace NexmoBundle\Event\Logger\Command; | ||
4 | + | ||
5 | +use Chromedia\AuditLogBundle\Entity\Action; | ||
6 | +use Chromedia\AuditLogBundle\Entity\ObjectName; | ||
7 | +use Chromedia\AuditLogBundle\Entity\ObjectType; | ||
8 | +use NexmoBundle\Event\Logger\VoiceCallAuditLogCommand; | ||
9 | +use NexmoBundle\VoiceCall\VoiceCallStatus; | ||
10 | + | ||
11 | +class InboundVoiceCallCommand extends VoiceCallAuditLogCommand | ||
12 | +{ | ||
13 | + public function isProcessable() | ||
14 | + { | ||
15 | + return parent::isProcessable(); | ||
16 | + } | ||
17 | + | ||
18 | + protected function buildLogData() | ||
19 | + { | ||
20 | + $actor = $this->voiceCallLog->getActor(); | ||
21 | + $subject = $actor | ||
22 | + ? $actor->getProfile()->getFullName() | ||
23 | + : $this->voiceCallLog->getFromNumber(); | ||
24 | + $data = [ | ||
25 | + // to show in patient logs, set object id to patient, object type account | ||
26 | + "objectId" => $actor ? $actor->getId() : null, | ||
27 | + "objectType" => ObjectType::ACCOUNT, | ||
28 | + "objectName" => ObjectName::VOICE_CALL, | ||
29 | + "actionStatus" => Action::STATUS_SUCCESS, | ||
30 | + "subject" => $subject | ||
31 | + ]; | ||
32 | + | ||
33 | + // actor will be admin recipient, since activity logs should always show in admin user's context | ||
34 | + if ($actor) { | ||
35 | + $data["actor"] = $this->voiceCallLog->getRecipient(); | ||
36 | + } | ||
37 | + | ||
38 | + $statusHandlers = [ | ||
39 | + VoiceCallStatus::ANSWERED => function () use (&$data, $subject) { | ||
40 | + $data["description"] = "{$subject} started an inbound call"; | ||
41 | + $data["action"] = Action::STATUS_SUCCESS; | ||
42 | + }, | ||
43 | + VoiceCallStatus::COMPLETED => function () use (&$data, $subject) { | ||
44 | + $data["description"] = "completed an inbound call with {$subject} with duration: ({$this->voiceCallLog->getFormattedDuration()}). "; | ||
45 | + $data["action"] = Action::STATUS_SUCCESS; | ||
46 | + }, | ||
47 | + VoiceCallStatus::FAILED => function () use (&$data, $subject) { | ||
48 | + $data["description"] = "failed to complete an inbound call with {$subject} ({$this->voiceCallLog->getFormattedDuration()})."; | ||
49 | + $data["action"] = Action::STATUS_FAILURE; | ||
50 | + }, | ||
51 | + VoiceCallStatus::BUSY => function () use (&$data, $subject) { | ||
52 | + $data["description"] = "failed to complete an inbound call with {$subject} ({$this->voiceCallLog->getFormattedDuration()})."; | ||
53 | + $data["action"] = Action::STATUS_FAILURE; | ||
54 | + }, | ||
55 | + ]; | ||
56 | + | ||
57 | + if (array_key_exists($this->voiceCallLog->getStatus(), $statusHandlers)) { | ||
58 | + $statusHandlers[$this->voiceCallLog->getStatus()](); | ||
59 | + } | ||
60 | + | ||
61 | + return $data; | ||
62 | + } | ||
63 | +} |
1 | +<?php | ||
2 | + | ||
3 | +namespace NexmoBundle\Event\Logger\Command; | ||
4 | + | ||
5 | +use AppBundle\Entity\VoiceCall; | ||
6 | +use Chromedia\AuditLogBundle\Entity\Action; | ||
7 | +use Chromedia\AuditLogBundle\Entity\ObjectName; | ||
8 | +use Chromedia\AuditLogBundle\Entity\ObjectType; | ||
9 | +use NexmoBundle\Event\Logger\VoiceCallAuditLogCommand; | ||
10 | +use NexmoBundle\VoiceCall\VoiceCallStatus; | ||
11 | + | ||
12 | +class UpdateVoiceCallStatusCommand extends VoiceCallAuditLogCommand | ||
13 | +{ | ||
14 | + | ||
15 | + public function isProcessable() | ||
16 | + { | ||
17 | + $knownStatus = [ | ||
18 | + VoiceCallStatus::STARTED, | ||
19 | + VoiceCallStatus::COMPLETED, | ||
20 | + VoiceCallStatus::FAILED, | ||
21 | + VoiceCallStatus::BUSY | ||
22 | + ]; | ||
23 | + | ||
24 | + return parent::isProcessable() && \in_array($this->voiceCallLog->getStatus(), $knownStatus); | ||
25 | + } | ||
26 | + | ||
27 | + protected function buildLogData() | ||
28 | + { | ||
29 | + $actor = $this->voiceCallLog->getActor(); | ||
30 | + $data = [ | ||
31 | + // to show in patient logs, set object id to patient, object type account | ||
32 | + "objectId" => $actor ? $actor->getId() : "Unknown", | ||
33 | + "objectType" => ObjectType::ACCOUNT, | ||
34 | + "objectName" => ObjectName::VOICE_CALL, | ||
35 | + "actionStatus" => Action::STATUS_SUCCESS, | ||
36 | + "subject" => $this->voiceCallLog->getActor() | ||
37 | + ? $this->voiceCallLog->getActor()->getProfile()->getFullName() | ||
38 | + : "Anonymous", | ||
39 | + ]; | ||
40 | + | ||
41 | + $recipientName = $actor | ||
42 | + ? $this->voiceCallLog->getActor()->getProfile()->getFullName() | ||
43 | + : "Unknown"; | ||
44 | + | ||
45 | + $statusHandlers = [ | ||
46 | + VoiceCallStatus::STARTED => function () use (&$data, $recipientName) { | ||
47 | + $data["description"] = "started a voice call with {$recipientName}."; | ||
48 | + $data["action"] = Action::STATUS_SUCCESS; | ||
49 | + }, | ||
50 | + VoiceCallStatus::COMPLETED => function () use (&$data, $recipientName) { | ||
51 | + $data["description"] = "completed a voice call with {$recipientName} ({$this->voiceCallLog->getFormattedDuration()}). "; | ||
52 | + $data["action"] = Action::STATUS_SUCCESS; | ||
53 | + }, | ||
54 | + VoiceCallStatus::FAILED => function () use (&$data, $recipientName) { | ||
55 | + $data["description"] = "failed to complete a voice call with {$recipientName} ({$this->voiceCallLog->getFormattedDuration()})."; | ||
56 | + $data["action"] = Action::STATUS_FAILURE; | ||
57 | + }, | ||
58 | + VoiceCallStatus::BUSY => function () use (&$data, $recipientName) { | ||
59 | + $data["description"] = "failed to complete a voice call with {$recipientName} ({$this->voiceCallLog->getFormattedDuration()})."; | ||
60 | + $data["action"] = Action::STATUS_FAILURE; | ||
61 | + }, | ||
62 | + ]; | ||
63 | + | ||
64 | + if (array_key_exists($this->voiceCallLog->getStatus(), $statusHandlers)) { | ||
65 | + $statusHandlers[$this->voiceCallLog->getStatus()](); | ||
66 | + } | ||
67 | + | ||
68 | + return $data; | ||
69 | + } | ||
70 | +} |
1 | +<?php | ||
2 | + | ||
3 | +namespace NexmoBundle\Event\Logger; | ||
4 | + | ||
5 | +use AppBundle\Entity\User; | ||
6 | +use AppBundle\Entity\VoiceCall; | ||
7 | +use AppBundle\Event\VoiceCallEvent; | ||
8 | +use Symfony\Component\EventDispatcher\Event; | ||
9 | + | ||
10 | +abstract class VoiceCallAuditLogCommand | ||
11 | +{ | ||
12 | + /** | ||
13 | + * @var VoiceCallEvent | ||
14 | + */ | ||
15 | + protected $event; | ||
16 | + | ||
17 | + /** | ||
18 | + * @var VoiceCall | ||
19 | + */ | ||
20 | + protected $voiceCallLog; | ||
21 | + | ||
22 | + protected $logData = []; | ||
23 | + | ||
24 | + public function isProcessable() | ||
25 | + { | ||
26 | + $this->event = $this->event instanceof VoiceCallEvent ? $this->event : null; | ||
27 | + $this->voiceCallLog = $this->event ? $this->event->getVoiceCall() : null; | ||
28 | + | ||
29 | + return $this->event && $this->voiceCallLog instanceof VoiceCall && $this->voiceCallLog->getDuration() > 0; | ||
30 | + } | ||
31 | + | ||
32 | + abstract protected function buildLogData(); | ||
33 | + | ||
34 | + protected function commonLogData() | ||
35 | + { | ||
36 | + /** | ||
37 | + * actor, currently our webhook sets caller as the employee and recipient as the employee | ||
38 | + */ | ||
39 | + return array( | ||
40 | + "actionDateTime" => new \DateTime(), | ||
41 | + "actor" => $this->event->getVoiceCall()->getRecipient() | ||
42 | + ); | ||
43 | + } | ||
44 | + | ||
45 | + final public function resolve(Event $event) | ||
46 | + { | ||
47 | + $this->event = $event; | ||
48 | + if ($this->isProcessable()) { | ||
49 | + $this->logData = array_merge($this->commonLogData(), $this->buildLogData()) ; | ||
50 | + } | ||
51 | + | ||
52 | + return $this->logData; | ||
53 | + } | ||
54 | +} |
1 | +<?php | ||
2 | + | ||
3 | +namespace NexmoBundle\Event\Logger; | ||
4 | + | ||
5 | +use AppBundle\Entity\User; | ||
6 | +use AppBundle\Event\VoiceCallEvents; | ||
7 | +use Chromedia\AuditLogBundle\Resolver\EventResolverInterface; | ||
8 | +use NexmoBundle\Event\Logger\Command\InboundVoiceCallCommand; | ||
9 | +use NexmoBundle\Event\Logger\Command\UpdateVoiceCallStatusCommand; | ||
10 | +use Symfony\Component\EventDispatcher\Event; | ||
11 | +use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorage; | ||
12 | + | ||
13 | +class VoiceCallAuditLogResolver implements EventResolverInterface | ||
14 | +{ | ||
15 | + protected $commands; | ||
16 | + | ||
17 | + /** | ||
18 | + * @var TokenStorage | ||
19 | + */ | ||
20 | + protected $tokenStorage; | ||
21 | + | ||
22 | + public function __construct() | ||
23 | + { | ||
24 | + $this->commands = [ | ||
25 | + VoiceCallEvents::COMPLETED_VOICE_CALL => UpdateVoiceCallStatusCommand::class, | ||
26 | + VoiceCallEvents::FAILED_VOICE_CALL => UpdateVoiceCallStatusCommand::class, | ||
27 | + VoiceCallEvents::STARTED_VOICE_CALL => UpdateVoiceCallStatusCommand::class, | ||
28 | + VoiceCallEvents::INBOUND_CALL_ONGOING => InboundVoiceCallCommand::class, | ||
29 | + VoiceCallEvents::INBOUND_CALL_COMPLETED => InboundVoiceCallCommand::class | ||
30 | + ]; | ||
31 | + } | ||
32 | + | ||
33 | + public function setTokenStorage(TokenStorage $v) | ||
34 | + { | ||
35 | + $this->tokenStorage = $v; | ||
36 | + } | ||
37 | + | ||
38 | + /** | ||
39 | + * @param Event $event | ||
40 | + * @param string $eventName | ||
41 | + * @return mixed | ||
42 | + */ | ||
43 | + public function getEventLogInfo(Event $event, $eventName) | ||
44 | + { | ||
45 | + if (!isset($this->commands[$eventName])) { | ||
46 | + return; | ||
47 | + } | ||
48 | + | ||
49 | + $class = $this->commands[$eventName]; | ||
50 | + $command = new $class(); | ||
51 | + if ($command instanceof VoiceCallAuditLogCommand) { | ||
52 | + return $command->resolve($event); | ||
53 | + } | ||
54 | + } | ||
55 | +} |
1 | +<?php | ||
2 | +namespace NexmoBundle\Event\Logger; | ||
3 | + | ||
4 | +use AppBundle\Event\VoiceCallEvents; | ||
5 | +use Chromedia\AuditLogBundle\Subscriber\AuditLogEventSubscriberInterface; | ||
6 | + | ||
7 | +class VoiceCallAuditLogSubscriber implements AuditLogEventSubscriberInterface | ||
8 | +{ | ||
9 | + | ||
10 | + public function getSubscribedEvents() | ||
11 | + { | ||
12 | + return [ | ||
13 | + VoiceCallEvents::STARTED_VOICE_CALL, | ||
14 | + VoiceCallEvents::FAILED_VOICE_CALL, | ||
15 | + VoiceCallEvents::COMPLETED_VOICE_CALL, | ||
16 | + VoiceCallEvents::INBOUND_CALL_ONGOING, | ||
17 | + VoiceCallEvents::INBOUND_CALL_COMPLETED | ||
18 | + ]; | ||
19 | + } | ||
20 | +} |
1 | +<?php | ||
2 | + | ||
3 | +namespace NexmoBundle\Event\Subscriber; | ||
4 | + | ||
5 | +use AppBundle\Entity\ThreadContent; | ||
6 | +use AppBundle\Entity\ThreadContentTypes; | ||
7 | +use AppBundle\Entity\VoiceCall; | ||
8 | +use AppBundle\Event\VoiceCallEvent; | ||
9 | +use AppBundle\Event\VoiceCallEvents; | ||
10 | +use AppBundle\Service\OpentokSignalingService; | ||
11 | +use Doctrine\Bundle\DoctrineBundle\Registry; | ||
12 | +use Symfony\Component\EventDispatcher\EventSubscriberInterface; | ||
13 | + | ||
14 | +/** | ||
15 | + * Class ThreadContentVoiceCallEventSubscriber | ||
16 | + * | ||
17 | + */ | ||
18 | +class ThreadContentVoiceCallEventSubscriber implements EventSubscriberInterface | ||
19 | +{ | ||
20 | + /** | ||
21 | + * @var Registry | ||
22 | + */ | ||
23 | + protected $doctrine; | ||
24 | + | ||
25 | + /** | ||
26 | + * @var OpentokSignalingService | ||
27 | + */ | ||
28 | + protected $opentokSignaling; | ||
29 | + | ||
30 | + public function setDoctrine(Registry $v) | ||
31 | + { | ||
32 | + $this->doctrine = $v; | ||
33 | + } | ||
34 | + | ||
35 | + public function setOpentokSignaling(OpentokSignalingService $v) | ||
36 | + { | ||
37 | + $this->opentokSignaling = $v; | ||
38 | + } | ||
39 | + | ||
40 | + public function onStatusChange(VoiceCallEvent $event) | ||
41 | + { | ||
42 | + if (!$event->getVoiceCall() instanceof VoiceCall) { | ||
43 | + return; | ||
44 | + } | ||
45 | + | ||
46 | + /** @var ThreadContent $threadContent */ | ||
47 | + $threadContent = $this->doctrine->getRepository("AppBundle:ThreadContent") | ||
48 | + ->findOneBy(["contentId" => $event->getVoiceCall()->getId(), "type" => ThreadContentTypes::VOICE_CALL_LOG]); | ||
49 | + | ||
50 | + if (!$threadContent instanceof ThreadContent) { | ||
51 | + return; | ||
52 | + } | ||
53 | + | ||
54 | + | ||
55 | + $this->opentokSignaling->sendSignal($threadContent->getThread(), "voice_call_update_content", ["thread_content" => $threadContent->getId()]); | ||
56 | + } | ||
57 | + | ||
58 | + public static function getSubscribedEvents() | ||
59 | + { | ||
60 | + return [ | ||
61 | + VoiceCallEvents::STARTED_VOICE_CALL => 'onStatusChange', | ||
62 | + VoiceCallEvents::ANSWERED_VOICE_CALL => 'onStatusChange', | ||
63 | + VoiceCallEvents::COMPLETED_VOICE_CALL => 'onStatusChange', | ||
64 | + VoiceCallEvents::FAILED_VOICE_CALL => 'onStatusChange' | ||
65 | + ]; | ||
66 | + } | ||
67 | +} |
1 | +<?php | ||
2 | + | ||
3 | + | ||
4 | +namespace NexmoBundle\Manager; | ||
5 | + | ||
6 | +use AppBundle\Entity\AdminUser; | ||
7 | +use AppBundle\Entity\OpentokSessionTypes; | ||
8 | +use AppBundle\Entity\Patient; | ||
9 | +use AppBundle\Entity\ThreadContent; | ||
10 | +use AppBundle\Entity\VoiceCall; | ||
11 | +use AppBundle\Exception\NotExistEntityException; | ||
12 | +use AppBundle\Service\Manager\ContactNumberManager; | ||
13 | +use AppBundle\Service\Manager\PatientManager; | ||
14 | +use AppBundle\Service\Manager\ThreadNoteManager; | ||
15 | +use AppBundle\Service\ThreadContentFactory; | ||
16 | +use JMS\DiExtraBundle\Annotation\Inject; | ||
17 | +use JMS\DiExtraBundle\Annotation\InjectParams; | ||
18 | +use JMS\DiExtraBundle\Annotation\Service; | ||
19 | +use NexmoBundle\VoiceCall\VoiceCallOrigins; | ||
20 | +use OpenTokBundle\Pool\GlobalAdminOpenTokPool; | ||
21 | +use Symfony\Component\HttpKernel\Exception\BadRequestHttpException; | ||
22 | + | ||
23 | +/** | ||
24 | + * Class InboundCallManager | ||
25 | + * @Service("nexmo.manager.inbound_call", parent="nexmo.base_manager", autowire=true, public=true) | ||
26 | + */ | ||
27 | +class InboundCallManager extends NexmoCallManager | ||
28 | +{ | ||
29 | + /** | ||
30 | + * @var ThreadContentFactory | ||
31 | + */ | ||
32 | + private $threadContentFactory; | ||
33 | + | ||
34 | + /** | ||
35 | + * @var PatientManager | ||
36 | + */ | ||
37 | + private $patientManager; | ||
38 | + | ||
39 | + /** | ||
40 | + * @var ThreadNoteManager | ||
41 | + */ | ||
42 | + private $threadNoteManager; | ||
43 | + | ||
44 | + /** | ||
45 | + * @var ContactNumberManager | ||
46 | + */ | ||
47 | + private $contactNumberManager; | ||
48 | + | ||
49 | + /** | ||
50 | + * Setter Injector for PatientManager | ||
51 | + * @param $patientManager | ||
52 | + * @InjectParams({ | ||
53 | + * "patientManager" = @Inject("spoke.manager.patient", required=false) | ||
54 | + * }) | ||
55 | + */ | ||
56 | + public function setPatientManager(PatientManager $patientManager) | ||
57 | + { | ||
58 | + $this->patientManager = $patientManager; | ||
59 | + } | ||
60 | + | ||
61 | + /** | ||
62 | + * Setter Injector for ThreadContentFactory | ||
63 | + * @param $threadContentFactory | ||
64 | + * @InjectParams({ | ||
65 | + * "threadContentFactory" = @Inject("app.service.thread_content_factory", required=true) | ||
66 | + * }) | ||
67 | + */ | ||
68 | + public function setThreadContentFactory(ThreadContentFactory $threadContentFactory) | ||
69 | + { | ||
70 | + $this->threadContentFactory = $threadContentFactory; | ||
71 | + } | ||
72 | + | ||
73 | + /** | ||
74 | + * Setter injector for ThreadNoteManager | ||
75 | + * @param ThreadNoteManager $manager | ||
76 | + * @InjectParams({ | ||
77 | + * "manager" = @Inject("spoke.manager.thread_note", required=true) | ||
78 | + * }) | ||
79 | + */ | ||
80 | + public function setThreadNoteManager(ThreadNoteManager $manager) | ||
81 | + { | ||
82 | + $this->threadNoteManager = $manager; | ||
83 | + } | ||
84 | + | ||
85 | + /** | ||
86 | + * Setter injector for ContactNumberManager | ||
87 | + * @param ContactNumberManager $contactNumberManager | ||
88 | + * @InjectParams({ | ||
89 | + * "contactNumberManager" = @Inject("spoke.manager.contactNumber", required=true) | ||
90 | + * }) | ||
91 | + */ | ||
92 | + public function setContactNumberManager(ContactNumberManager $contactNumberManager) | ||
93 | + { | ||
94 | + $this->contactNumberManager = $contactNumberManager; | ||
95 | + } | ||
96 | + | ||
97 | + public function create() | ||
98 | + { | ||
99 | + $voiceCall = parent::create(); | ||
100 | + $voiceCall->setOriginType(VoiceCallOrigins::MEMBER_USER_INBOUND_CALL); | ||
101 | + | ||
102 | + return $voiceCall; | ||
103 | + } | ||
104 | + | ||
105 | + /** | ||
106 | + * Set up thread content for this voice call | ||
107 | + * @param VoiceCall $voiceCall | ||
108 | + * @return null | ||
109 | + * @throws \Exception | ||
110 | + */ | ||
111 | + public function initThreadContent(VoiceCall $voiceCall) | ||
112 | + { | ||
113 | + // already created thread content | ||
114 | + if ($voiceCall->getThreadContent()) { | ||
115 | + return null; | ||
116 | + } | ||
117 | + $patient = $voiceCall->getActor(); | ||
118 | + if ($patient instanceof Patient) { | ||
119 | + $voiceCall->setThreadContent($this->threadContentFactory->createContent($patient->getThread(), $voiceCall, $patient)); | ||
120 | + } | ||
121 | + } | ||
122 | + | ||
123 | + public function asOpenTokSignalData(VoiceCall $voiceCall) | ||
124 | + { | ||
125 | + $actorData = [ | ||
126 | + "number" => $voiceCall->getFromNumber() | ||
127 | + ]; | ||
128 | + $patient = $voiceCall->getActor(); | ||
129 | + if ($patient instanceof Patient) { | ||
130 | + $actorData = array_merge($actorData, [ | ||
131 | + "id" => $patient ? $patient->getId() : 0, | ||
132 | + "name" => $patient->getProfile()->getFullName(), | ||
133 | + "company" => $patient->getCompany()->getName() | ||
134 | + ]); | ||
135 | + } | ||
136 | + | ||
137 | + $now = new \DateTime('now'); | ||
138 | + return [ | ||
139 | + "voiceCall" => $voiceCall->getId(), | ||
140 | + "recipient" => $voiceCall->getRecipient() ? $voiceCall->getRecipient()->getId() : null, | ||
141 | + "actor" => $actorData, | ||
142 | + "isCallOngoing" => $voiceCall->getIsCallOngoing(), | ||
143 | + "duration" => $voiceCall->getDuration(), | ||
144 | + "runningDuration" => $now->getTimestamp()-$voiceCall->getCreatedAt()->getTimestamp(), | ||
145 | + "fromNumber" => $voiceCall->getFromNumber() | ||
146 | + ]; | ||
147 | + } | ||
148 | + | ||
149 | + protected function getUpdateMappers(VoiceCall $voiceCall) | ||
150 | + { | ||
151 | + return [ | ||
152 | + "actor" => function ($value) use ($voiceCall) { | ||
153 | + $patient = $this->patientManager->getFind($value); | ||
154 | + | ||
155 | + if (!$patient) { | ||
156 | + throw new \Exception("Invalid patient actor for voice call."); | ||
157 | + } | ||
158 | + | ||
159 | + $voiceCallNumber = $voiceCall->getFromNumber(); | ||
160 | + | ||
161 | + $contactNumber = $this->contactNumberManager->create( | ||
162 | + [ | ||
163 | + 'number' => substr($voiceCallNumber, -10), | ||
164 | + 'country_number' => substr($voiceCallNumber, 0, strlen($voiceCallNumber) % 10), | ||
165 | + 'primary' => false | ||
166 | + ] | ||
167 | + ); | ||
168 | + | ||
169 | + $this->patientManager->update($patient, ['contactNumber' => $contactNumber]); | ||
170 | + $voiceCall->setActor($patient); | ||
171 | + $this->initThreadContent($voiceCall); | ||
172 | + }, | ||
173 | + // create ThreadNote for this VoiceCall | ||
174 | + "notes" => function ($value) use ($voiceCall) { | ||
175 | + $parentThreadContent = $voiceCall->getThreadContent(); | ||
176 | + $recipient = $voiceCall->getRecipient(); | ||
177 | + | ||
178 | + // must have existing thread content for main voice call log, | ||
179 | + // must have a recipient, automatically set as actor for notes | ||
180 | + if ($parentThreadContent && $recipient) { | ||
181 | + $threadNote = $this->threadNoteManager->create($value); | ||
182 | + $this->threadContentFactory->createContent($parentThreadContent->getThread(), $threadNote, $recipient, $parentThreadContent); | ||
183 | + } | ||
184 | + } | ||
185 | + ]; | ||
186 | + } | ||
187 | +} |
1 | +<?php | ||
2 | +namespace NexmoBundle\Manager; | ||
3 | + | ||
4 | +use AppBundle\Entity\VoiceCall; | ||
5 | +use AppBundle\Repository\VoiceCallRepository; | ||
6 | +use AppBundle\Service\Manager\BaseManager; | ||
7 | +use Doctrine\Bundle\DoctrineBundle\Registry; | ||
8 | +use Doctrine\ORM\EntityManager; | ||
9 | +use JMS\DiExtraBundle\Annotation\Inject; | ||
10 | +use JMS\DiExtraBundle\Annotation\InjectParams; | ||
11 | +use JMS\DiExtraBundle\Annotation\Service; | ||
12 | +use Sentry\SentryBundle\SentrySymfonyClient; | ||
13 | +use Symfony\Component\HttpKernel\Exception\BadRequestHttpException; | ||
14 | +use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; | ||
15 | + | ||
16 | +/** | ||
17 | + * Class NexmoCallManager | ||
18 | + * @Service("nexmo.base_manager", autowire=true, public=true) | ||
19 | + */ | ||
20 | +abstract class NexmoCallManager extends BaseManager | ||
21 | +{ | ||
22 | + /** | ||
23 | + * @var VoiceCallRepository | ||
24 | + */ | ||
25 | + protected $repository; | ||
26 | + | ||
27 | + /** | ||
28 | + * @var SentrySymfonyClient | ||
29 | + */ | ||
30 | + protected $sentryClient; | ||
31 | + | ||
32 | + /** | ||
33 | + * @param VoiceCallRepository $voiceCallRepository | ||
34 | + */ | ||
35 | + public function __construct(VoiceCallRepository $voiceCallRepository) | ||
36 | + { | ||
37 | + $this->repository = $voiceCallRepository; | ||
38 | + } | ||
39 | + | ||
40 | + /** | ||
41 | + * @param SentrySymfonyClient $client | ||
42 | + * @InjectParams({ | ||
43 | + * "client" = @Inject("sentry.client", required=false) | ||
44 | + * }) | ||
45 | + */ | ||
46 | + public function setSentryClient(SentrySymfonyClient $client) | ||
47 | + { | ||
48 | + $this->sentryClient = $client; | ||
49 | + } | ||
50 | + | ||
51 | + /** | ||
52 | + * Common query function for all Nexmo related voice call logs since most interact via uuid. | ||
53 | + * @param $uuid | ||
54 | + * @return VoiceCall | ||
55 | + */ | ||
56 | + public function findOneByUUID($uuid) | ||
57 | + { | ||
58 | + return $this->repository->findOneBy(["uuid" => $uuid]); | ||
59 | + } | ||
60 | + | ||
61 | + /** | ||
62 | + * @param string $uuid | ||
63 | + * @return VoiceCall | ||
64 | + */ | ||
65 | + public function findOneByUuidOrHalt($uuid) | ||
66 | + { | ||
67 | + $obj = $this->findOneByUUID($uuid); | ||
68 | + if (!$obj) { | ||
69 | + throw new NotFoundHttpException("Invalid voice call uuid."); | ||
70 | + } | ||
71 | + | ||
72 | + return $obj; | ||
73 | + } | ||
74 | + | ||
75 | + abstract protected function getUpdateMappers(VoiceCall $voiceCall); | ||
76 | + | ||
77 | + /** | ||
78 | + * @param VoiceCall $voiceCall | ||
79 | + * @param array $params | ||
80 | + * @throws \Exception | ||
81 | + */ | ||
82 | + final public function patch(VoiceCall $voiceCall, array $params = []) | ||
83 | + { | ||
84 | + try { | ||
85 | + $this->beginTransaction(); | ||
86 | + $mappers = $this->getUpdateMappers($voiceCall); | ||
87 | + | ||
88 | + foreach ($params as $key => $value) { | ||
89 | + if (isset($mappers[$key]) && !is_null($value)) { | ||
90 | + $mappers[$key]($value); | ||
91 | + } | ||
92 | + } | ||
93 | + | ||
94 | + $this->save($voiceCall); | ||
95 | + $this->commit(); | ||
96 | + } catch (\Exception $exception) { | ||
97 | + $this->rollback(); | ||
98 | + $this->sentryClient->captureException($exception); | ||
99 | + throw new BadRequestHttpException("Failed to apply patch changes for VoiceCall", $exception); | ||
100 | + } | ||
101 | + } | ||
102 | + | ||
103 | + /** | ||
104 | + * @return VoiceCall | ||
105 | + * @throws \Exception | ||
106 | + */ | ||
107 | + public function create() | ||
108 | + { | ||
109 | + $voiceCall = new VoiceCall(); | ||
110 | + $voiceCall->setCreatedAt(new \DateTime()); | ||
111 | + | ||
112 | + return $voiceCall; | ||
113 | + } | ||
114 | +} |
Symfony3/NexmoBundle/NexmoBundle.php
0 → 100644
1 | +nexmo_inbound_webhooks: | ||
2 | + type: annotation | ||
3 | + resource: NexmoBundle\Controller\InboundCallWebHookController | ||
4 | + prefix: "web-hooks/inbound-calls" | ||
5 | + name_prefix: nexmo.webhooks.inbound_call_ | ||
6 | + | ||
7 | +nexmo_outbound_webhooks: | ||
8 | + type: annotation | ||
9 | + resource: NexmoBundle\Controller\OutboundCallWebHookController | ||
10 | + prefix: "web-hooks/outbound-calls" | ||
11 | + name_prefix: nexmo.webhooks.outbound_call_ |
1 | +imports: | ||
2 | + - { resource: "@NexmoBundle/Resources/config/logger.yml" } | ||
3 | + | ||
4 | +services: | ||
5 | + nexmo.voice_call_manager: | ||
6 | + class: NexmoBundle\VoiceCall\VoiceCallManager | ||
7 | + arguments: ["%nexmo_application_id%", "@cobrand.factory"] | ||
8 | + calls: | ||
9 | + - [setProvisionedNumber, ["%nexmo_provisioned_number%"]] | ||
10 | + - [setRouter, ["@router"]] | ||
11 | + - [initWebhookUrls, ["%base_host%"]] | ||
12 | + | ||
13 | + nexmo.thread_content_voice_call_subscriber: | ||
14 | + class: NexmoBundle\Event\Subscriber\ThreadContentVoiceCallEventSubscriber | ||
15 | + calls: | ||
16 | + - [setDoctrine, ["@doctrine"]] | ||
17 | + - [setOpentokSignaling, ["@app.opentok_signaling"]] | ||
18 | + tags: | ||
19 | + - { name: kernel.event_subscriber } |
1 | +<?php | ||
2 | + | ||
3 | + | ||
4 | +namespace NexmoBundle\WebHook; | ||
5 | + | ||
6 | +use AppBundle\Service\ConsoleInvoker; | ||
7 | +use CobrandBundle\CobrandInstance; | ||
8 | +use CobrandBundle\Service\CobrandFactory; | ||
9 | +use JMS\DiExtraBundle\Annotation\Inject; | ||
10 | +use JMS\DiExtraBundle\Annotation\InjectParams; | ||
11 | +use JMS\DiExtraBundle\Annotation\Service; | ||
12 | +use Monolog\Logger; | ||
13 | +use NexmoBundle\Manager\NexmoCallManager; | ||
14 | +use Sentry\SentryBundle\SentrySymfonyClient; | ||
15 | +use Symfony\Component\HttpFoundation\JsonResponse; | ||
16 | +use Symfony\Component\HttpFoundation\Request; | ||
17 | +use Symfony\Component\HttpFoundation\Response; | ||
18 | +use Symfony\Component\HttpKernel\Exception\BadRequestHttpException; | ||
19 | + | ||
20 | +/** | ||
21 | + * Class NexmoWebHookHandler | ||
22 | + * @Service("nexmo.webhooks.base", abstract=true, autowire=true, public=true) | ||
23 | + */ | ||
24 | +abstract class NexmoWebHookHandler | ||
25 | +{ | ||
26 | + /** | ||
27 | + * @var Request | ||
28 | + */ | ||
29 | + protected $request; | ||
30 | + | ||
31 | + protected $logger; | ||
32 | + | ||
33 | + protected $responseData = []; | ||
34 | + | ||
35 | + /** | ||
36 | + * @var SentrySymfonyClient | ||
37 | + */ | ||
38 | + protected $sentry; | ||
39 | + | ||
40 | + /** | ||
41 | + * @var CobrandInstance | ||
42 | + */ | ||
43 | + protected $currentCobrand; | ||
44 | + | ||
45 | + /** | ||
46 | + * @var ConsoleInvoker | ||
47 | + */ | ||
48 | + protected $consoleInvoker; | ||
49 | + | ||
50 | + /** | ||
51 | + * @param Logger $logger | ||
52 | + * @InjectParams({ | ||
53 | + * "logger" = @Inject("logger", required=false) | ||
54 | + * }) | ||
55 | + */ | ||
56 | + public function injectLogger(Logger $logger) | ||
57 | + { | ||
58 | + $this->logger = $logger; | ||
59 | + } | ||
60 | + | ||
61 | + /** | ||
62 | + * @param CobrandFactory $factory | ||
63 | + * @InjectParams({ | ||
64 | + * "factory" = @Inject("cobrand.factory", required=false) | ||
65 | + * }) | ||
66 | + */ | ||
67 | + public function injectCurrentCobrand(CobrandFactory $factory) | ||
68 | + { | ||
69 | + $this->currentCobrand = $factory->current(); | ||
70 | + } | ||
71 | + | ||
72 | + /** | ||
73 | + * @param SentrySymfonyClient $client | ||
74 | + * @InjectParams({ | ||
75 | + * "client" = @Inject("sentry.client", required=false) | ||
76 | + * }) | ||
77 | + */ | ||
78 | + public function injectSentryClient(SentrySymfonyClient $client) | ||
79 | + { | ||
80 | + $this->sentry = $client; | ||
81 | + } | ||
82 | + | ||
83 | + /** | ||
84 | + * @param ConsoleInvoker $invoker | ||
85 | + * @InjectParams({ | ||
86 | + * "invoker" = @Inject("spoke.console_invoker", required=false) | ||
87 | + * }) | ||
88 | + */ | ||
89 | + public function injectConsoleInvoker(ConsoleInvoker $invoker) | ||
90 | + { | ||
91 | + $this->consoleInvoker = $invoker; | ||
92 | + } | ||
93 | + | ||
94 | + /** | ||
95 | + * @param Request $request | ||
96 | + * @return JsonResponse | ||
97 | + */ | ||
98 | + final public function handle(Request $request) | ||
99 | + { | ||
100 | + $this->responseData = []; | ||
101 | + $this->request = $request; | ||
102 | + | ||
103 | + try { | ||
104 | + $this->processRequest(); | ||
105 | + | ||
106 | + return new JsonResponse($this->responseData, Response::HTTP_OK); | ||
107 | + } catch (\Exception $exception) { | ||
108 | + // Important: Nexmo webhooks needs 200 response code, even for error. | ||
109 | + $this->sentry->captureException($exception); | ||
110 | + return new JsonResponse(["error" => $exception->getMessage()], Response::HTTP_OK); | ||
111 | + } | ||
112 | + } | ||
113 | + | ||
114 | + protected function getRequestParam($key, $required = true) | ||
115 | + { | ||
116 | + $val = $this->request->get($key, null); | ||
117 | + if ($required && is_null($val)) { | ||
118 | + throw new BadRequestHttpException(sprintf('Missing required parameter for nexmo webhook: %s', $key)); | ||
119 | + } | ||
120 | + | ||
121 | + return $val; | ||
122 | + } | ||
123 | + | ||
124 | + protected function logInfo($string) | ||
125 | + { | ||
126 | + $this->logger->info("[NEXMO_WEBHOOK]: {$string}"); | ||
127 | + } | ||
128 | + | ||
129 | + abstract protected function processRequest(); | ||
130 | +} |
1 | +<?php | ||
2 | + | ||
3 | + | ||
4 | +namespace NexmoBundle\WebHook; | ||
5 | + | ||
6 | +use AppBundle\Entity\AdminUser; | ||
7 | +use AppBundle\Entity\OpentokSessionTypes; | ||
8 | +use AppBundle\Entity\Patient; | ||
9 | +use AppBundle\Entity\VoiceCall; | ||
10 | +use AppBundle\Repository\PatientRepository; | ||
11 | +use AppBundle\Service\Manager\AdminUserManager; | ||
12 | +use AppBundle\Service\Manager\PatientManager; | ||
13 | +use JMS\DiExtraBundle\Annotation\Inject; | ||
14 | +use JMS\DiExtraBundle\Annotation\InjectParams; | ||
15 | +use JMS\DiExtraBundle\Annotation\Service; | ||
16 | +use NexmoBundle\Manager\InboundCallManager; | ||
17 | +use NexmoBundle\VoiceCall\VoiceCallStatus; | ||
18 | +use OpenTokBundle\Pool\GlobalAdminOpenTokPool; | ||
19 | +use OpenTokBundle\Pool\OpenTokPoolFactory; | ||
20 | + | ||
21 | +/** | ||
22 | + * Class OnAnswerInboundWebHook | ||
23 | + * @Service("nexmo.webhooks.answer_inbound", autowire=true, public=true) | ||
24 | + */ | ||
25 | +class OnAnswerInboundWebHook extends NexmoWebHookHandler | ||
26 | +{ | ||
27 | + /** | ||
28 | + * @var InboundCallManager | ||
29 | + */ | ||
30 | + private $callManager; | ||
31 | + | ||
32 | + /** | ||
33 | + * @var AdminUserManager | ||
34 | + */ | ||
35 | + private $adminUserManager; | ||
36 | + | ||
37 | + /** | ||
38 | + * @var PatientManager | ||
39 | + */ | ||
40 | + private $patientManager; | ||
41 | + | ||
42 | + /** | ||
43 | + * @var GlobalAdminOpenTokPool | ||
44 | + */ | ||
45 | + private $opentokPool; | ||
46 | + | ||
47 | + /** | ||
48 | + * @param InboundCallManager $manager | ||
49 | + * @InjectParams({ | ||
50 | + * "manager" = @Inject("nexmo.manager.inbound_call", required=false) | ||
51 | + * }) | ||
52 | + */ | ||
53 | + public function setInboundVoiceCallManager(InboundCallManager $manager) | ||
54 | + { | ||
55 | + $this->callManager = $manager; | ||
56 | + } | ||
57 | + | ||
58 | + /** | ||
59 | + * @param AdminUserManager $manager | ||
60 | + * @InjectParams({ | ||
61 | + * "manager" = @Inject("spoke.manager.admin_user", required=false) | ||
62 | + * }) | ||
63 | + */ | ||
64 | + public function injectUserManager(AdminUserManager $manager) | ||
65 | + { | ||
66 | + $this->adminUserManager = $manager; | ||
67 | + } | ||
68 | + | ||
69 | + /** | ||
70 | + * @param PatientManager $manager | ||
71 | + * @InjectParams({ | ||
72 | + * "manager" = @Inject("spoke.manager.patient", required=false) | ||
73 | + * }) | ||
74 | + */ | ||
75 | + public function injectPatientManager(PatientManager $manager) | ||
76 | + { | ||
77 | + $this->patientManager = $manager; | ||
78 | + } | ||
79 | + | ||
80 | + /** | ||
81 | + * @param OpenTokPoolFactory $factory | ||
82 | + * @throws \Exception | ||
83 | + * @InjectParams({ | ||
84 | + * "factory" = @Inject("opentok_pool_factory", required=false) | ||
85 | + * }) | ||
86 | + */ | ||
87 | + public function injectAdminPool(OpenTokPoolFactory $factory) | ||
88 | + { | ||
89 | + $this->opentokPool = $factory->get(OpentokSessionTypes::ADMIN_POOL); | ||
90 | + } | ||
91 | + | ||
92 | + | ||
93 | + /** | ||
94 | + * @return VoiceCall|object|null | ||
95 | + * @throws \Exception | ||
96 | + */ | ||
97 | + private function buildVoiceCallFromRequest() | ||
98 | + { | ||
99 | + $uuid = $this->getRequestParam('uuid'); | ||
100 | + $fromNumber = $this->getRequestParam('from'); | ||
101 | + $conversationId = $this->getRequestParam('conversation_uuid'); | ||
102 | + | ||
103 | + $voiceCall = $this->callManager->findOneByUUID($uuid); | ||
104 | + // no existing voice call with uuid, create one | ||
105 | + if (!$voiceCall) { | ||
106 | + $voiceCall = $this->callManager->create(); | ||
107 | + } | ||
108 | + | ||
109 | + $voiceCall->setUpdatedAt(new \DateTime()); | ||
110 | + $voiceCall->setConversationUuid($conversationId); | ||
111 | + $voiceCall->setFromNumber($fromNumber); | ||
112 | + $voiceCall->setUuid($uuid); | ||
113 | + | ||
114 | + return $voiceCall; | ||
115 | + } | ||
116 | + | ||
117 | + protected function processRequest() | ||
118 | + { | ||
119 | + $voiceCall = $this->buildVoiceCallFromRequest(); | ||
120 | + $this->logInfo("Inbound call answer url. From number: {$voiceCall->getFromNumber()}"); | ||
121 | + | ||
122 | + // find mapped member based on the contact number | ||
123 | + // we can only map out one patient to this voice call log | ||
124 | + // for multiple patients having same contact number will be manually managed | ||
125 | + $patients = $this->patientManager->findByContactNumber($voiceCall->getFromNumber()); | ||
126 | + /** @var Patient $actor */ | ||
127 | + $actor = 1 == count($patients) ? $patients[0] : null; | ||
128 | + if ($actor) { | ||
129 | + $voiceCall->setActor($actor); | ||
130 | + } | ||
131 | + | ||
132 | + // as per Jason discussion, we will always forward the call to the default number FOR march 15 | ||
133 | + $proxyNumber = $this->currentCobrand->getNexmo()->defaultNumber; | ||
134 | + | ||
135 | + // find AdminUser who owns this proxyNumber, can only handle one recipient | ||
136 | + $adminUsers = $this->adminUserManager->findByContactNumber($proxyNumber); | ||
137 | + /** @var AdminUser $adminRecipient */ | ||
138 | + $adminRecipient = 1 == count($adminUsers) ? $adminUsers[0] : null; | ||
139 | + if ($adminRecipient) { | ||
140 | + $voiceCall->setRecipient($adminRecipient); | ||
141 | + } | ||
142 | + | ||
143 | + $voiceCall->setToNumber($proxyNumber); | ||
144 | + | ||
145 | + try { | ||
146 | + $this->callManager->beginTransaction(); | ||
147 | + $this->callManager->save($voiceCall); | ||
148 | + $this->callManager->commit(); | ||
149 | + | ||
150 | + // build out response data for connecting to proxy number | ||
151 | + $this->responseData = [ | ||
152 | + [ | ||
153 | + "action" => "connect", | ||
154 | + // redirect to advocate or company default number | ||
155 | + "endpoint" => [[ | ||
156 | + "type" => "phone", | ||
157 | + "number" => $proxyNumber | ||
158 | + ]], | ||
159 | + "timeout" => 45 | ||
160 | + ] | ||
161 | + ]; | ||
162 | + } catch (\Exception $exception) { | ||
163 | + $appName = $this->currentCobrand ? $this->currentCobrand->getApp()->getName() : null; | ||
164 | + $this->callManager->rollback(); | ||
165 | + $this->sentry->captureException($exception); | ||
166 | + | ||
167 | + // failure | ||
168 | + $this->responseData = [[ | ||
169 | + "action" => "talk", | ||
170 | + "text" => "Thank you for calling ${appName}, we are currently experiencing technical problems." | ||
171 | + ]]; | ||
172 | + } | ||
173 | + } | ||
174 | +} |
1 | +<?php | ||
2 | + | ||
3 | + | ||
4 | +namespace NexmoBundle\WebHook; | ||
5 | + | ||
6 | +use AppBundle\Entity\AdminUser; | ||
7 | +use AppBundle\Entity\OpentokSessionTypes; | ||
8 | +use AppBundle\Event\VoiceCallEvent; | ||
9 | +use AppBundle\Event\VoiceCallEvents; | ||
10 | +use JMS\DiExtraBundle\Annotation\Inject; | ||
11 | +use JMS\DiExtraBundle\Annotation\InjectParams; | ||
12 | +use JMS\DiExtraBundle\Annotation\Service; | ||
13 | +use NexmoBundle\Manager\InboundCallManager; | ||
14 | +use NexmoBundle\VoiceCall\VoiceCallStatus; | ||
15 | +use OpenTokBundle\Pool\GlobalAdminOpenTokPool; | ||
16 | +use OpenTokBundle\Pool\OpenTokPoolFactory; | ||
17 | +use Symfony\Component\EventDispatcher\EventDispatcherInterface; | ||
18 | + | ||
19 | +/** | ||
20 | + * Class OnEventInboundWebHook | ||
21 | + * @Service("nexmo.webhooks.event_inbound", autowire=true, public=true) | ||
22 | + */ | ||
23 | +class OnEventInboundWebHook extends NexmoWebHookHandler | ||
24 | +{ | ||
25 | + /** | ||
26 | + * @var InboundCallManager | ||
27 | + */ | ||
28 | + private $callManager; | ||
29 | + | ||
30 | + /** | ||
31 | + * @var EventDispatcherInterface | ||
32 | + */ | ||
33 | + private $eventDispatcher; | ||
34 | + | ||
35 | + /** | ||
36 | + * @var GlobalAdminOpenTokPool | ||
37 | + */ | ||
38 | + private $opentokPool; | ||
39 | + | ||
40 | + /** | ||
41 | + * @param EventDispatcherInterface $dispatcher | ||
42 | + * @InjectParams({ | ||
43 | + * "dispatcher" = @Inject("event_dispatcher", required=false) | ||
44 | + * }) | ||
45 | + */ | ||
46 | + public function setEventDispatcher(EventDispatcherInterface $dispatcher) | ||
47 | + { | ||
48 | + $this->eventDispatcher = $dispatcher; | ||
49 | + } | ||
50 | + | ||
51 | + /** | ||
52 | + * @param InboundCallManager $manager | ||
53 | + * @InjectParams({ | ||
54 | + * "manager" = @Inject("nexmo.manager.inbound_call", required=false) | ||
55 | + * }) | ||
56 | + */ | ||
57 | + public function setInboundVoiceCallManager(InboundCallManager $manager) | ||
58 | + { | ||
59 | + $this->callManager = $manager; | ||
60 | + } | ||
61 | + | ||
62 | + /** | ||
63 | + * @param OpenTokPoolFactory $factory | ||
64 | + * @throws \Exception | ||
65 | + * @InjectParams({ | ||
66 | + * "factory" = @Inject("opentok_pool_factory", required=false) | ||
67 | + * }) | ||
68 | + */ | ||
69 | + public function injectAdminPool(OpenTokPoolFactory $factory) | ||
70 | + { | ||
71 | + $this->opentokPool = $factory->get(OpentokSessionTypes::ADMIN_POOL); | ||
72 | + } | ||
73 | + | ||
74 | + protected function processRequest() | ||
75 | + { | ||
76 | + $voiceCall = $this->callManager->findOneByUuidOrHalt($this->getRequestParam('uuid')); | ||
77 | + | ||
78 | + $requestMap = [ | ||
79 | + "status" => function ($val) use ($voiceCall, &$delegates) { | ||
80 | + $voiceCall->setStatus($val); | ||
81 | + }, | ||
82 | + "timestamp" => function ($val) use ($voiceCall) { | ||
83 | + $dt = new \DateTime($val); | ||
84 | + $voiceCall->setUpdatedAt($dt); | ||
85 | + }, | ||
86 | + "rate" => function ($val) use ($voiceCall) { | ||
87 | + $voiceCall->setRate($val); | ||
88 | + }, | ||
89 | + "price" => function ($val) use ($voiceCall) { | ||
90 | + $voiceCall->setPrice($val); | ||
91 | + }, | ||
92 | + "duration" => function ($val) use ($voiceCall) { | ||
93 | + $voiceCall->setDuration($val); | ||
94 | + }, | ||
95 | + ]; | ||
96 | + | ||
97 | + // call event mapper | ||
98 | + foreach ($this->request->request->all() as $key => $val) { | ||
99 | + if (array_key_exists($key, $requestMap)) { | ||
100 | + $requestMap[$key]($val); | ||
101 | + } | ||
102 | + } | ||
103 | + | ||
104 | + try { | ||
105 | + $this->callManager->beginTransaction(); | ||
106 | + $this->callManager->initThreadContent($voiceCall); | ||
107 | + $this->callManager->save($voiceCall); | ||
108 | + $this->callManager->commit(); | ||
109 | + | ||
110 | + if ($voiceCall->getRecipient() instanceof AdminUser) { | ||
111 | + $this->opentokPool->sendSignal("inbound-call", $this->callManager->asOpenTokSignalData($voiceCall)); | ||
112 | + | ||
113 | + // trigger log only on completed call | ||
114 | + if (VoiceCallStatus::COMPLETED === $voiceCall->getStatus()) { | ||
115 | + $this->eventDispatcher->dispatch(VoiceCallEvents::INBOUND_CALL_COMPLETED, new VoiceCallEvent($voiceCall)); | ||
116 | + } | ||
117 | + } | ||
118 | + | ||
119 | + // no needed data for response | ||
120 | + $this->responseData = [ | ||
121 | + 'success' => true | ||
122 | + ]; | ||
123 | + } catch (\Exception $exception) { | ||
124 | + $this->sentry->captureException($exception); | ||
125 | + | ||
126 | + // no needed data for response | ||
127 | + $this->responseData = [ | ||
128 | + 'success' => false | ||
129 | + ]; | ||
130 | + } | ||
131 | + } | ||
132 | +} |
android/BoothActivity.java
0 → 100644
1 | +package com.styleteq.app; | ||
2 | + | ||
3 | +import android.content.DialogInterface; | ||
4 | +import android.content.Intent; | ||
5 | +import android.os.Bundle; | ||
6 | +import android.view.Menu; | ||
7 | +import android.view.MenuItem; | ||
8 | +import android.view.View; | ||
9 | +import android.widget.TextView; | ||
10 | +import android.widget.Toast; | ||
11 | + | ||
12 | +import com.styleteq.app.adapters.SwipeScreenAdapter; | ||
13 | +import com.styleteq.app.api.STCallback; | ||
14 | +import com.styleteq.app.api.STRequest; | ||
15 | +import com.styleteq.app.api.ServiceGenerator; | ||
16 | +import com.styleteq.app.errors.ErrorBag; | ||
17 | +import com.styleteq.app.errors.ErrorsSerializer; | ||
18 | +import com.styleteq.app.helpers.Logger; | ||
19 | +import com.styleteq.app.helpers.STActivity; | ||
20 | +import com.styleteq.app.helpers.TemporaryValues; | ||
21 | +import com.styleteq.app.helpers.Utils; | ||
22 | +import com.styleteq.app.models.Attachment; | ||
23 | +import com.styleteq.app.models.Booth; | ||
24 | +import com.styleteq.app.models.Session; | ||
25 | +import com.styleteq.app.services.BoothService; | ||
26 | +import com.styleteq.app.ui.ConfirmDeleteDialog; | ||
27 | +import com.styleteq.app.ui.DynamicHeightViewPager; | ||
28 | +import com.styleteq.app.ui.LoadingScreen; | ||
29 | +import com.viewpagerindicator.CirclePageIndicator; | ||
30 | + | ||
31 | +import java.util.ArrayList; | ||
32 | +import java.util.List; | ||
33 | + | ||
34 | +import butterknife.BindView; | ||
35 | +import butterknife.ButterKnife; | ||
36 | +import retrofit2.Call; | ||
37 | +import retrofit2.Response; | ||
38 | + | ||
39 | +public class BoothActivity extends STActivity { | ||
40 | + @BindView(R.id.booth) | ||
41 | + public TextView boothName; | ||
42 | + @BindView(R.id.name) | ||
43 | + public TextView name; | ||
44 | + @BindView(R.id.address) | ||
45 | + public TextView address; | ||
46 | + @BindView(R.id.view_pager) | ||
47 | + public DynamicHeightViewPager pager; | ||
48 | + @BindView(R.id.indicator) | ||
49 | + public CirclePageIndicator indicator; | ||
50 | + @BindView(R.id.price) | ||
51 | + public TextView price; | ||
52 | + @BindView(R.id.method) | ||
53 | + public TextView method; | ||
54 | + @BindView(R.id.businessType) | ||
55 | + public TextView businessType; | ||
56 | + @BindView(R.id.boothContainer) | ||
57 | + public View boothContainer; | ||
58 | + | ||
59 | + private ConfirmDeleteDialog deleteDialog; | ||
60 | + private Booth booth; | ||
61 | + private LoadingScreen loadingScreen; | ||
62 | + private Menu menu; | ||
63 | + | ||
64 | + @Override | ||
65 | + protected void onCreate(Bundle savedInstanceState) { | ||
66 | + super.onCreate(savedInstanceState); | ||
67 | + Intent i = getIntent(); | ||
68 | + | ||
69 | + setContentView(R.layout.activity_booth); | ||
70 | + ButterKnife.bind(this); | ||
71 | + | ||
72 | + if (savedInstanceState != null) { | ||
73 | + String s = savedInstanceState.getString("booth"); | ||
74 | + if (s != null) | ||
75 | + booth = Booth.createFromJsonString(s); | ||
76 | + } | ||
77 | + if (booth == null) { | ||
78 | + if (i.hasExtra("booth")) { | ||
79 | + booth = Booth.createFromJsonString(i.getStringExtra("booth")); | ||
80 | + } else if (i.hasExtra("booth_id")) { | ||
81 | + BoothService s = ServiceGenerator.createService(BoothService.class, "booth"); | ||
82 | + new STRequest<>(s.get(i.getLongExtra("booth_id", 0)), new STCallback<Booth>() { | ||
83 | + @Override | ||
84 | + public void onCustomResponse(Call<Booth> call, Response<Booth> response) { | ||
85 | + if (response.isSuccessful()) { | ||
86 | + booth = response.body(); | ||
87 | + populate(); | ||
88 | + } else | ||
89 | + showResponseError(response); | ||
90 | + } | ||
91 | + | ||
92 | + @Override | ||
93 | + public void onFailure(Call<Booth> call, Throwable t) { | ||
94 | + Toast.makeText(BoothActivity.this, t.getMessage(), Toast.LENGTH_SHORT).show(); | ||
95 | + } | ||
96 | + }).execute(); | ||
97 | + } else | ||
98 | + Logger.e("Booth not found"); | ||
99 | + } | ||
100 | + init(); | ||
101 | + addListeners(); | ||
102 | + if (booth != null) { | ||
103 | + populate(); | ||
104 | + } | ||
105 | + } | ||
106 | + | ||
107 | + private void init() { | ||
108 | + setToolbar(R.id.tool_bar); | ||
109 | + loadingScreen = new LoadingScreen(); | ||
110 | + loadingScreen.show(true); | ||
111 | + } | ||
112 | + | ||
113 | + private void populate() { | ||
114 | + boothName.setText(String.format("%s %s for Rent", booth.slots, getResources().getQuantityString(R.plurals.booths, booth.slots))); | ||
115 | + name.setText(booth.salon.name); | ||
116 | + address.setText(booth.salon.address()); | ||
117 | + | ||
118 | + List<Attachment> images = booth.images.isEmpty() ? booth.salon.images : booth.images; | ||
119 | + if (images.isEmpty()) { | ||
120 | + images = new ArrayList<>(); | ||
121 | + images.add(new Attachment()); | ||
122 | + } | ||
123 | + | ||
124 | + SwipeScreenAdapter a = new SwipeScreenAdapter(this, images, pager); | ||
125 | + pager.setAdapter(a); | ||
126 | + | ||
127 | + indicator.setViewPager(pager); | ||
128 | + price.setText(String.format("%s %s", Utils.formatDollar(booth.price.doubleValue()), booth.terms)); | ||
129 | + method.setText(booth.payMethod()); | ||
130 | + businessType.setText(booth.salon.businessType); | ||
131 | + | ||
132 | + deleteDialog = new ConfirmDeleteDialog(this, "Are you sure you want to delete this Booth?", | ||
133 | + new DialogInterface.OnClickListener() { | ||
134 | + public void onClick(DialogInterface dialog, int whichButton) { | ||
135 | + showProgressDialogWithAction("Deleting booth"); | ||
136 | + BoothService s = ServiceGenerator.createService(BoothService.class); | ||
137 | + new STRequest<>(s.delete(booth.id), new STCallback<Booth>() { | ||
138 | + @Override | ||
139 | + public void onCustomResponse(Call<Booth> call, Response<Booth> response) { | ||
140 | + hideProgressDialog(); | ||
141 | + if (response.isSuccessful()) { | ||
142 | + if (!getIntent().hasExtra(Utils.BACK)) { | ||
143 | + TemporaryValues.setBooth(booth); | ||
144 | + TemporaryValues.setMode(TemporaryValues.DELETE_MODE); | ||
145 | + finish(); | ||
146 | + } else | ||
147 | + startActivity(new Intent(BoothActivity.this, BoothListActivity.class).putExtra("salon", booth.salon.toJsonString()).addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP)); | ||
148 | + Toast.makeText(BoothActivity.this, "Successfully deleted booth!", Toast.LENGTH_LONG).show(); | ||
149 | + } else { | ||
150 | + ErrorBag e = ErrorsSerializer.parseResourceErrors(response); | ||
151 | + if (e != null) | ||
152 | + Toast.makeText(BoothActivity.this, e.concatenateErrors(), Toast.LENGTH_LONG).show(); | ||
153 | + } | ||
154 | + } | ||
155 | + | ||
156 | + @Override | ||
157 | + public void onFailure(Call<Booth> call, Throwable t) { | ||
158 | + hideProgressDialog(); | ||
159 | + Toast.makeText(BoothActivity.this, t.getMessage(), Toast.LENGTH_SHORT).show(); | ||
160 | + } | ||
161 | + }).execute(); | ||
162 | + } | ||
163 | + }); | ||
164 | + | ||
165 | + setupMenu(); | ||
166 | + loadingScreen.show(false); | ||
167 | + } | ||
168 | + | ||
169 | + private void addListeners() { | ||
170 | + boothContainer.setOnClickListener(new View.OnClickListener() { | ||
171 | + @Override | ||
172 | + public void onClick(View v) { | ||
173 | + startActivity(new Intent(BoothActivity.this, SalonActivity.class) | ||
174 | + .putExtra("salon", booth.salon.toJsonString()).putExtra(Utils.BACK, Utils.BACK)); | ||
175 | + } | ||
176 | + }); | ||
177 | + } | ||
178 | + | ||
179 | + @Override | ||
180 | + public boolean onCreateOptionsMenu(Menu menu) { | ||
181 | + this.menu = menu; | ||
182 | + if (booth != null) | ||
183 | + setupMenu(); | ||
184 | + return true; | ||
185 | + } | ||
186 | + | ||
187 | + private void setupMenu() { | ||
188 | + if (menu == null) | ||
189 | + return; | ||
190 | + if (Session.isCurrentUser(booth.salon.userId)) { | ||
191 | + getMenuInflater().inflate(R.menu.menu_salon, menu); | ||
192 | + menu.findItem(R.id.edit).setTitle("Edit Booth"); | ||
193 | + menu.findItem(R.id.manage_photos).setTitle("Manage Images"); | ||
194 | + } else | ||
195 | + getMenuInflater().inflate(R.menu.menu_report, menu); | ||
196 | + } | ||
197 | + | ||
198 | + @Override | ||
199 | + public boolean onOptionsItemSelected(MenuItem item) { | ||
200 | + if (!Session.get().exists()) { | ||
201 | + STApplication.promptLogin(); | ||
202 | + return true; | ||
203 | + } | ||
204 | + switch (item.getItemId()) { | ||
205 | + case R.id.edit: | ||
206 | + startActivity(new Intent(this, BoothFormActivity.class).putExtra("booth", booth.toJsonString())); | ||
207 | + return true; | ||
208 | + case R.id.manage_photos: | ||
209 | + startActivity(new Intent(this, UploadBoothPhotosActivity.class).putExtra("booth", booth.toJsonString()).putExtra(Utils.BACK, Utils.BACK)); | ||
210 | + return true; | ||
211 | + case R.id.delete: | ||
212 | + deleteDialog.show(); | ||
213 | + return true; | ||
214 | + case R.id.report: | ||
215 | + startActivity(new Intent(this, ReportActivity.class).putExtra("booth", booth.toJsonString())); | ||
216 | + return true; | ||
217 | + default: | ||
218 | + return super.onOptionsItemSelected(item); | ||
219 | + } | ||
220 | + } | ||
221 | + | ||
222 | + @Override | ||
223 | + public void onBackPressed() { | ||
224 | + if (!loadingScreen.isLoading()) { | ||
225 | + TemporaryValues.setBooth(booth); | ||
226 | + super.onBackPressed(); | ||
227 | + } | ||
228 | + } | ||
229 | + | ||
230 | + @Override | ||
231 | + public void onResume() { | ||
232 | + super.onResume(); | ||
233 | + Booth tempBooth = TemporaryValues.useBooth(); | ||
234 | + if (tempBooth != null) { | ||
235 | + booth = tempBooth; | ||
236 | + init(); | ||
237 | + populate(); | ||
238 | + } | ||
239 | + } | ||
240 | + | ||
241 | + @Override | ||
242 | + protected void onSaveInstanceState(Bundle outState) { | ||
243 | + super.onSaveInstanceState(outState); | ||
244 | + if (booth != null) | ||
245 | + outState.putString("booth", booth.toJsonString()); | ||
246 | + } | ||
247 | +} |
android/MyScheduleFragment.java
0 → 100644
1 | +package com.styleteq.app.fragments; | ||
2 | + | ||
3 | +import android.app.Activity; | ||
4 | +import android.os.Bundle; | ||
5 | + | ||
6 | +import androidx.annotation.Nullable; | ||
7 | +import androidx.fragment.app.Fragment; | ||
8 | +import androidx.recyclerview.widget.LinearLayoutManager; | ||
9 | +import androidx.recyclerview.widget.RecyclerView; | ||
10 | + | ||
11 | +import android.view.LayoutInflater; | ||
12 | +import android.view.View; | ||
13 | +import android.view.ViewGroup; | ||
14 | +import android.view.animation.AnimationUtils; | ||
15 | +import android.widget.CompoundButton; | ||
16 | +import android.widget.RelativeLayout; | ||
17 | +import android.widget.Switch; | ||
18 | +import android.widget.Toast; | ||
19 | + | ||
20 | +import com.styleteq.app.R; | ||
21 | +import com.styleteq.app.adapters.SchedulesAdapter; | ||
22 | +import com.styleteq.app.api.STCallback; | ||
23 | +import com.styleteq.app.api.STRequest; | ||
24 | +import com.styleteq.app.api.ServiceGenerator; | ||
25 | +import com.styleteq.app.errors.ErrorBag; | ||
26 | +import com.styleteq.app.errors.ErrorsSerializer; | ||
27 | +import com.styleteq.app.models.InfiniteScrollable; | ||
28 | +import com.styleteq.app.models.PaginatedResult; | ||
29 | +import com.styleteq.app.models.Schedule; | ||
30 | +import com.styleteq.app.services.ScheduleService; | ||
31 | +import com.styleteq.app.ui.DividerItemDecorator; | ||
32 | +import com.styleteq.app.ui.InfiniteScrollView; | ||
33 | +import com.styleteq.app.ui.LoadingScreen; | ||
34 | +import com.styleteq.app.ui.NoEntityPlaceholder; | ||
35 | + | ||
36 | +import java.util.Collections; | ||
37 | +import java.util.List; | ||
38 | +import java.util.Objects; | ||
39 | + | ||
40 | +import butterknife.ButterKnife; | ||
41 | +import retrofit2.Call; | ||
42 | +import retrofit2.Response; | ||
43 | + | ||
44 | +/** | ||
45 | + * A simple {@link Fragment} subclass. | ||
46 | + * create an instance of this fragment. | ||
47 | + */ | ||
48 | +public abstract class MyScheduleFragment extends Fragment implements InfiniteScrollable { | ||
49 | + | ||
50 | + ScheduleService scheduleService; | ||
51 | + boolean qWithPast; | ||
52 | + | ||
53 | + protected ViewGroup container; | ||
54 | + protected Runnable runnable; | ||
55 | + protected PaginatedResult currentResult; | ||
56 | + | ||
57 | + private RelativeLayout fragmentWrapper; | ||
58 | + private NoEntityPlaceholder placeholder; | ||
59 | + private LoadingScreen loadingScreen; | ||
60 | + private boolean loading; | ||
61 | + private boolean resetList; | ||
62 | + | ||
63 | + public MyScheduleFragment() { | ||
64 | + // Required empty public constructor | ||
65 | + } | ||
66 | + | ||
67 | + @Override | ||
68 | + public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) { | ||
69 | + // Inflate the layout for this fragment | ||
70 | + this.container = container; | ||
71 | + return inflater.inflate(R.layout.fragment_my_schedule_list, container, false); | ||
72 | + } | ||
73 | + | ||
74 | + @Override | ||
75 | + public void onActivityCreated(@Nullable Bundle savedInstanceState) { | ||
76 | + super.onActivityCreated(savedInstanceState); | ||
77 | + ButterKnife.bind(Objects.requireNonNull(getFragmentActivity())); | ||
78 | + preInit(); | ||
79 | + init(); | ||
80 | + scheduleService = ServiceGenerator.createService(ScheduleService.class); | ||
81 | + addListeners(); | ||
82 | + qWithPast = defaultSwitchState(); | ||
83 | + } | ||
84 | + | ||
85 | + @Override | ||
86 | + public void onResume() { | ||
87 | + super.onResume(); | ||
88 | + this.loadingScreen = new LoadingScreen(container); | ||
89 | + placeholder.setTarget(fragmentWrapper); | ||
90 | + load(0); | ||
91 | + } | ||
92 | + | ||
93 | + @Override | ||
94 | + public void onStop() { | ||
95 | + super.onStop(); | ||
96 | + getSchedulesAdapter().clearAll(); | ||
97 | + } | ||
98 | + | ||
99 | + @Override | ||
100 | + public void onViewStateRestored(@Nullable Bundle savedInstanceState) { | ||
101 | + super.onViewStateRestored(savedInstanceState); | ||
102 | + resetList = true; | ||
103 | + } | ||
104 | + | ||
105 | + private void init() { | ||
106 | + View v = getMainView(); | ||
107 | + RecyclerView recyclerView = v.findViewById(R.id.schedule_list_recycler); | ||
108 | + InfiniteScrollView scrollView = v.findViewById(R.id.inf_scroll_view); | ||
109 | + fragmentWrapper = v.findViewById(R.id.schedule_list); | ||
110 | + placeholder = v.findViewById(R.id.placeholder); | ||
111 | + | ||
112 | + scrollView.setScrollable(this); | ||
113 | + | ||
114 | + placeholder.set(Objects.requireNonNull(getActivity()), R.string.schedule_no_title, R.string.schedule_no_sub); | ||
115 | + | ||
116 | + recyclerView.setLayoutManager(new LinearLayoutManager(getActivity())); | ||
117 | + recyclerView.setAnimation(AnimationUtils.loadAnimation(getActivity(), android.R.anim.fade_in)); | ||
118 | + recyclerView.setAdapter(getSchedulesAdapter()); | ||
119 | + recyclerView.setNestedScrollingEnabled(false); | ||
120 | + recyclerView.addItemDecoration(new DividerItemDecorator(Objects.requireNonNull(getActivity()), DividerItemDecorator.VERTICAL_LIST)); | ||
121 | + } | ||
122 | + | ||
123 | + protected void addListeners() { | ||
124 | + if (null != getSwitch()) { | ||
125 | + getSwitch().setChecked(defaultSwitchState()); | ||
126 | + getSwitch().setOnCheckedChangeListener(new CompoundButton.OnCheckedChangeListener() { | ||
127 | + public void onCheckedChanged(CompoundButton buttonView, boolean isChecked) { | ||
128 | + qWithPast = isChecked; | ||
129 | + resetList = true; | ||
130 | + load(0); | ||
131 | + } | ||
132 | + }); | ||
133 | + } | ||
134 | + } | ||
135 | + | ||
136 | + protected void load(int page) { | ||
137 | + this.loadingScreen.show(true); | ||
138 | + this.loading = true; | ||
139 | + new STRequest<>(getQuery(page), new STCallback<PaginatedResult>() { | ||
140 | + @Override | ||
141 | + public void onCustomResponse(Call<PaginatedResult> call, Response<PaginatedResult> response) { | ||
142 | + if (response.isSuccessful()) { | ||
143 | + currentResult = response.body(); | ||
144 | + if (null != currentResult && !currentResult.schedules.isEmpty()) { | ||
145 | + getResults(currentResult.schedules); | ||
146 | + } else { | ||
147 | + getResults(Collections.emptyList()); | ||
148 | + } | ||
149 | + } else { | ||
150 | + ErrorBag b = ErrorsSerializer.parseResourceErrors(response); | ||
151 | + if (b != null) | ||
152 | + Toast.makeText(getActivity(), b.concatenateErrors(), Toast.LENGTH_SHORT).show(); | ||
153 | + } | ||
154 | + checkList(); | ||
155 | + endLoading(); | ||
156 | + } | ||
157 | + | ||
158 | + @Override | ||
159 | + public void onFailure(Call<PaginatedResult> call, Throwable t) { | ||
160 | + endLoading(); | ||
161 | + placeholder.showError(t.getMessage()); | ||
162 | + } | ||
163 | + }).execute(); | ||
164 | + } | ||
165 | + | ||
166 | + private void checkList() { | ||
167 | + if (getSchedulesAdapter().getItemCount() > 0) { | ||
168 | + placeholder.show(false); | ||
169 | + } else { | ||
170 | + getSchedulesAdapter().notifyDataSetChanged(); | ||
171 | + placeholder.show(true); | ||
172 | + } | ||
173 | + } | ||
174 | + | ||
175 | + private void getResults(List<Schedule> schedules) { | ||
176 | + if (resetList) { | ||
177 | + getSchedulesAdapter().clearAll(); | ||
178 | + resetList = false; | ||
179 | + } | ||
180 | + getSchedulesAdapter().addAll(schedules); | ||
181 | + } | ||
182 | + | ||
183 | + private void endLoading() { | ||
184 | + this.loading = false; | ||
185 | + this.loadingScreen.show(false); | ||
186 | + } | ||
187 | + | ||
188 | + @Override | ||
189 | + public boolean canLoad() { | ||
190 | + return !this.loading && (null != currentResult && currentResult.hasNext()); | ||
191 | + } | ||
192 | + | ||
193 | + @Override | ||
194 | + public Runnable loadMoreResults() { | ||
195 | + if (runnable == null) | ||
196 | + runnable = new Runnable() { | ||
197 | + @Override | ||
198 | + public void run() { | ||
199 | + load(currentResult.nextPage()); | ||
200 | + } | ||
201 | + }; | ||
202 | + return runnable; | ||
203 | + } | ||
204 | + | ||
205 | + protected boolean defaultSwitchState() { | ||
206 | + return false; | ||
207 | + } | ||
208 | + | ||
209 | + protected Switch getSwitch() { | ||
210 | + return null; | ||
211 | + } | ||
212 | + | ||
213 | + protected abstract void preInit(); | ||
214 | + | ||
215 | + protected abstract Call<PaginatedResult> getQuery(int page); | ||
216 | + | ||
217 | + protected abstract SchedulesAdapter getSchedulesAdapter(); | ||
218 | + | ||
219 | + protected abstract View getMainView(); | ||
220 | + | ||
221 | + protected abstract Activity getFragmentActivity(); | ||
222 | + | ||
223 | + protected abstract String getSource(); | ||
224 | +} |
android/STApplication.java
0 → 100644
1 | +package com.styleteq.app; | ||
2 | + | ||
3 | +import android.content.ComponentName; | ||
4 | +import android.content.Context; | ||
5 | +import android.content.Intent; | ||
6 | +import androidx.annotation.LayoutRes; | ||
7 | +import androidx.multidex.MultiDexApplication; | ||
8 | +import android.view.LayoutInflater; | ||
9 | +import android.view.View; | ||
10 | + | ||
11 | +import com.crashlytics.android.Crashlytics; | ||
12 | +import com.crashlytics.android.core.CrashlyticsCore; | ||
13 | +import com.facebook.FacebookSdk; | ||
14 | +import com.facebook.appevents.AppEventsLogger; | ||
15 | +import com.styleteq.app.firebase.STFirebase; | ||
16 | +import com.styleteq.app.helpers.STActivity; | ||
17 | +import com.styleteq.app.helpers.Utils; | ||
18 | +import com.styleteq.app.models.Session; | ||
19 | + | ||
20 | +import io.fabric.sdk.android.Fabric; | ||
21 | + | ||
22 | +public class STApplication extends MultiDexApplication { | ||
23 | + private static boolean activityVisible; | ||
24 | + private static STApplication instance; | ||
25 | + private STActivity currentActivity; | ||
26 | + | ||
27 | + public static STApplication getInstance() { | ||
28 | + return instance; | ||
29 | + } | ||
30 | + | ||
31 | + public static Context applicationContext() { | ||
32 | + return instance.getApplicationContext(); | ||
33 | + } | ||
34 | + | ||
35 | + public static STActivity getCurrentActivity() { | ||
36 | + return getInstance().getActivity(); | ||
37 | + } | ||
38 | + | ||
39 | + @Override | ||
40 | + public void onCreate() { | ||
41 | + super.onCreate(); | ||
42 | + instance = this; | ||
43 | + // Set up Crashlytics, disabled for debug builds | ||
44 | + Crashlytics crashlyticsKit = new Crashlytics.Builder() | ||
45 | + .core(new CrashlyticsCore.Builder().disabled(BuildConfig.DEBUG).build()) | ||
46 | + .build(); | ||
47 | + | ||
48 | + // Initialize Fabric with the debug-disabled crashlytics. | ||
49 | + Fabric.with(this, crashlyticsKit); | ||
50 | + STFirebase.subscribe(); | ||
51 | + FacebookSdk.sdkInitialize(getApplicationContext()); | ||
52 | + AppEventsLogger.activateApp(this); | ||
53 | + Session.get().setNotificationId(0); | ||
54 | + } | ||
55 | + | ||
56 | + public void setActivity(STActivity activity) { | ||
57 | + currentActivity = activity; | ||
58 | + } | ||
59 | + | ||
60 | + public STActivity getActivity() { | ||
61 | + return currentActivity; | ||
62 | + } | ||
63 | + | ||
64 | + public static void newActivity(Class klass) { | ||
65 | + getCurrentActivity().startActivity(new Intent(getCurrentActivity(), klass)); | ||
66 | + } | ||
67 | + | ||
68 | + public static void newActivity(Intent i) { | ||
69 | + getCurrentActivity().startActivity(i); | ||
70 | + } | ||
71 | + | ||
72 | + public static void newActivity(Class klass, Intent i) { | ||
73 | + i.setComponent(new ComponentName(getCurrentActivity(), klass)); | ||
74 | + getCurrentActivity().startActivity(i); | ||
75 | + } | ||
76 | + | ||
77 | + public static LayoutInflater getLayoutInflater() { | ||
78 | + return (LayoutInflater) getInstance().getSystemService(Context.LAYOUT_INFLATER_SERVICE); | ||
79 | + } | ||
80 | + | ||
81 | + public static View inflate(@LayoutRes int resource) { | ||
82 | + return getLayoutInflater().inflate(resource, null); | ||
83 | + } | ||
84 | + | ||
85 | + public static boolean isActivityVisible() { | ||
86 | + return activityVisible; | ||
87 | + } | ||
88 | + | ||
89 | + public static void activityResumed() { | ||
90 | + activityVisible = true; | ||
91 | + Session.get().startSession(); | ||
92 | + } | ||
93 | + | ||
94 | + public static void activityPaused() { | ||
95 | + activityVisible = false; | ||
96 | + Session.get().pauseSession(); | ||
97 | + } | ||
98 | + | ||
99 | + public static void promptLogin() { | ||
100 | + newActivity(new Intent(STApplication.getCurrentActivity(), LoginActivity.class).putExtra(Utils.PROMPT, true)); | ||
101 | + } | ||
102 | +} |
android/Style.java
0 → 100644
1 | +package com.styleteq.app.models; | ||
2 | + | ||
3 | +import android.text.TextUtils; | ||
4 | + | ||
5 | +import com.styleteq.app.helpers.ImageHelper; | ||
6 | +import com.styleteq.app.mixpanel.MixPanelModel; | ||
7 | +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; | ||
8 | +import com.fasterxml.jackson.annotation.JsonInclude; | ||
9 | +import com.fasterxml.jackson.annotation.JsonProperty; | ||
10 | + | ||
11 | +import org.json.JSONObject; | ||
12 | + | ||
13 | +import java.util.ArrayList; | ||
14 | +import java.util.List; | ||
15 | + | ||
16 | +@JsonInclude(JsonInclude.Include.NON_NULL) | ||
17 | +@JsonIgnoreProperties(ignoreUnknown = true) | ||
18 | +public class Style extends Model implements Likeable, Mappable, MixPanelModel { | ||
19 | + public int id; | ||
20 | + public String name; | ||
21 | + public List<Attachment> images; | ||
22 | + public String description; | ||
23 | + | ||
24 | + @JsonProperty("user") | ||
25 | + public User owner; | ||
26 | + | ||
27 | + @JsonProperty("tag_list") | ||
28 | + public String[] tagList; | ||
29 | + | ||
30 | + @JsonProperty("like_count") | ||
31 | + public int likeCount; | ||
32 | + | ||
33 | + @JsonProperty("comment_count") | ||
34 | + public int commentCount; | ||
35 | + | ||
36 | + @Override | ||
37 | + public int likeCount() { | ||
38 | + return likeCount; | ||
39 | + } | ||
40 | + | ||
41 | + @JsonProperty("primary_picture_url") | ||
42 | + public String primaryPictureUrl; | ||
43 | + | ||
44 | + @JsonProperty("user_id") | ||
45 | + public long userId; | ||
46 | + | ||
47 | + public float latitude; | ||
48 | + public float longitude; | ||
49 | + | ||
50 | + public String primaryPictureUrl(int style) { | ||
51 | + return ImageHelper.getImageUrl(primaryPictureUrl, style); | ||
52 | + } | ||
53 | + | ||
54 | + @JsonProperty("images_attributes") | ||
55 | + public List<ImageAttributes> imageAttributes; | ||
56 | + | ||
57 | + @Override | ||
58 | + public long id() { | ||
59 | + return id; | ||
60 | + } | ||
61 | + | ||
62 | + public static Style createFromJsonString(String json) { | ||
63 | + return createFromJsonString(json, Style.class); | ||
64 | + } | ||
65 | + | ||
66 | + public boolean is_liked; | ||
67 | + | ||
68 | + @Override | ||
69 | + public boolean isLiked() { | ||
70 | + return is_liked; | ||
71 | + } | ||
72 | + | ||
73 | + @Override | ||
74 | + public void setLiked(boolean liked) { | ||
75 | + this.is_liked = liked; | ||
76 | + } | ||
77 | + | ||
78 | + @Override | ||
79 | + public void setLikeCount(int count) { | ||
80 | + this.likeCount = count; | ||
81 | + } | ||
82 | + | ||
83 | + public String tagHashString() { | ||
84 | + if (tagList == null) | ||
85 | + return null; | ||
86 | + | ||
87 | + List<String> hash = new ArrayList<>(); | ||
88 | + for (String s : tagList) { | ||
89 | + hash.add("#" + s); | ||
90 | + } | ||
91 | + return TextUtils.join(", ", hash); | ||
92 | + } | ||
93 | + | ||
94 | + @Override | ||
95 | + public MapPosition mapPosition() { | ||
96 | + MapPosition p = new MapPosition(); | ||
97 | + p.latitude = latitude; | ||
98 | + p.longitude = longitude; | ||
99 | + p.label = tagHashString(); | ||
100 | + return p; | ||
101 | + } | ||
102 | + | ||
103 | + @Override | ||
104 | + public JSONObject mixpanelObject() { | ||
105 | + return toJson(); | ||
106 | + } | ||
107 | +} |
graphQL/airline.rb
0 → 100644
1 | +module Queries | ||
2 | + class Airline < Base | ||
3 | + def define_fields | ||
4 | + field :airline, Types::Airline do | ||
5 | + description 'Get Airline' | ||
6 | + argument :id, types.Int | ||
7 | + resolve ->(obj, args, ctx) do | ||
8 | + ::Airline.find_by(id: args.id) | ||
9 | + end | ||
10 | + end | ||
11 | + field :airlines, Types::PaginatedCollection do | ||
12 | + description 'Get Airlines' | ||
13 | + argument :q, types.String | ||
14 | + argument :page, types.Int | ||
15 | + argument :per, types.Int | ||
16 | + argument :sort, types.String | ||
17 | + argument :includeInactive, types.Boolean | ||
18 | + resolve ->(obj, args, ctx) do | ||
19 | + Services::Airline::Search.run(args, ctx) | ||
20 | + end | ||
21 | + end | ||
22 | + end | ||
23 | + end | ||
24 | +end |
swift/ActivityFeedNavigationController.swift
0 → 100644
1 | +// | ||
2 | +// ActivityFeedNavigationController.swift | ||
3 | +// styleteqios | ||
4 | +// | ||
5 | +// Created by Allejo Chris Velarde on 10/13/16. | ||
6 | +// Copyright © 2016 Allejo Chris Velarde. All rights reserved. | ||
7 | +// | ||
8 | + | ||
9 | +import Foundation | ||
10 | +import UIKit | ||
11 | + | ||
12 | +class ActivityFeedNavigationController: UINavigationController, AuthenticatedController | ||
13 | +{ | ||
14 | + | ||
15 | +} | ||
16 | + | ||
17 | +extension ActivityFeedNavigationController: LoginControllerDelegate { | ||
18 | + func didSuccessfulLogin() { | ||
19 | + self.loadDefaultAuthenticatedView(nil) | ||
20 | + } | ||
21 | +} |
swift/ActivityTableViewCell.swift
0 → 100644
1 | +// | ||
2 | +// ActivityTableViewCell.swift | ||
3 | +// styleteqios | ||
4 | +// | ||
5 | +// Created by Alfonz Montelibano on 4/20/16. | ||
6 | +// Copyright © 2016 Allejo Chris Velarde. All rights reserved. | ||
7 | +// | ||
8 | + | ||
9 | +import UIKit | ||
10 | + | ||
11 | +protocol ActivityTableViewCellDelegate { | ||
12 | + func shouldRefreshCell(_ cell: ActivityTableViewCell) | ||
13 | + | ||
14 | + func pushController(_ viewController: UIViewController) | ||
15 | + func shouldRedirectToProfile(_ username: String) | ||
16 | + func didTapHashTag(_ hashtag: String) | ||
17 | +} | ||
18 | + | ||
19 | +class ActivityTableViewCell: UITableViewCell { | ||
20 | + | ||
21 | + @IBOutlet weak var primaryImageHeight: NSLayoutConstraint! | ||
22 | + @IBOutlet weak var activityUserImage: UIImageView! | ||
23 | + @IBOutlet weak var activityImage: UIImageView! | ||
24 | + @IBOutlet weak var activityUserNameLabel: UILabel! | ||
25 | + @IBOutlet weak var activityDescriptionLabel: UILabel! | ||
26 | + @IBOutlet weak var activityDateLabel: UILabel! | ||
27 | + | ||
28 | + @IBOutlet weak var numberOfLIkesUIView: UIView! | ||
29 | + @IBOutlet weak var numberOfLikesUIButton: UIButton! | ||
30 | + | ||
31 | + var delegate: ActivityTableViewCellDelegate? | ||
32 | + | ||
33 | + var activityItem: Activity? | ||
34 | + var adjustedSize: CGSize? | ||
35 | + | ||
36 | + override func awakeFromNib() { | ||
37 | + super.awakeFromNib() | ||
38 | + self.activityDateLabel.isHidden = true | ||
39 | + } | ||
40 | + | ||
41 | + override func prepareForReuse() { | ||
42 | + self.resetCommonElements() | ||
43 | + self.primaryImageHeight.constant = 0 | ||
44 | + | ||
45 | + } | ||
46 | + | ||
47 | + func resetCommonElements() | ||
48 | + { | ||
49 | + self.activityUserImage.image = UIImage(named: "default-image") | ||
50 | + self.activityDateLabel.text = "" | ||
51 | + self.activityUserNameLabel.text = "" | ||
52 | + self.activityDescriptionLabel.text = "" | ||
53 | + | ||
54 | + } | ||
55 | + | ||
56 | + func loadDefaultImage() { | ||
57 | + | ||
58 | + guard let targetImageView = self.activityImage else { | ||
59 | + return | ||
60 | + } | ||
61 | + | ||
62 | + let defaultImage = UIImage(named: "default-image-transparent") | ||
63 | + targetImageView.image = defaultImage | ||
64 | + targetImageView.contentMode = .scaleAspectFill | ||
65 | + // set default size to square | ||
66 | + self.primaryImageHeight.constant = UIScreen.main.bounds.size.width | ||
67 | + | ||
68 | + } | ||
69 | + | ||
70 | + | ||
71 | + open func loadActivityItem(_ activityItem: Activity){ | ||
72 | + self.activityItem = activityItem | ||
73 | + loadCommonDetails() | ||
74 | + | ||
75 | + if (self.activityItem as? NotificationEntity) != nil { | ||
76 | + self.primaryImageHeight.constant = 0 | ||
77 | + } | ||
78 | + | ||
79 | + // display and update number of likes of a photo | ||
80 | + if let activityType = self.activityItem?.activityType.rawValue { | ||
81 | + switch activityType { | ||
82 | + case "Style": | ||
83 | + let style = self.activityItem?.object as! Style | ||
84 | + self.updateLikesCountAndIcon(isLiked: style.isLikedByCurrentUser) | ||
85 | + self.numberOfLIkesUIView.isHidden = false | ||
86 | + case "Special": | ||
87 | + return | ||
88 | + default: | ||
89 | + self.numberOfLIkesUIView.isHidden = true | ||
90 | + } | ||
91 | + | ||
92 | + } | ||
93 | + | ||
94 | + if activityItem.withImage { | ||
95 | + | ||
96 | + self.activityImage.contentMode = .scaleAspectFit | ||
97 | + self.primaryImageHeight.constant = 0 | ||
98 | + WebImageHelper.instance.loadFromUrlString((self.activityItem?.imageUrl)!, imageView: self.activityImage) { (loadedImage) in | ||
99 | + guard let targetImage = loadedImage else { | ||
100 | + return | ||
101 | + } | ||
102 | + | ||
103 | + let adjustedSize = targetImage.getScreenAspectSize(UIScreen.main.bounds.size) | ||
104 | + self.primaryImageHeight.constant = adjustedSize.height | ||
105 | + } | ||
106 | + } | ||
107 | + else { | ||
108 | + | ||
109 | + self.loadDefaultImage() | ||
110 | + | ||
111 | + | ||
112 | + } | ||
113 | + } | ||
114 | + | ||
115 | + func loadCommonDetails() { | ||
116 | + | ||
117 | + let usernameTapRecognizer = UITapGestureRecognizer(target: self, action: #selector(self.presentUserProfile(tapGestureRecognizer:))) | ||
118 | + let userImageTapRecognizer = UITapGestureRecognizer(target: self, action: #selector(self.presentUserProfile(tapGestureRecognizer:))) | ||
119 | + | ||
120 | + // Set activity details | ||
121 | + self.activityUserNameLabel.text = self.activityItem?.getUserName() | ||
122 | + self.activityUserNameLabel?.isUserInteractionEnabled = true | ||
123 | + self.activityUserNameLabel.addGestureRecognizer(usernameTapRecognizer) | ||
124 | + self.activityUserImage.backgroundColor = UIColor.white | ||
125 | + | ||
126 | + if let rawDescription = self.activityItem?.description, let rawDateString = self.activityItem?.getDate() { | ||
127 | + let attributedDescription = NSMutableAttributedString(string: "\(rawDescription) ", attributes: [NSAttributedString.Key.font: UIFont.systemFont(ofSize: 12)]) | ||
128 | + attributedDescription.append(NSAttributedString(string: rawDateString, attributes: [NSAttributedString.Key.font: UIFont.italicSystemFont(ofSize: 10), NSAttributedString.Key.foregroundColor: UIColor.lightGray])) | ||
129 | + self.activityDescriptionLabel.attributedText = attributedDescription | ||
130 | + } | ||
131 | + else { | ||
132 | + self.activityDescriptionLabel.text = "" | ||
133 | + } | ||
134 | + | ||
135 | + | ||
136 | + // Set the profile pic image of the user/owner of this activity | ||
137 | + if let url = self.activityItem?.getUserImageUrl() { | ||
138 | + if url.isEmpty == false { | ||
139 | + WebImageHelper.instance.loadFromUrlString(url, imageView: self.activityUserImage) { _ in | ||
140 | + | ||
141 | + self.activityUserImage?.isUserInteractionEnabled = true | ||
142 | + self.activityUserImage.addGestureRecognizer(userImageTapRecognizer) | ||
143 | + } | ||
144 | + } | ||
145 | + } | ||
146 | + else { | ||
147 | + self.activityUserImage.image = #imageLiteral(resourceName: "default-icon") | ||
148 | + self.activityUserImage?.isUserInteractionEnabled = false | ||
149 | + self.activityUserNameLabel?.isUserInteractionEnabled = false | ||
150 | + } | ||
151 | + } | ||
152 | + | ||
153 | + @objc func presentUserProfile(tapGestureRecognizer: UITapGestureRecognizer) | ||
154 | + { | ||
155 | + if let userId = self.activityItem?.getUserId() { | ||
156 | + | ||
157 | + self.activityUserNameLabel.isUserInteractionEnabled = false | ||
158 | + self.activityUserImage.isUserInteractionEnabled = false | ||
159 | + | ||
160 | + UserRepository.instance.findById(userId) { (user, error) in | ||
161 | + | ||
162 | + self.activityUserNameLabel.isUserInteractionEnabled = true | ||
163 | + self.activityUserImage.isUserInteractionEnabled = true | ||
164 | + | ||
165 | + let storyboard = UIStoryboard(name: "Search", bundle: nil) | ||
166 | + let controller = storyboard.instantiateViewController(withIdentifier: "SearchProfileDetailController") as! SearchProfileDetailController | ||
167 | + | ||
168 | + controller.user = user | ||
169 | + self.delegate?.pushController(controller) | ||
170 | + } | ||
171 | + } | ||
172 | + | ||
173 | + } | ||
174 | + | ||
175 | + @IBAction func pressLikeButton(_ sender: AnyObject) { | ||
176 | + | ||
177 | + let currentUser = UserRepository.instance.getCurrentLocalUser() | ||
178 | + let style = self.activityItem?.object as! Style | ||
179 | + | ||
180 | + if !style.isLikedByCurrentUser { | ||
181 | + currentUser.likeUpload(style) { result, error in | ||
182 | + self.updateLikesCountAndIcon(isLiked: true) | ||
183 | + } | ||
184 | + } else { | ||
185 | + currentUser.unlikeUpload(style) { result, error in | ||
186 | + self.updateLikesCountAndIcon(isLiked: false) | ||
187 | + } | ||
188 | + } | ||
189 | + } | ||
190 | + | ||
191 | + func updateLikesCountAndIcon(isLiked: Bool) { | ||
192 | + | ||
193 | + if isLiked == true { | ||
194 | + self.numberOfLikesUIButton.setImage(UIImage(named: "fa-heart"), for: UIControl.State.normal) | ||
195 | + } else { | ||
196 | + self.numberOfLikesUIButton.setImage(UIImage(named: "fa-heart-o-white"), for: UIControl.State.normal) | ||
197 | + } | ||
198 | + | ||
199 | + (self.activityItem?.object as! Style).getLikesCount() { count, error in | ||
200 | + self.numberOfLikesUIButton.setTitle(" \(count) \( (count == 1) ? "Like" : "Likes")", for: UIControl.State.normal) | ||
201 | + } | ||
202 | + } | ||
203 | + | ||
204 | +} | ||
205 | + | ||
206 | + | ||
207 | + |
swift/ActivityTableViewController.swift
0 → 100644
1 | +// | ||
2 | +// ActivityTableViewController.swift | ||
3 | +// styleteqios | ||
4 | +// | ||
5 | +// Created by Alfonz Montelibano on 5/10/16. | ||
6 | +// Copyright © 2016 Allejo Chris Velarde. All rights reserved. | ||
7 | +// | ||
8 | + | ||
9 | +import UIKit | ||
10 | + | ||
11 | +// This is a parent class extended by MainActivityViewController (activity feeds screen) | ||
12 | +// and NotificationsTableViewController (notifications screen) | ||
13 | + | ||
14 | +class ActivityTableViewController: UIViewController, WithProcessIndicatorController { | ||
15 | + | ||
16 | + let activityTableViewCellIdentifier = "activityTableViewCell" | ||
17 | + let specialActivityTableViewCellIdentifier = "specialActivityTableViewCell" | ||
18 | + var activityItems: [Activity] = [] | ||
19 | + | ||
20 | + var adjustedHeights: [Int:CGFloat] = [:] | ||
21 | + | ||
22 | + | ||
23 | + // Used for pagination | ||
24 | + var currentPage: Int = 0 | ||
25 | + var emptyCollectionLabel = UILabel() | ||
26 | + var refreshControl: UIRefreshControl! | ||
27 | + | ||
28 | + // Process Indicator | ||
29 | + var indicator = UIActivityIndicatorView() | ||
30 | + var processIndicator: UIActivityIndicatorView { | ||
31 | + get { | ||
32 | + return self.indicator | ||
33 | + } | ||
34 | + } | ||
35 | + | ||
36 | + @IBOutlet weak var tableView: UITableView! | ||
37 | + | ||
38 | + override func viewWillAppear(_ animated: Bool) { | ||
39 | + super.viewWillAppear(animated) | ||
40 | + | ||
41 | + self.clear() | ||
42 | + self.refreshControl?.beginRefreshing() | ||
43 | + self.fetchActivities() | ||
44 | + | ||
45 | + tableView.reloadData() | ||
46 | + } | ||
47 | + | ||
48 | + | ||
49 | + | ||
50 | + override func viewDidLoad() { | ||
51 | + super.viewDidLoad() | ||
52 | + | ||
53 | + | ||
54 | + | ||
55 | + self.view.backgroundColor = Theme.defaultBackgroundColor() | ||
56 | + | ||
57 | + self.registerCellNibs() | ||
58 | + | ||
59 | + self.tableView.tableFooterView = UIView() | ||
60 | + self.tableView.tableHeaderView = UIView() | ||
61 | + | ||
62 | + self.emptyCollectionLabel = UILabel(frame: CGRect(x: 20, y: 0, width: self.view.bounds.width - 40, height: self.view.bounds.height)) | ||
63 | + self.emptyCollectionLabel.textAlignment = .center | ||
64 | + self.emptyCollectionLabel.numberOfLines = 0 | ||
65 | + self.emptyCollectionLabel.isHidden = true | ||
66 | + self.view.addSubview(self.emptyCollectionLabel) | ||
67 | + | ||
68 | + self.activityItems = [Activity]() | ||
69 | + | ||
70 | + self.initProcessIndicator(self.view) | ||
71 | + | ||
72 | + self.refreshControl = UIRefreshControl() | ||
73 | + self.refreshControl?.addTarget(self, action: #selector(ActivityTableViewController.handleRefresh(_:)), for: UIControl.Event.valueChanged) | ||
74 | + self.refreshControl?.tintColor = UIColor.lightGray | ||
75 | + self.tableView.addSubview(self.refreshControl) | ||
76 | + | ||
77 | + | ||
78 | + } | ||
79 | + | ||
80 | + func registerCellNibs() | ||
81 | + { | ||
82 | + self.tableView.register(UINib(nibName: "ActivityTableViewCell", bundle: nil), forCellReuseIdentifier: self.activityTableViewCellIdentifier) | ||
83 | + self.tableView.register(UINib(nibName: "ActivitySpecialCellView", bundle: nil), forCellReuseIdentifier: self.specialActivityTableViewCellIdentifier) | ||
84 | + self.tableView.register(UINib(nibName: "StyleActivityTableViewCell", bundle: nil), forCellReuseIdentifier: "styleActivityTableViewCell") | ||
85 | + } | ||
86 | + | ||
87 | + func fetchActivities(){ | ||
88 | + | ||
89 | + } | ||
90 | + | ||
91 | + @objc func handleRefresh(_ refreshControl: UIRefreshControl) { | ||
92 | + self.clear() | ||
93 | + self.fetchActivities() | ||
94 | + | ||
95 | + } | ||
96 | + | ||
97 | + func clear() | ||
98 | + { | ||
99 | + if nil != self.tableView { | ||
100 | + self.activityItems.removeAll() | ||
101 | + self.tableView.reloadData() | ||
102 | + self.currentPage = 1 | ||
103 | + } | ||
104 | + } | ||
105 | + | ||
106 | + | ||
107 | +} | ||
108 | + | ||
109 | +extension ActivityTableViewController: UITableViewDelegate, UITableViewDataSource | ||
110 | +{ | ||
111 | + // MARK: Table View Delegate Functions | ||
112 | + | ||
113 | + func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { | ||
114 | + | ||
115 | + let activityItem = self.activityItems[indexPath.row] | ||
116 | + | ||
117 | + switch activityItem.activityType{ | ||
118 | + case .Style: | ||
119 | + self.loadStylePublicView(activityItem.object as! Style) | ||
120 | + break | ||
121 | + | ||
122 | + case .Special: | ||
123 | + self.loadSpecialPublicView(activityItem.object as! Special) | ||
124 | + break | ||
125 | + | ||
126 | + case .Booth: | ||
127 | + self.loadBoothPublicView(activityItem.object as! Booth) | ||
128 | + break | ||
129 | + | ||
130 | + case .User: | ||
131 | + self.loadUserProfilePublicView(activityItem.object as! User) | ||
132 | + break | ||
133 | + case .Salon: | ||
134 | + self.loadSalonPublicView(activityItem.object as! Salon) | ||
135 | + case .Page: | ||
136 | + self.loadPublicNotificationAnnouncementPage(activityItem.object as! NotificationAnnouncementPage) | ||
137 | + break | ||
138 | + case .Booking: | ||
139 | + guard let booking = activityItem.object as? Booking else { | ||
140 | + break | ||
141 | + } | ||
142 | + self.loadBookingDetailView(booking) | ||
143 | + break | ||
144 | + default: | ||
145 | + break | ||
146 | + } | ||
147 | + } | ||
148 | + | ||
149 | + func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) { | ||
150 | + if self.isLastIndexPath(indexPath) { | ||
151 | + self.fetchActivities() | ||
152 | + } | ||
153 | + } | ||
154 | + | ||
155 | + | ||
156 | + | ||
157 | + | ||
158 | + func isLastIndexPath(_ indexPath: IndexPath) -> Bool { | ||
159 | + let lastSectionIndex = self.tableView.numberOfSections - 1 | ||
160 | + let lastItemIndex = self.tableView.numberOfRows(inSection: lastSectionIndex) - 1 | ||
161 | + | ||
162 | + return (lastSectionIndex == indexPath.section && lastItemIndex == indexPath.row) | ||
163 | + } | ||
164 | + | ||
165 | + func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { | ||
166 | + let activity = self.activityItems[indexPath.row] | ||
167 | + | ||
168 | + switch activity.activityType { | ||
169 | + case .Special: | ||
170 | + let cell = tableView.dequeueReusableCell(withIdentifier: self.specialActivityTableViewCellIdentifier) as! ActivitySpecialCellView | ||
171 | + cell.loadActivityItem(activity) | ||
172 | + cell.loadSpecial(activity.object as! Special) | ||
173 | + cell.delegate = self | ||
174 | + return cell | ||
175 | + | ||
176 | + case .Style: | ||
177 | + let cell = tableView.dequeueReusableCell(withIdentifier: "styleActivityTableViewCell") as! StyleActivityTableViewCell | ||
178 | + cell.loadActivityItem(activity) | ||
179 | + cell.delegate = self | ||
180 | + | ||
181 | + return cell | ||
182 | + default: | ||
183 | + let cell = tableView.dequeueReusableCell(withIdentifier: activityTableViewCellIdentifier) as! ActivityTableViewCell | ||
184 | + cell.loadActivityItem(activity) | ||
185 | + cell.delegate = self | ||
186 | + | ||
187 | + return cell | ||
188 | + } | ||
189 | + | ||
190 | + | ||
191 | + } | ||
192 | + | ||
193 | + func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { | ||
194 | + return self.activityItems.count | ||
195 | + } | ||
196 | + | ||
197 | + func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat { | ||
198 | + let activity = self.activityItems[indexPath.row] | ||
199 | + switch activity.activityType { | ||
200 | + case .Special: | ||
201 | + return 160 | ||
202 | + case .Style: | ||
203 | + guard let preloadedSize = activity.carouselImageAspectSize, let itemStyle = activity.object as? Style else { | ||
204 | + return UIScreen.main.bounds.size.width + 100 | ||
205 | + } | ||
206 | + | ||
207 | + let labelHeight = itemStyle.description.height(withConstrainedWidth: UIScreen.main.bounds.width, UIFont.systemFont(ofSize: 14)) | ||
208 | + | ||
209 | + return preloadedSize.height + 64 + labelHeight | ||
210 | + default: | ||
211 | + | ||
212 | + if (activity as? NotificationEntity) != nil { | ||
213 | + return 80 | ||
214 | + } | ||
215 | + | ||
216 | + if activity.withImage == false { | ||
217 | + return UIScreen.main.bounds.size.width + 100 | ||
218 | + } | ||
219 | + else { | ||
220 | + guard let preloadedSize = activity.carouselImageAspectSize else { | ||
221 | + return UIScreen.main.bounds.size.width + 100 | ||
222 | + } | ||
223 | + | ||
224 | + return preloadedSize.height + 64 | ||
225 | + } | ||
226 | + | ||
227 | + } | ||
228 | + | ||
229 | + } | ||
230 | + | ||
231 | + | ||
232 | +} | ||
233 | + | ||
234 | +extension ActivityTableViewController: ActivityTableViewCellDelegate { | ||
235 | + func shouldRefreshCell(_ cell: ActivityTableViewCell) { | ||
236 | + } | ||
237 | + | ||
238 | + func pushController(_ viewController: UIViewController) { | ||
239 | + self.navigationController?.pushViewController(viewController, animated: true) | ||
240 | + } | ||
241 | + func shouldRedirectToProfile(_ username: String) { | ||
242 | + UserRepository.instance.findByUsername(username) { (user, error) in | ||
243 | + if let user = user { | ||
244 | + self.loadUserProfilePublicView(user) | ||
245 | + } else { | ||
246 | + self.showAlert("Alert!", message: "User not found!", action: nil) | ||
247 | + } | ||
248 | + } | ||
249 | + } | ||
250 | + func didTapHashTag(_ hashtag: String) { | ||
251 | + self.loadHashTagSearchResult(hashtag) | ||
252 | + } | ||
253 | + | ||
254 | +} |
swift/MainActivityViewController.swift
0 → 100644
1 | +// | ||
2 | +// MainActivityViewController.swift | ||
3 | +// styleteqios | ||
4 | +// | ||
5 | +// Created by Alfonz Montelibano on 4/20/16. | ||
6 | +// Copyright © 2016 Allejo Chris Velarde. All rights reserved. | ||
7 | +// | ||
8 | + | ||
9 | +import UIKit | ||
10 | + | ||
11 | +class MainActivityViewController: ActivityTableViewController { | ||
12 | + | ||
13 | + // Fetch news feed activities. This func is called from ActivityTableViewController.updateList() | ||
14 | + override func fetchActivities() { | ||
15 | + | ||
16 | + if currentPage == 0 { | ||
17 | + return | ||
18 | + } | ||
19 | + | ||
20 | + self.emptyCollectionLabel.text = "No Feeds Available. Follow a user or shop and get their latest updates." | ||
21 | + self.startProcessIndicator() | ||
22 | + ActivityRepository.instance.getActivityByUser(UserRepository.instance.getCurrentLocalUser(), params: ["page": self.currentPage as Any]) { (response, error) in | ||
23 | + | ||
24 | + self.stopProcessIndicator() | ||
25 | + self.refreshControl!.endRefreshing() | ||
26 | + | ||
27 | + if error != nil { | ||
28 | + self.showAlert("Ooops", message: "Unable to load your feeds right now.", action: nil) | ||
29 | + } | ||
30 | + else { | ||
31 | + guard let pager = response else { | ||
32 | + return | ||
33 | + } | ||
34 | + self.currentPage = pager.nextPage | ||
35 | + | ||
36 | + for each in pager.collection as! [Activity] { | ||
37 | + self.activityItems.append(each) | ||
38 | + self.tableView.reloadData() | ||
39 | + | ||
40 | + } | ||
41 | + } | ||
42 | + | ||
43 | + self.emptyCollectionLabel.isHidden = self.activityItems.isEmpty ? false : true | ||
44 | + | ||
45 | + } | ||
46 | + } | ||
47 | +} | ||
48 | + | ||
49 | +extension MainActivityViewController: StyleFullViewDelegate { | ||
50 | + func didUpdateStyle(_ style: Style) { | ||
51 | + | ||
52 | + } | ||
53 | + | ||
54 | + func didDeleteStyle(_ style: Style) { | ||
55 | + self.activityItems = self.activityItems.filter { each in | ||
56 | + | ||
57 | + guard let asStyle = each.object as? Style else { | ||
58 | + return true | ||
59 | + } | ||
60 | + | ||
61 | + return (style.id == asStyle.id) ? false : true | ||
62 | + } | ||
63 | + self.tableView?.reloadData() | ||
64 | + } | ||
65 | +} | ||
66 | + | ||
67 | + |