feat: calls to services and the construction of the UI are incorporated

master
TyFonDev 2023-12-06 00:39:45 -03:00
parent b66e4e19bc
commit 1541036acc
58 changed files with 4294 additions and 727 deletions

118
App.tsx
View File

@ -1,118 +0,0 @@
/**
* Sample React Native App
* https://github.com/facebook/react-native
*
* @format
*/
import React from 'react';
import type {PropsWithChildren} from 'react';
import {
SafeAreaView,
ScrollView,
StatusBar,
StyleSheet,
Text,
useColorScheme,
View,
} from 'react-native';
import {
Colors,
DebugInstructions,
Header,
LearnMoreLinks,
ReloadInstructions,
} from 'react-native/Libraries/NewAppScreen';
type SectionProps = PropsWithChildren<{
title: string;
}>;
function Section({children, title}: SectionProps): JSX.Element {
const isDarkMode = useColorScheme() === 'dark';
return (
<View style={styles.sectionContainer}>
<Text
style={[
styles.sectionTitle,
{
color: isDarkMode ? Colors.white : Colors.black,
},
]}>
{title}
</Text>
<Text
style={[
styles.sectionDescription,
{
color: isDarkMode ? Colors.light : Colors.dark,
},
]}>
{children}
</Text>
</View>
);
}
function App(): JSX.Element {
const isDarkMode = useColorScheme() === 'dark';
const backgroundStyle = {
backgroundColor: isDarkMode ? Colors.darker : Colors.lighter,
};
return (
<SafeAreaView style={backgroundStyle}>
<StatusBar
barStyle={isDarkMode ? 'light-content' : 'dark-content'}
backgroundColor={backgroundStyle.backgroundColor}
/>
<ScrollView
contentInsetAdjustmentBehavior="automatic"
style={backgroundStyle}>
<Header />
<View
style={{
backgroundColor: isDarkMode ? Colors.black : Colors.white,
}}>
<Section title="Step One">
Edit <Text style={styles.highlight}>App.tsx</Text> to change this
screen and then come back to see your edits.
</Section>
<Section title="See Your Changes">
<ReloadInstructions />
</Section>
<Section title="Debug">
<DebugInstructions />
</Section>
<Section title="Learn More">
Read the docs to discover what to do next:
</Section>
<LearnMoreLinks />
</View>
</ScrollView>
</SafeAreaView>
);
}
const styles = StyleSheet.create({
sectionContainer: {
marginTop: 32,
paddingHorizontal: 24,
},
sectionTitle: {
fontSize: 24,
fontWeight: '600',
},
sectionDescription: {
marginTop: 8,
fontSize: 18,
fontWeight: '400',
},
highlight: {
fontWeight: '700',
},
});
export default App;

View File

@ -1,17 +0,0 @@
/**
* @format
*/
import 'react-native';
import React from 'react';
import App from '../App';
// Note: import explicitly to use the types shiped with jest.
import {it} from '@jest/globals';
// Note: test renderer must be required after react-native.
import renderer from 'react-test-renderer';
it('renders correctly', () => {
renderer.create(<App />);
});

View File

@ -1,6 +1,11 @@
apply plugin: "com.android.application"
apply plugin: "com.facebook.react"
project.ext.vectoricons = [
iconFontNames: ['MaterialCommunityIcons.ttf']
]
apply from: file("../../node_modules/react-native-vector-icons/fonts.gradle")
/**
* This is the configuration block to customize your React Native Android app.
* By default you don't need to apply any configuration, just uncomment the lines you need.

View File

@ -3,7 +3,7 @@
*/
import {AppRegistry} from 'react-native';
import App from './App';
import {name as appName} from './app.json';
import App from './src/presentation/screens/App';
AppRegistry.registerComponent(appName, () => App);

View File

