Commit 112ccc1805e1f69dcb6e46a6e8ad042b3cb3a42b

Authored by Allejo Chris Velarde
0 parents

Sample snippets

Showing 58 changed files with 4270 additions and 0 deletions
  1 +.idea
  2 +.DS_Store
... ...
  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`;
... ...
  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'/>&nbsp; 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;
... ...
  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;
... ...
  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 +);
... ...
  1 +import { createThunkRoutine } from 'redux-thunk-routine';
  2 +
  3 +export const fetchThreadContents = createThunkRoutine('FETCH_THREAD_CONTENTS');
  4 +
  5 +export const postThreadContent = createThunkRoutine('POST_THREAD_CONTENT');
... ...
  1 +import composePagePathFor from '../helpers/composePagePathFor';
  2 +
  3 +export const inboxPath = composePagePathFor('inbox');
... ...
  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 +});
... ...
  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 +export const COLUMNS = {
  2 + RANK: 'rank',
  3 + FP: 'fantasyPoints',
  4 + FPPG: 'fantasyPointsPerGame',
  5 + PRICE: 'value'
  6 +};
  7 +
  8 +export const DEFAULT_SORT: { COLUMN: string; DIRECTION: SortType } = {
  9 + COLUMN: COLUMNS.PRICE,
  10 + DIRECTION: 'DESC'
  11 +};
... ...
  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;
... ...
  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 +namespace NexmoBundle\Exception;
  4 +
  5 +class NexmoException extends \Exception
  6 +{
  7 + public static function invalidPrivateKeyPath()
  8 + {
  9 + return new self("Invalid path for Nexmo private key.");
  10 + }
  11 +}
... ...
  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 +}
... ...
  1 +<?php
  2 +
  3 +namespace NexmoBundle;
  4 +
  5 +use Symfony\Component\HttpKernel\Bundle\Bundle;
  6 +
  7 +class NexmoBundle extends Bundle
  8 +{
  9 +}
... ...
  1 +services:
  2 + nexmo.audit_log_resolver:
  3 + class: NexmoBundle\Event\Logger\VoiceCallAuditLogResolver
  4 +
  5 + nexmo.audit_log_subscriber:
  6 + class: NexmoBundle\Event\Logger\VoiceCallAuditLogSubscriber
  7 + tags:
  8 + - { name: audit_log.event_subscriber, resolver : nexmo.audit_log_resolver }
\ No newline at end of file
... ...
  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 +}
... ...
  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 +}
... ...
  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 +}
... ...
  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 +}
... ...
  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 +}
... ...
  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
... ...
  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 +}
... ...
  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 +
... ...
  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 +}
... ...
  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 +
... ...