@ -14,6 +14,7 @@
13B07FC11A68108700A75B9A /* main.m in Sources */ = {isa = PBXBuildFile; fileRef = 13B07FB71A68108700A75B9A /* main.m */; };
7699B88040F8A987B510C191 /* libPods-SmartStop-SmartStopTests.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 19F6CBCC0A4E27FBF8BF4A61 /* libPods-SmartStop-SmartStopTests.a */; };
81AB9BB82411601600AC10FF /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 81AB9BB72411601600AC10FF /* LaunchScreen.storyboard */; };
904C36942B12DB0600B0C7C3 /* MaterialCommunityIcons.ttf in Resources */ = {isa = PBXBuildFile; fileRef = 90BEE4642AFC5DCD0014196C /* MaterialCommunityIcons.ttf */; };
/* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */
@ -43,6 +44,7 @@
5DCACB8F33CDC322A6C60F78 /* libPods-SmartStop.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-SmartStop.a"; sourceTree = BUILT_PRODUCTS_DIR; };
81AB9BB72411601600AC10FF /* LaunchScreen.storyboard */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.storyboard; name = LaunchScreen.storyboard; path = SmartStop/LaunchScreen.storyboard; sourceTree = "<group>"; };
89C6BE57DB24E9ADA2F236DE /* Pods-SmartStop-SmartStopTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-SmartStop-SmartStopTests.release.xcconfig"; path = "Target Support Files/Pods-SmartStop-SmartStopTests/Pods-SmartStop-SmartStopTests.release.xcconfig"; sourceTree = "<group>"; };
90BEE4642AFC5DCD0014196C /* MaterialCommunityIcons.ttf */ = {isa = PBXFileReference; lastKnownFileType = file; path = MaterialCommunityIcons.ttf; sourceTree = "<group>"; };
ED297162215061F000B7C4FE /* JavaScriptCore.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = JavaScriptCore.framework; path = System/Library/Frameworks/JavaScriptCore.framework; sourceTree = SDKROOT; };
/* End PBXFileReference section */
@ -116,6 +118,7 @@
83CBB9F61A601CBA00E9B192 = {
isa = PBXGroup;
children = (
90BEE45D2AFC5DCD0014196C /* Fonts */,
13B07FAE1A68108700A75B9A /* SmartStop */,
832341AE1AAA6A7D00B99B32 /* Libraries */,
00E356EF1AD99517003FC87E /* SmartStopTests */,
@ -137,6 +140,15 @@
name = Products;
sourceTree = "<group>";
};
90BEE45D2AFC5DCD0014196C /* Fonts */ = {
isa = PBXGroup;
children = (
90BEE4642AFC5DCD0014196C /* MaterialCommunityIcons.ttf */,
);
name = Fonts;
path = "../node_modules/react-native-vector-icons/Fonts";
sourceTree = "<group>";
};
BBD78D7AC51CEA395F1C20DB /* Pods */ = {
isa = PBXGroup;
children = (
@ -244,6 +256,7 @@
files = (
81AB9BB82411601600AC10FF /* LaunchScreen.storyboard in Resources */,
13B07FBF1A68108700A75B9A /* Images.xcassets in Resources */,
904C36942B12DB0600B0C7C3 /* MaterialCommunityIcons.ttf in Resources */,
);
runOnlyForDeploymentPostprocessing = 0;
};

View File

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>IDEDidComputeMac32BitWarning</key>
<true/>
</dict>
</plist>

View File

@ -2,6 +2,10 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>UIAppFonts</key>
<array>
<string>MaterialCommunityIcons.ttf</string>
</array>
<key>CFBundleDevelopmentRegion</key>
<string>en</string>
<key>CFBundleDisplayName</key>

View File

@ -1,3 +0,0 @@
module.exports = {
preset: 'react-native',
};

10
jest.config.ts 100644
View File

@ -0,0 +1,10 @@
const esModules = [
'@react-native',
'react-native',
'react-native-vector-icons',
].join('|');
export default {
preset: 'react-native',
transformIgnorePatterns: [`node_modules/(?!${esModules})`],
};

View File

@ -10,8 +10,12 @@
"test": "jest"
},
"dependencies": {
"axios": "^1.6.2",
"moment": "^2.29.4",
"react": "18.2.0",
"react-native": "0.72.6"
"react-native": "0.72.6",
"react-native-device-info": "^10.11.0",
"react-native-vector-icons": "^10.0.2"
},
"devDependencies": {
"@babel/core": "^7.20.0",
@ -22,9 +26,14 @@
"@react-native-community/eslint-config": "^3.2.0",
"@react-native/eslint-config": "^0.72.2",
"@react-native/metro-config": "^0.72.11",
"@testing-library/react-native": "^12.4.1",
"@tsconfig/react-native": "^3.0.0",
"@types/jest": "^29.5.8",
"@types/node": "^20.10.3",
"@types/react": "^18.0.24",
"@types/react-native-vector-icons": "^6.4.17",
"@types/react-test-renderer": "^18.0.0",
"axios-mock-adapter": "^1.22.0",
"babel-jest": "^29.2.1",
"eslint": "^8.19.0",
"husky": "^8.0.3",
@ -32,6 +41,8 @@
"metro-react-native-babel-preset": "0.76.8",
"prettier": "^2.4.1",
"react-test-renderer": "18.2.0",
"ts-node": "^10.9.1",
"tslib": "^2.6.2",
"typescript": "4.8.4"
},
"engines": {

View File

@ -0,0 +1,9 @@
module.exports = {
dependencies: {
'react-native-vector-icons': {
platforms: {
ios: null,
},
},
},
};

View File

@ -0,0 +1,8 @@
interface Arrival {
carPlate: string;
planned: string;
estimatedGPS: string;
distanceGPS: string;
}
export default Arrival;

View File

@ -0,0 +1,7 @@
import AuthRequest from '../../infraestructure/api/models/AuthRequest';
interface AuthRepository {
auth(request: AuthRequest): Promise<string>;
}
export default AuthRepository;

View File

@ -0,0 +1,10 @@
import DeviceRequest from '../../infraestructure/api/models/DeviceRequest';
import DeviceInfoResponse from '../../infraestructure/api/models/DeviceInfoResponse';
import WhoAmIResponse from './WhoAmIResponse';
interface DevicesRepository {
whoAmI(request: DeviceRequest): Promise<WhoAmIResponse>;
getDeviceInfo(request: DeviceRequest): Promise<DeviceInfoResponse>;
}
export default DevicesRepository;

View File

@ -0,0 +1,13 @@
import Arrival from './Arrival';
interface LineDetail {
lineNumber: string;
description: string;
locomotionType: number;
backgroundColor: string;
letterColor: string;
lineMessage: string;
arrivals: Arrival[];
}
export default LineDetail;

View File

@ -0,0 +1,6 @@
interface WhoAmIResponse {
stopNumber: string;
stopName: string;
}
export default WhoAmIResponse;

View File

@ -0,0 +1,371 @@
import {jest} from '@jest/globals';
import BusStopInfoService from './BusStopInfoService';
const service = new BusStopInfoService([
{
lineNumber: '803010',
description: 'Tucapel',
locomotionType: 1,
backgroundColor: 'Hexadecimal',
letterColor: 'Hexadecimal',
lineMessage: '',
arrivals: [
{
carPlate: 'RPDA-98',
planned: '',
estimatedGPS: '15:08',
distanceGPS: '1.0 KM',
},
{
carPlate: 'WYXYZ-22',
planned: '',
estimatedGPS: '15:42',
distanceGPS: '5.0 KM',
},
{
carPlate: 'ABCA-65',
planned: '',
estimatedGPS: '17:18',
distanceGPS: '13.4 KM',
},
],
},
{
lineNumber: '5487',
description: 'Centauro',
locomotionType: 1,
backgroundColor: 'Hexadecimal',
letterColor: 'Hexadecimal',
lineMessage: 'Sin info. GPS, la informacion es estimada',
arrivals: [
{
carPlate: 'PLKJ-32',
planned: '15:13',
estimatedGPS: '15:13',
distanceGPS: '',
},
{
carPlate: 'GHLK-11',
planned: '15:39',
estimatedGPS: '15:39',
distanceGPS: '',
},
{
carPlate: 'DFQW-55',
planned: '17:22',
estimatedGPS: '17:22',
distanceGPS: '',
},
],
},
]);
jest.useFakeTimers({legacyFakeTimers: false});
describe('BusStopInfoService tests', () => {
it('should be defined', () => {
expect(BusStopInfoService).toBeTruthy();
});
describe('getBuses', () => {
it('should return a list of buses', () => {
const buses = service.getLines();
expect(buses).toHaveLength(2);
expect(buses).toMatchObject([
{
lineNumber: '803010',
description: 'Tucapel',
locomotionType: 1,
backgroundColor: 'Hexadecimal',
letterColor: 'Hexadecimal',
lineMessage: '',
arrivals: [
{
carPlate: 'RPDA-98',
planned: '',
estimatedGPS: '15:08',
distanceGPS: '1.0 KM',
},
{
carPlate: 'WYXYZ-22',
planned: '',
estimatedGPS: '15:42',
distanceGPS: '5.0 KM',
},
{
carPlate: 'ABCA-65',
planned: '',
estimatedGPS: '17:18',
distanceGPS: '13.4 KM',
},
],
},
{
lineNumber: '5487',
description: 'Centauro',
locomotionType: 1,
backgroundColor: 'Hexadecimal',
letterColor: 'Hexadecimal',
lineMessage: 'Sin info. GPS, la informacion es estimada',
arrivals: [
{
carPlate: 'PLKJ-32',
planned: '15:13',
estimatedGPS: '15:13',
distanceGPS: '',
},
{
carPlate: 'GHLK-11',
planned: '15:39',
estimatedGPS: '15:39',
distanceGPS: '',
},
{
carPlate: 'DFQW-55',
planned: '17:22',
estimatedGPS: '17:22',
distanceGPS: '',
},
],
},
]);
});
});
it('should return a bus by lineNumber', () => {
const bus = service.getLinesByNumber('803010');
expect(bus).toMatchObject({
lineNumber: '803010',
description: 'Tucapel',
locomotionType: 1,
backgroundColor: 'Hexadecimal',
letterColor: 'Hexadecimal',
lineMessage: '',
arrivals: [
{
carPlate: 'RPDA-98',
planned: '',
estimatedGPS: '15:08',
distanceGPS: '1.0 KM',
},
{
carPlate: 'WYXYZ-22',
planned: '',
estimatedGPS: '15:42',
distanceGPS: '5.0 KM',
},
{
carPlate: 'ABCA-65',
planned: '',
estimatedGPS: '17:18',
distanceGPS: '13.4 KM',
},
],
});
});
describe('pruneBusList', () => {
it('should return a list of buses', () => {
const prunedList = service.pruneBusList(0, 3);
expect(prunedList).toHaveLength(2);
expect(prunedList).toMatchObject([
{
lineNumber: '803010',
description: 'Tucapel',
locomotionType: 1,
backgroundColor: 'Hexadecimal',
letterColor: 'Hexadecimal',
lineMessage: '',
arrivals: [
{
carPlate: 'RPDA-98',
planned: '',
estimatedGPS: '15:08',
distanceGPS: '1.0 KM',
},
{
carPlate: 'WYXYZ-22',
planned: '',
estimatedGPS: '15:42',
distanceGPS: '5.0 KM',
},
{
carPlate: 'ABCA-65',
planned: '',
estimatedGPS: '17:18',
distanceGPS: '13.4 KM',
},
],
},
{
lineNumber: '5487',
description: 'Centauro',
locomotionType: 1,
backgroundColor: 'Hexadecimal',
letterColor: 'Hexadecimal',
lineMessage: 'Sin info. GPS, la informacion es estimada',
arrivals: [
{
carPlate: 'PLKJ-32',
planned: '15:13',
estimatedGPS: '15:13',
distanceGPS: '',
},
{
carPlate: 'GHLK-11',
planned: '15:39',
estimatedGPS: '15:39',
distanceGPS: '',
},
{
carPlate: 'DFQW-55',
planned: '17:22',
estimatedGPS: '17:22',
distanceGPS: '',
},
],
},
]);
});
});
describe('getNextArraival', () => {
beforeEach(() => {
jest.useFakeTimers({legacyFakeTimers: false});
});
afterEach(() => {
jest.useRealTimers();
});
it('should return next arraival', () => {
jest.setSystemTime(new Date('2024-11-25T16:33:37').getTime());
const nextArraival = service.getNextArraival('803010');
expect(nextArraival).toMatchObject({
carPlate: 'ABCA-65',
planned: '',
estimatedGPS: '17:18',
distanceGPS: '13.4 KM',
});
});
it('should throw an error if not arraival found', () => {
jest.setSystemTime(new Date('2024-11-25T20:33:37').getTime());
expect(() => {
service.getNextArraival('803010');
}).toThrow('No next arrival');
});
});
describe('isArraivalTimeBetween tests', () => {
beforeEach(() => {
jest.useFakeTimers({legacyFakeTimers: false});
});
afterEach(() => {
jest.useRealTimers();
});
it('should return < a 3 minutos', () => {
jest.setSystemTime(new Date('2024-11-25T17:17:37').getTime());
const arraival = {
carPlate: 'ABCA-65',
planned: '',
estimatedGPS: '17:18',
distanceGPS: '13.4 KM',
};
const arraivalTimeText = service.checkArraivalTime(arraival);
expect(arraivalTimeText).toStrictEqual('< a 3 minutos');
});
it('should return Entre 3 a 5 minutos', () => {
jest.setSystemTime(new Date('2024-11-25T17:14:59').getTime());
const arraival = {
carPlate: 'ABCA-65',
planned: '',
estimatedGPS: '17:18',
distanceGPS: '13.4 KM',
};
const arraivalTimeText = service.checkArraivalTime(arraival);
expect(arraivalTimeText).toStrictEqual('Entre 3 a 5 minutos');
});
it('should return Menos de 10 minutos', () => {
jest.setSystemTime(new Date('2024-11-25T17:09:37').getTime());
const arraival = {
carPlate: 'ABCA-65',
planned: '',
estimatedGPS: '17:18',
distanceGPS: '13.4 KM',
};
const arraivalTimeText = service.checkArraivalTime(arraival);
expect(arraivalTimeText).toStrictEqual('Menos de 10 minutos');
});
it('should return Más de 10 minutos', () => {
jest.setSystemTime(new Date('2024-11-25T17:00:37').getTime());
const arraival = {
carPlate: 'ABCA-65',
planned: '',
estimatedGPS: '17:18',
distanceGPS: '13.4 KM',
};
const arraivalTimeText = service.checkArraivalTime(arraival);
expect(arraivalTimeText).toStrictEqual('Más de 10 minutos');
});
});
describe('getLineNumberFromDescription tests', () => {
it('should return a list of numbers', () => {
const numbers = service.getLineNumberFromDescription('10N');
expect(numbers).toBe('10');
});
it('should throw an error if no numbers found', () => {
expect(() => {
service.getLineNumberFromDescription('N');
}).toThrow('No numbers found');
});
});
describe('getLineLetterFromDescription tests', () => {
it('should return a list of letters', () => {
const letters = service.getLineLetterFromDescription('10N');
expect(letters).toBe('N');
});
it('should throw an error if no letters found', () => {
expect(() => {
service.getLineLetterFromDescription('10');
}).toThrow('No letters found');
});
});
describe('getLineDescription tests', () => {
it('should return a line description', () => {
const description = service.getLineDescription(
'10K',
'Vía Láctea 10K - Leonera',
);
expect(description).toBe('Vía Láctea - Leonera');
});
});
});

View File

@ -0,0 +1,102 @@
import moment from 'moment';
import LineDetail from '../repositories/LineDetail';
import {getHours, getMinutes, getSeconds} from '../../utils/DateUtils';
import Arrival from '../repositories/Arrival';
// TODO: Change to a better name or split ?
class BusStopInfoService {
private lines: LineDetail[];
constructor(lines: LineDetail[]) {
this.lines = lines;
}
getLines(): LineDetail[] {
return this.lines;
}
getLinesByNumber(lineNumber: string): LineDetail {
const line = this.lines.find(bus => bus.lineNumber === lineNumber);
if (!line) {
throw new Error('No buses found');
}
return line;
}
pruneBusList(start: number, howMany: number): LineDetail[] {
const end = start + howMany;
return this.lines.slice(start, end);
}
getNextArraival(lineNumber: string): Arrival {
const currentTime = moment();
const nextArraival = this.getLinesByNumber(lineNumber).arrivals.find(
arrival => {
const compareTime = moment();
compareTime.set('hour', getHours(arrival.estimatedGPS));
compareTime.set('minute', getMinutes(arrival.estimatedGPS));
compareTime.set('second', getSeconds(arrival.estimatedGPS));
if (compareTime.isAfter(currentTime)) {
return arrival;
}
},
);
if (!nextArraival) {
throw new Error('No next arrival');
}
return nextArraival;
}
checkArraivalTime(arrival: Arrival): string {
const compareTime = moment();
compareTime.set('hour', getHours(arrival.estimatedGPS));
compareTime.set('minute', getMinutes(arrival.estimatedGPS));
compareTime.set('second', getSeconds(arrival.estimatedGPS));
const now = moment();
const diffInMinutes = compareTime.diff(now, 'minutes');
if (diffInMinutes <= 3) {
return '< a 3 minutos';
} else if (diffInMinutes > 3 && diffInMinutes < 6) {
return 'Entre 3 a 5 minutos';
} else if (diffInMinutes >= 6 && diffInMinutes <= 10) {
return 'Menos de 10 minutos';
} else {
return 'Más de 10 minutos';
}
}
getLineNumberFromDescription(description: string): string {
const numbers = description.match(/\d+/g);
if (!numbers) {
throw new Error('No numbers found');
}
return numbers.join('');
}
getLineLetterFromDescription(description: string): string {
const letters = description.match(/[a-zA-Z]+/g);
if (!letters) {
throw new Error('No letters found');
}
return letters.join('');
}
getLineDescription(description: string, line: string): string {
const space = ' '; // this is to eliminate the extra space left when applying the replace.
return line.replace(`${space}${description}`, '');
}
}
export default BusStopInfoService;

View File

@ -0,0 +1,30 @@
import axios from 'axios';
import AuthResponse from '../models/AuthResponse';
import AuthRequest from '../models/AuthRequest';
class AuthAPI {
private baseURL: string;
constructor(baseURL: string) {
this.baseURL = baseURL;
}
async auth({username, password}: AuthRequest): Promise<AuthResponse> {
if (!username) {
throw new Error('username is required');
}
if (!password) {
throw new Error('password is required');
}
const {data} = await axios.post<AuthResponse>(`${this.baseURL}/api/auth/`, {
username,
password,
});
return data;
}
}
export default AuthAPI;

View File

@ -0,0 +1,72 @@
import axios from 'axios';
import DeviceRequest from '../models/DeviceRequest';
import {GetInfoDeviceResponse} from '../models/GetInfoDeviceResponse';
import WhoamiResponse from '../models/WhoamiResponse';
import responseDevicesMock from './response-devices-mock.json';
const KEYAUTORIZACION = 'token';
class DevicesAPI {
private baseURL: string;
constructor(baseURL: string) {
this.baseURL = baseURL;
}
async whoami({token, deviceId}: DeviceRequest): Promise<WhoamiResponse> {
if (!token) {
throw new Error('token is required');
}
if (!deviceId) {
throw new Error('deviceId is required');
}
const {data} = await axios.post<WhoamiResponse>(
`${this.baseURL}/api/dispositivos/whoami/`,
{
whoami: {idDispositivo: deviceId, KeyAuthorizacion: KEYAUTORIZACION},
},
{
headers: {
Authorization: `Bearer ${token}`,
},
},
);
return data;
}
async getInfoDevice({
token,
deviceId,
}: DeviceRequest): Promise<GetInfoDeviceResponse> {
if (!token) {
throw new Error('token is required');
}
if (!deviceId) {
throw new Error('deviceId is required');
}
const {data} = await axios.post<GetInfoDeviceResponse>(
`${this.baseURL}/api/dispositivos/getInfoDevice/`,
{
GetInfoDevice: {
idDispositivo: deviceId,
KeyAuthorizacion: KEYAUTORIZACION,
},
},
{
headers: {
Authorization: `Bearer ${token}`,
},
},
);
return data; // rial implementation
// return responseDevicesMock; // mock implementation
}
}
export default DevicesAPI;

View File

@ -0,0 +1,59 @@
import axios from 'axios';
import MockAdapter from 'axios-mock-adapter';
import AuthAPI from '../AuthAPI';
const mockAdapter = new MockAdapter(axios);
const BASE_URL = 'https://test.cl';
const authAPI = new AuthAPI(BASE_URL);
describe('AuthAPI tests', () => {
it('should be defined', () => {
expect(AuthAPI).toBeDefined();
});
it('should return a token', async () => {
mockAdapter.onPost(`${BASE_URL}/api/auth/`).reply(200, {
token: 'token',
});
const token = await authAPI.auth({
username: 'username',
password: 'password',
});
expect(token).toMatchObject({token: 'token'});
});
it('should throw an error if username is not provided', async () => {
await expect(
authAPI.auth({
password: 'password',
username: '',
}),
).rejects.toThrow('username is required');
});
it('should throw an error if password is not provided', async () => {
await expect(
authAPI.auth({
username: 'username',
password: '',
}),
).rejects.toThrow('password is required');
});
it('should return an error if it fails to request a token', async () => {
mockAdapter.onPost(`${BASE_URL}/api/auth/`).reply(400, {
error: 'error',
});
await expect(
authAPI.auth({
username: 'username',
password: 'password',
}),
).rejects.toThrow('Request failed with status code 400');
});
});

View File

@ -0,0 +1,107 @@
import axios from 'axios';
import MockAdapter from 'axios-mock-adapter';
import DevicesAPI from '../DevicesAPI';
const mockAdapter = new MockAdapter(axios);
const BASE_URL = 'https://test.cl';
const deviceAPI = new DevicesAPI(BASE_URL);
describe('devices tests', () => {
it('should be defined', () => {
expect(deviceAPI).toBeDefined();
});
describe('whoami', () => {
it('should return stop information by device id', async () => {
mockAdapter.onPost(`${BASE_URL}/api/dispositivos/whoami/`).reply(200, {
stopNumber: 'stopNumber',
stopName: 'stopName',
});
const response = await deviceAPI.whoami({
token: 'token',
deviceId: 'deviceId',
});
expect(response).toMatchObject({
stopNumber: 'stopNumber',
stopName: 'stopName',
});
});
it('should throw an error if token is not provided', async () => {
await expect(
deviceAPI.whoami({token: '', deviceId: 'deviceId'}),
).rejects.toThrow('token is required');
});
it('should throw an error if deviceId is not provided', async () => {
await expect(
deviceAPI.whoami({token: 'token', deviceId: ''}),
).rejects.toThrow('deviceId is required');
});
it('should return an error if it fails to request stop information', async () => {
mockAdapter.onPost(`${BASE_URL}/api/dispositivos/whoami/`).reply(400, {
error: 'error',
});
await expect(
deviceAPI.whoami({
token: 'token',
deviceId: 'deviceId',
}),
).rejects.toThrow('Request failed with status code 400');
});
});
describe('getInfoDevice', () => {
it('should return stop information by device id', async () => {
mockAdapter
.onPost(`${BASE_URL}/api/dispositivos/getInfoDevice/`)
.reply(200, {
stopNumber: 'stopNumber',
stopName: 'stopName',
});
const response = await deviceAPI.getInfoDevice({
token: 'token',
deviceId: 'deviceId',
});
expect(response).toMatchObject({
stopNumber: 'stopNumber',
stopName: 'stopName',
});
});
it('should throw an error if token is not provided', async () => {
await expect(
deviceAPI.getInfoDevice({token: '', deviceId: 'deviceId'}),
).rejects.toThrow('token is required');
});
it('should throw an error if deviceId is not provided', async () => {
await expect(
deviceAPI.getInfoDevice({token: 'token', deviceId: ''}),
).rejects.toThrow('deviceId is required');
});
it('should return an error if it fails to request stop information', async () => {
mockAdapter
.onPost(`${BASE_URL}/api/dispositivos/getInfoDevice/`)
.reply(400, {
error: 'error',
});
await expect(
deviceAPI.getInfoDevice({
token: 'token',
deviceId: 'deviceId',
}),
).rejects.toThrow('Request failed with status code 400');
});
});
});

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,6 @@
interface AuthRequest {
username: string;
password: string;
}
export default AuthRequest;

View File

@ -0,0 +1,5 @@
interface AuthResponse {
token: string;
}
export default AuthResponse;

View File

@ -0,0 +1,13 @@
import Llegada from './Llegada';
interface DetalleLinea {
Linea: string;
Descripcion: string;
TipoLocomocion: number;
colorFondo: string;
colorLetra: string;
Llegadas: Llegada[];
Mensajelinea: string;
}
export default DetalleLinea;

View File

@ -0,0 +1,8 @@
import LineDetail from './LineDetail';
interface DeviceInfoResponse {
stopMessage: string;
lineDetails: LineDetail[];
}
export default DeviceInfoResponse;

View File

@ -0,0 +1,6 @@
interface DeviceRequest {
token: string;
deviceId: string;
}
export default DeviceRequest;

View File

@ -0,0 +1,8 @@
import DetalleLinea from './DetalleLinea';
export interface GetInfoDeviceResponse {
GetInfoDeviceResponse: {
DetalleLineas: DetalleLinea[];
MensajeParadero: string;
};
}

View File

@ -0,0 +1,8 @@
interface Llegada {
Patente: string;
Planificada: string;
EstimadaGPS: string;
DistanciaGPS: string;
}
export default Llegada;

View File

@ -0,0 +1,8 @@
interface WhoamiResponse {
WhoamiResponse: {
NroParadero: string;
NombreParadero: string;
};
}
export default WhoamiResponse;

View File

@ -0,0 +1,6 @@
const BASE_URL = 'https://transporte.hz.kursor.cl';
const LOGIN_METHOD = '/api/auth/';
const WHOAMI_METHOD = '/api/dispositivos/whoami/';
const INFO_DEVICE_METHOD = '/api/dispositivos/getInfoDevice/';
export {BASE_URL, LOGIN_METHOD, WHOAMI_METHOD, INFO_DEVICE_METHOD};

View File

@ -0,0 +1,13 @@
describe('useDevices tests', () => {
it('should be defined', () => {
expect(true).toBeTruthy();
});
it('should return stop information', () => {
expect(true).toBeTruthy();
});
it('should return a list of devices', () => {
expect(true).toBeTruthy();
});
});

View File

@ -0,0 +1,9 @@
describe('useLogin tests', () => {
it('should be defined', () => {
expect(true).toBeTruthy();
});
it('should return a token', () => {
expect(true).toBeTruthy();
});
});

View File

@ -0,0 +1,212 @@
import {useCallback, useEffect, useMemo, useState} from 'react';
import DevicesRepositoryImpl from '../repositories/DevicesRepositoryImpl';
import DevicesAPI from '../api/clients/DevicesAPI';
import AuthRepositoryImpl from '../repositories/AuthRepositoryImpl';
import AuthAPI from '../api/clients/AuthAPI';
import LineDetail from '../../domain/repositories/LineDetail';
import {Line} from '../../presentation/screens/BusStopInfoScreen';
import BusStopInfoService from '../../domain/services/BusStopInfoService';
export enum Status {
LOADING = 'LOADING',
SUCCESS = 'SUCCESS',
ERROR = 'ERROR',
}
interface State {
status: Status;
lines: LineDetail[];
displayedLines: Line[];
currentIndex: number;
stopMessage: string;
stopName: string;
}
const DEVICE_ID = 'TTM543870hyt';
const BASE_URL = 'https://transporte.hz.kursor.cl';
const USER = 'usuario1';
const PASSWORD = 'usuario1';
const useDevices = () => {
const [state, setState] = useState<State>({
status: Status.LOADING,
currentIndex: 0,
stopMessage: '',
displayedLines: [],
lines: [],
stopName: 'Sin información - Sin información',
});
const baseUrl = BASE_URL; // TODO: remoteconfig ?
const username = USER;
const password = PASSWORD;
const deviceApi = useMemo(() => new DevicesAPI(baseUrl), [baseUrl]);
const authApi = useMemo(() => new AuthAPI(baseUrl), [baseUrl]);
const devicesRepository = useMemo(
() => new DevicesRepositoryImpl(deviceApi),
[deviceApi],
);
const authRepository = useMemo(
() => new AuthRepositoryImpl(authApi),
[authApi],
);
const setDisplayedLines = useCallback(
(lineDetails: LineDetail[], stopMessage: string, stopName: string) => {
if (!lineDetails || !stopName) {
return;
}
try {
let busStopInfoService = new BusStopInfoService(lineDetails);
const linesWithArrivals = lineDetails
.map(line => {
try {
busStopInfoService.getNextArraival(line.lineNumber);
return line;
} catch (error) {
return undefined;
}
})
.filter((line): line is LineDetail => line !== undefined);
busStopInfoService = new BusStopInfoService(linesWithArrivals);
const linesToDisplay: Line[] = busStopInfoService
.pruneBusList(state.currentIndex, 21) // result 7 * 3
.map(line => {
try {
const nextArraival = busStopInfoService.getNextArraival(
line.lineNumber,
);
const estimatedArrivalTimeInMinutes =
busStopInfoService.checkArraivalTime(nextArraival);
const lineLetter =
busStopInfoService.getLineLetterFromDescription(
line.description,
);
const lineNumber =
busStopInfoService.getLineNumberFromDescription(
line.description,
);
const lineDescription = busStopInfoService.getLineDescription(
line.description,
line.lineNumber,
);
return {
backgroundColor: line.backgroundColor,
estimatedArrivalTimeInMinutes,
letterColor: line.letterColor,
lineLetter,
lineNumber,
lineDescription,
};
} catch (error) {
return undefined;
}
})
.filter((line): line is Line => line !== undefined);
setState(prevState => {
const displayedLines =
linesToDisplay.length === 0
? prevState.displayedLines
: linesToDisplay;
return {
...prevState,
displayedLines,
stopMessage,
stopName,
lines: lineDetails,
};
});
} catch (error: unknown) {
setState(prevState => ({
...prevState,
status: Status.ERROR,
}));
}
},
[state.currentIndex],
);
useEffect(() => {
const init = async () => {
try {
const token = await authRepository.auth({username, password});
const {lineDetails, stopMessage} =
await devicesRepository.getDeviceInfo({
deviceId: DEVICE_ID,
token,
});
const {stopName} = await devicesRepository.whoAmI({
deviceId: DEVICE_ID,
token,
});
if (!lineDetails) {
setState((prevState: State) => ({
...prevState,
status: Status.ERROR,
}));
return;
}
setState((prevState: State) => ({
...prevState,
...{
lines: lineDetails,
stopMessage,
stopName,
status: Status.SUCCESS,
},
}));
} catch (error) {
setState((prevState: State) => ({
...prevState,
status: Status.ERROR,
}));
}
};
init();
}, [authRepository, devicesRepository, password, username]);
useEffect(() => {
setDisplayedLines(state.lines, state.stopMessage, state.stopName);
}, [setDisplayedLines, state.lines, state.stopMessage, state.stopName]);
useEffect(() => {
const interval = setInterval(() => {
setState(prevState => {
const isGreatherThanLinesLength =
(prevState.currentIndex || 1) + 21 >= state.lines.length;
return {
...prevState,
currentIndex: isGreatherThanLinesLength
? 0
: (prevState.currentIndex || 1 + 21) % state.lines.length,
};
});
}, 5000);
return () => {
clearInterval(interval);
};
}, [state.lines, state.status]);
return {state};
};
export default useDevices;

View File

@ -0,0 +1,19 @@
import AuthRepository from '../../domain/repositories/AuthRepository';
import AuthAPI from '../api/clients/AuthAPI';
import AuthRequest from '../api/models/AuthRequest';
class AuthRepositoryImpl implements AuthRepository {
private authAPI: AuthAPI;
constructor(authAPI: AuthAPI) {
this.authAPI = authAPI;
}
async auth(request: AuthRequest): Promise<string> {
const {token} = await this.authAPI.auth(request);
return token;
}
}
export default AuthRepositoryImpl;

View File

@ -0,0 +1,29 @@
import DevicesRepository from '../../domain/repositories/DevicesRepository';
import DevicesAPI from '../api/clients/DevicesAPI';
import DeviceInfoResponse from '../api/models/DeviceInfoResponse';
import toDeviceInfoResponse from './mappers/toDeviceInfoResponse';
import WhoAmIResponse from '../../domain/repositories/WhoAmIResponse';
import DeviceRequest from '../api/models/DeviceRequest';
import toWhoAmIResponse from './mappers/toWhoAmIResponse';
class DevicesRepositoryImpl implements DevicesRepository {
private devicesAPI: DevicesAPI;
constructor(devicesAPI: DevicesAPI) {
this.devicesAPI = devicesAPI;
}
async whoAmI(request: DeviceRequest): Promise<WhoAmIResponse> {
const response = await this.devicesAPI.whoami(request);
return toWhoAmIResponse(response);
}
async getDeviceInfo(request: DeviceRequest): Promise<DeviceInfoResponse> {
const response = await this.devicesAPI.getInfoDevice(request);
return toDeviceInfoResponse(response);
}
}
export default DevicesRepositoryImpl;

View File

@ -0,0 +1,24 @@
import AuthAPI from '../../api/clients/AuthAPI';
import AuthRepositoryImpl from '../AuthRepositoryImpl';
jest.mock('../../api/clients/AuthAPI');
describe('AuthRepositoryImpl tests', () => {
it('should be defined', () => {
expect(AuthRepositoryImpl).toBeDefined();
});
it('should return a token', async () => {
const mockedAuthAPI = new AuthAPI(
'https://api.example.com',
) as jest.Mocked<AuthAPI>;
mockedAuthAPI.auth.mockResolvedValue({token: 'TOKEN'});
const repo = new AuthRepositoryImpl(mockedAuthAPI);
const token = await repo.auth({username: 'username', password: 'password'});
expect(token).toBe('TOKEN');
});
});

View File

@ -0,0 +1,93 @@
import DevicesRepositoryImpl from '../DevicesRepositoryImpl';
import DevicesAPI from '../../api/clients/DevicesAPI';
import DeviceInfoResponse from '../../api/models/DeviceInfoResponse';
import LineDetail from '../../../domain/repositories/LineDetail';
jest.mock('../../api/clients/DevicesAPI');
describe('DevicesRepositoryImpl tests', () => {
it('should be defined', () => {
expect(DevicesRepositoryImpl).toBeDefined();
});
it('should return a whoami response', async () => {
const mockedDevicesAPI = new DevicesAPI(
'https://api.example.com',
) as jest.Mocked<DevicesAPI>;
mockedDevicesAPI.whoami.mockResolvedValue({
WhoamiResponse: {NombreParadero: 'stopName', NroParadero: 'stopNumber'},
});
const repo = new DevicesRepositoryImpl(mockedDevicesAPI);
const whoami = await repo.whoAmI({
token: 'token',
deviceId: 'deviceId',
});
expect(whoami).toMatchObject({
stopNumber: 'stopNumber',
stopName: 'stopName',
});
});
it('should return a device info response', async () => {
const mockedDevicesAPI = new DevicesAPI(
'https://api.example.com',
) as jest.Mocked<DevicesAPI>;
mockedDevicesAPI.getInfoDevice.mockResolvedValue({
GetInfoDeviceResponse: {
MensajeParadero: 'stopMessage',
DetalleLineas: [
{
colorFondo: 'colorFondo',
colorLetra: 'colorLetra',
Descripcion: 'Descripcion',
Linea: 'Linea',
Llegadas: [
{
DistanciaGPS: 'DistanciaGPS',
EstimadaGPS: 'EstimadaGPS',
Patente: 'Patente',
Planificada: 'Planificada',
},
],
Mensajelinea: 'Mensajelinea',
TipoLocomocion: 0,
},
],
},
});
const repo = new DevicesRepositoryImpl(mockedDevicesAPI);
const deviceInfo = await repo.getDeviceInfo({
token: 'token',
deviceId: 'deviceId',
});
expect(deviceInfo).toMatchObject({
lineDetails: [
{
arrivals: [
{
carPlate: 'Patente',
distanceGPS: 'DistanciaGPS',
estimatedGPS: 'EstimadaGPS',
planned: 'Planificada',
},
],
backgroundColor: 'colorFondo',
description: 'Descripcion',
letterColor: 'colorLetra',
lineMessage: 'Mensajelinea',
lineNumber: 'Linea',
locomotionType: 0,
} as LineDetail,
],
stopMessage: 'stopMessage',
} as DeviceInfoResponse);
});
});

View File

@ -0,0 +1,136 @@
import {GetInfoDeviceResponse} from '../../../api/models/GetInfoDeviceResponse';
import toDeviceInfoResponse from '../toDeviceInfoResponse';
describe('toDeviceInfoResponse tests', () => {
it('should be defined', () => {});
it('should map to DeviceInfoResponse', () => {
const responseFromBackEnd: GetInfoDeviceResponse = {
GetInfoDeviceResponse: {
DetalleLineas: [
{
Linea: '803010',
Descripcion: 'Tucapel',
TipoLocomocion: 1,
colorFondo: 'Hexadecimal',
colorLetra: 'Hexadecimal',
Llegadas: [
{
Patente: 'RPDA-98',
Planificada: '',
EstimadaGPS: '15:08',
DistanciaGPS: '1.0 KM',
},
{
Patente: 'WYXYZ-22',
Planificada: '',
EstimadaGPS: '15:42',
DistanciaGPS: '5.0 KM',
},
{
Patente: 'ABCA-65',
Planificada: '',
EstimadaGPS: '16:18',
DistanciaGPS: '13.4 KM',
},
],
Mensajelinea: '',
},
{
Linea: '5487',
Descripcion: 'Centauro',
TipoLocomocion: 1,
colorFondo: 'Hexadecimal',
colorLetra: 'Hexadecimal',
Llegadas: [
{
Patente: 'PLKJ-32',
Planificada: '15:13',
EstimadaGPS: '',
DistanciaGPS: '',
},
{
Patente: 'GHLK-11',
Planificada: '15:39',
EstimadaGPS: '',
DistanciaGPS: '',
},
{
Patente: 'DFQW-55',
Planificada: '16:22',
EstimadaGPS: '',
DistanciaGPS: '',
},
],
Mensajelinea: 'Sin info. GPS, la informacion es estimada',
},
],
MensajeParadero: 'No considerar, uso futuro',
},
};
const mappedResponse = toDeviceInfoResponse(responseFromBackEnd);
expect(mappedResponse).toMatchObject({
lineDetails: [
{
lineNumber: '803010',
description: 'Tucapel',
locomotionType: 1,
backgroundColor: 'Hexadecimal',
letterColor: 'Hexadecimal',
lineMessage: '',
arrivals: [
{
carPlate: 'RPDA-98',
planned: '',
estimatedGPS: '15:08',
distanceGPS: '1.0 KM',
},
{
carPlate: 'WYXYZ-22',
planned: '',
estimatedGPS: '15:42',
distanceGPS: '5.0 KM',
},
{
carPlate: 'ABCA-65',
planned: '',
estimatedGPS: '16:18',
distanceGPS: '13.4 KM',
},
],
},
{
lineNumber: '5487',
description: 'Centauro',
locomotionType: 1,
backgroundColor: 'Hexadecimal',
letterColor: 'Hexadecimal',
lineMessage: 'Sin info. GPS, la informacion es estimada',
arrivals: [
{
carPlate: 'PLKJ-32',
planned: '15:13',
estimatedGPS: '',
distanceGPS: '',
},
{
carPlate: 'GHLK-11',
planned: '15:39',
estimatedGPS: '',
distanceGPS: '',
},
{
carPlate: 'DFQW-55',
planned: '16:22',
estimatedGPS: '',
distanceGPS: '',
},
],
},
],
stopMessage: 'No considerar, uso futuro',
});
});
});

View File

@ -0,0 +1,21 @@
import toWhoAmIResponse from '../toWhoAmIResponse';
describe('whoamiMapper', () => {
it('should be defined', () => {
expect(toWhoAmIResponse).toBeDefined();
});
it('should map to WhoamIResponse', () => {
expect(
toWhoAmIResponse({
WhoamiResponse: {
NroParadero: '37477',
NombreParadero: "O'Higgins - entre Angol y Salas",
},
}),
).toMatchObject({
stopNumber: '37477',
stopName: "O'Higgins - entre Angol y Salas",
});
});
});

View File

@ -0,0 +1,52 @@
import Arrival from '../../../domain/repositories/Arrival';
import LineDetail from '../../../domain/repositories/LineDetail';
import DeviceInfoResponse from '../../api/models/DeviceInfoResponse';
import {GetInfoDeviceResponse} from '../../api/models/GetInfoDeviceResponse';
const toDeviceInfoResponse = (
response: GetInfoDeviceResponse,
): DeviceInfoResponse => {
const {
GetInfoDeviceResponse: {DetalleLineas, MensajeParadero},
} = response;
const mappedLines: LineDetail[] = DetalleLineas.map(linea => {
const {
Linea,
Descripcion,
TipoLocomocion,
colorFondo,
colorLetra,
Mensajelinea,
Llegadas,
} = linea;
const mappedArrivals: Arrival[] = Llegadas.map(llegada => {
const {Patente, Planificada, EstimadaGPS, DistanciaGPS} = llegada;
return {
carPlate: Patente,
planned: Planificada,
estimatedGPS: EstimadaGPS,
distanceGPS: DistanciaGPS,
};
});
return {
lineNumber: Linea,
description: Descripcion,
locomotionType: TipoLocomocion,
backgroundColor: colorFondo,
letterColor: colorLetra,
lineMessage: Mensajelinea,
arrivals: mappedArrivals,
};
});
return {
lineDetails: mappedLines,
stopMessage: MensajeParadero,
};
};
export default toDeviceInfoResponse;

View File

@ -0,0 +1,13 @@
import WhoAmIResponse from '../../../domain/repositories/WhoAmIResponse';
import WhoamiResponse from '../../api/models/WhoamiResponse';
const toWhoAmIResponse = (response: WhoamiResponse): WhoAmIResponse => {
const {NroParadero, NombreParadero} = response.WhoamiResponse;
return {
stopNumber: NroParadero,
stopName: NombreParadero,
};
};
export default toWhoAmIResponse;

View File

@ -0,0 +1,27 @@
import {View, Text, StyleSheet, StyleProp, ViewStyle} from 'react-native';
interface BannerProps {
text: string;
style?: StyleProp<ViewStyle>;
}
const Banner = ({text, style}: BannerProps) => {
return (
<View style={StyleSheet.compose(styles.bannerContainer, style)}>
<Text style={styles.bannerText}>{text}</Text>
</View>
);
};
const styles = StyleSheet.create({
bannerContainer: {
justifyContent: 'center',
paddingLeft: 8,
},
bannerText: {
color: 'white',
fontWeight: 'bold',
},
});
export default Banner;

View File

@ -0,0 +1,21 @@
import {ReactNode} from 'react';
import {View, StyleSheet, StyleProp, ViewStyle} from 'react-native';
interface ContainerProps {
children: ReactNode;
style?: StyleProp<ViewStyle>;
}
const Container = ({children, style}: ContainerProps) => {
return (
<View style={StyleSheet.compose(styles.container, style)}>{children}</View>
);
};
const styles = StyleSheet.create({
container: {
flex: 1,
},
});
export default Container;

View File

@ -0,0 +1,66 @@
import {View, Text, StyleSheet, StyleProp, ViewStyle} from 'react-native';
import Container from './Container';
import Icon from 'react-native-vector-icons/MaterialCommunityIcons';
interface HeaderProps {
title?: string;
subTitle?: string;
style?: StyleProp<ViewStyle>;
}
const Header = ({title, subTitle, style}: HeaderProps) => {
return (
<Container style={style}>
<View style={styles.contentContainer}>
<View style={styles.iconContainer}>
<Icon name="bus" size={70} color={'white'} />
</View>
<View style={styles.titleContainer}>
<Text style={styles.title}>{title}</Text>
{subTitle && <Text style={styles.subTitle}>{subTitle}</Text>}
</View>
</View>
</Container>
);
};
const defaultProps = {
title: 'Sin información',
subTitle: 'Sin información',
};
Header.defaultProps = defaultProps;
const styles = StyleSheet.create({
container: {
backgroundColor: 'orange',
},
contentContainer: {
flex: 1,
flexDirection: 'row',
alignItems: 'stretch',
},
iconContainer: {
justifyContent: 'center',
alignContent: 'center',
overflow: 'hidden',
},
titleContainer: {
flex: 1,
flexDirection: 'column',
justifyContent: 'center',
alignItems: 'center',
marginRight: 16,
},
title: {
fontSize: 24,
fontWeight: 'bold',
color: 'white',
},
subTitle: {
fontSize: 16,
fontWeight: 'normal',
color: 'white',
},
});
export default Header;

View File

@ -0,0 +1,20 @@
import {render} from '@testing-library/react-native';
import Banner from '../Banner';
describe('Banner tests', () => {
it('should be defined', () => {
expect(Banner).toBeDefined();
});
it('should render correctly', () => {
const {toJSON} = render(<Banner text="text" />);
expect(toJSON()).toMatchSnapshot();
});
it('should get text correctly', () => {
const {getByText} = render(<Banner text="text" />);
expect(getByText('text')).toBeDefined();
});
});

View File

@ -0,0 +1,19 @@
import {render} from '@testing-library/react-native';
import Container from '../Container';
import {Text} from 'react-native';
describe('Container tests', () => {
it('should be defined', () => {
expect(Container).toBeDefined();
});
it('should render correctly', () => {
const {toJSON} = render(
<Container>
<Text>Test</Text>
</Container>,
);
expect(toJSON()).toMatchSnapshot();
});
});

View File

@ -0,0 +1,21 @@
import {render} from '@testing-library/react-native';
import Header from '../Header';
describe('Header tests', () => {
it('should be defined', () => {
expect(Header).toBeDefined();
});
it('should render correctly', () => {
const {toJSON} = render(<Header title="Title" subTitle="Subtitle" />);
expect(toJSON()).toMatchSnapshot();
});
it('should render a title and a subtitle', () => {
const {getByText} = render(<Header title="Title" subTitle="Subtitle" />);
expect(getByText('Title')).toBeDefined();
expect(getByText('Subtitle')).toBeDefined();
});
});

View File

@ -0,0 +1,23 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Banner tests should render correctly 1`] = `
<View
style={
{
"justifyContent": "center",
"paddingLeft": 8,
}
}
>
<Text
style={
{
"color": "white",
"fontWeight": "bold",
}
}
>
text
</Text>
</View>
`;

View File

@ -0,0 +1,15 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Container tests should render correctly 1`] = `
<View
style={
{
"flex": 1,
}
}
>
<Text>
Test
</Text>
</View>
`;

View File

@ -0,0 +1,87 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Header tests should render correctly 1`] = `
<View
style={
{
"flex": 1,
}
}
>
<View
style={
{
"alignItems": "stretch",
"flex": 1,
"flexDirection": "row",
}
}
>
<View
style={
{
"alignContent": "center",
"justifyContent": "center",
"overflow": "hidden",
}
}
>
<Text
allowFontScaling={false}
selectable={false}
style={
[
{
"color": "white",
"fontSize": 70,
},
undefined,
{
"fontFamily": "Material Design Icons",
"fontStyle": "normal",
"fontWeight": "normal",
},
{},
]
}
>
󰃧
</Text>
</View>
<View
style={
{
"alignItems": "center",
"flex": 1,
"flexDirection": "column",
"justifyContent": "center",
"marginRight": 16,
}
}
>
<Text
style={
{
"color": "white",
"fontSize": 24,
"fontWeight": "bold",
}
}
>
Title
</Text>
<Text
style={
{
"color": "white",
"fontSize": 16,
"fontWeight": "normal",
}
}
>
Subtitle
</Text>
</View>
</View>
</View>
`;

View File

@ -0,0 +1,18 @@
import {SafeAreaView, StyleSheet} from 'react-native';
import BusStopInfoScreen from './BusStopInfoScreen';
const App = () => {
return (
<SafeAreaView style={styles.container}>
<BusStopInfoScreen />
</SafeAreaView>
);
};
const styles = StyleSheet.create({
container: {
flex: 1,
},
});
export default App;

View File

@ -0,0 +1,197 @@
import {ActivityIndicator, StyleSheet, View} from 'react-native';
import Container from '../components/Container';
import Header from '../components/Header';
import {Text} from 'react-native';
import useDevices, {Status} from '../../infraestructure/hooks/useDevices';
import Banner from '../components/Banner';
export interface Line {
lineNumber: string;
lineLetter: string;
letterColor: string;
backgroundColor: string;
estimatedArrivalTimeInMinutes: string;
lineDescription: string;
}
interface TableProps {
data: Line[];
}
const Table = ({data}: TableProps) => {
const rows = 7;
const columns = 3;
const baseTableData: Line[][] = Array.from({length: rows}, () =>
new Array(columns).fill({
lineNumber: '',
lineLetter: '',
letterColor: '',
backgroundColor: '',
estimatedArrivalTimeInMinutes: '',
}),
);
data.map((item, index) => {
const row = Math.floor(index / columns);
const column = index % columns;
baseTableData[row][column] = item;
});
return (
<View style={tableStyles.table}>
{baseTableData.map((row, rowIndex) => (
<View key={rowIndex} style={tableStyles.row}>
{row.map((cell, cellIndex) => {
if (cell.lineNumber === '') {
return <View style={tableStyles.cell} key={cellIndex} />;
}
return (
<View style={[tableStyles.cell]} key={cellIndex}>
<View style={tableStyles.lineInformationContainer}>
<Text style={tableStyles.lineNumber}>{cell.lineNumber}</Text>
<View
style={[
tableStyles.letterContainer,
{backgroundColor: `#${cell.backgroundColor}` || 'grey'},
]}>
<Text style={tableStyles.lineLetter}>
{cell.lineLetter}
</Text>
</View>
</View>
<View
style={[
tableStyles.timeContainer,
{backgroundColor: `#${cell.backgroundColor}` || 'grey'},
]}>
<Text style={tableStyles.time} numberOfLines={1}>
{cell.lineDescription}
</Text>
<Text style={tableStyles.time}>
{cell.estimatedArrivalTimeInMinutes}
</Text>
</View>
</View>
);
})}
</View>
))}
</View>
);
};
const BusStopInfoScreen = () => {
const {
state: {status, displayedLines, stopName},
} = useDevices();
const splitStopName = stopName.split('-');
let title = splitStopName[0] ? `${splitStopName[0].trim()}` : '';
const subTitle = splitStopName[1] ? `${splitStopName[1].trim()}` : '';
return (
<Container style={styles.container}>
<Header
style={styles.headerContainer}
subTitle={subTitle}
title={title}
/>
<Banner
style={styles.bannerContainer}
text="Buses que se detienen en esta parada"
/>
<View style={styles.bodyContainer}>
{status === Status.LOADING ? (
<ActivityIndicator />
) : (
<Table data={displayedLines} />
)}
</View>
<View style={styles.footerContainer} />
</Container>
);
};
const styles = StyleSheet.create({
container: {
justifyContent: 'center',
},
headerContainer: {
flex: 1.5,
backgroundColor: 'orange',
},
bannerContainer: {
flex: 0.5,
backgroundColor: 'grey',
},
bodyContainer: {
flex: 10,
justifyContent: 'center',
},
busContainer: {
backgroundColor: 'brown',
flexDirection: 'column',
},
footerContainer: {
flex: 1,
backgroundColor: 'grey',
},
});
const tableStyles = StyleSheet.create({
table: {
flex: 1,
backgroundColor: 'white',
},
row: {
flex: 1,
flexDirection: 'row',
},
cell: {
flex: 1,
borderWidth: 1,
flexDirection: 'column',
borderColor: 'grey',
},
lineInformationContainer: {
flex: 1,
flexDirection: 'row',
justifyContent: 'center',
alignItems: 'center',
},
timeContainer: {
alignItems: 'center',
backgroundColor: 'blue',
},
lineNumber: {
fontSize: 20,
marginRight: 8,
fontWeight: 'bold',
color: 'grey',
},
lineLetter: {
fontSize: 14,
color: 'white',
fontWeight: 'bold',
},
letterContainer: {
paddingHorizontal: 16,
paddingVertical: 8,
borderRadius: 50,
},
time: {
fontSize: 12,
fontWeight: 'bold',
color: 'white',
},
lineDescription: {
fontSize: 12,
fontWeight: 'bold',
color: 'white',
},
});
export default BusStopInfoScreen;

View File

@ -0,0 +1,22 @@
/**
* @param {date} string in format 'HH:mm:ss'
*/
const getHours = (date: string): number => {
return Number.parseInt(date.split(':')[0], 10);
};
/**
* @param {date} string in format 'HH:mm:ss'
*/
const getMinutes = (date: string): number => {
return Number.parseInt(date.split(':')[1], 10);
};
/**
* @param {date} string in format 'HH:mm:ss'
*/
const getSeconds = (date: string): number => {
return Number.parseInt(date.split(':')[2], 10);
};
export {getHours, getMinutes, getSeconds};

View File

@ -0,0 +1,33 @@
import {getHours, getMinutes, getSeconds} from '../DateUtils';
describe('DateUtils tests', () => {
describe('getHours tests', () => {
it('should be defined', () => {
expect(getHours).toBeDefined();
});
it('should return 13', () => {
expect(getHours('13:00:00')).toBe(13);
});
});
describe('getMinutes tests', () => {
it('should be defined', () => {
expect(getMinutes).toBeDefined();
});
it('should return 30', () => {
expect(getMinutes('13:30:00')).toBe(30);
});
});
describe('getSeconds tests', () => {
it('should be defined', () => {
expect(getSeconds).toBeDefined();
});
it('should return 13', () => {
expect(getSeconds('13:30:13')).toBe(13);
});
});
});

1408
yarn.lock

File diff suppressed because it is too large Load Diff