├── .expo-shared
└── assets.json
├── .gitignore
├── App.tsx
├── Readme.md
├── app.json
├── assets
├── fonts
│ └── SpaceMono-Regular.ttf
├── icons
│ ├── Art.tsx
│ ├── Back.tsx
│ ├── BackIcon.tsx
│ ├── Basic.tsx
│ ├── Ellipse.tsx
│ ├── Entertainment.tsx
│ ├── Forward.tsx
│ ├── General.tsx
│ ├── Like.tsx
│ ├── Logo.tsx
│ ├── Search.tsx
│ ├── Secur.tsx
│ ├── Shape.tsx
│ ├── Technology.tsx
│ ├── Unlike.tsx
│ └── index.ts
└── images
│ ├── 1.gif
│ ├── 2.gif
│ ├── 3.gif
│ ├── adaptive-icon.png
│ ├── background.png
│ ├── favicon.png
│ ├── icon.png
│ └── splash.png
├── babel.config.js
├── jest
├── jest.setup.ts
├── mocks
│ ├── handlers.ts
│ └── server.ts
├── setup.js
└── test-utils.tsx
├── metro.config.js
├── package-lock.json
├── package.json
├── src
├── __test__
│ └── LoginScreen.test.tsx
├── app
│ ├── apiSlice.tsx
│ └── store.tsx
├── components
│ ├── AuthenticationButton.tsx
│ ├── AuthenticationInput.tsx
│ ├── CategoryButton.tsx
│ ├── CategorySelectButtons.tsx
│ ├── ListButton.tsx
│ ├── SearchInput.tsx
│ ├── SpinnerHOC.tsx
│ └── index.tsx
├── constants
│ └── index.tsx
├── navigation
│ ├── app.tsx
│ └── index.tsx
├── screens
│ ├── ErrorModal.tsx
│ ├── ListScreen.tsx
│ ├── LogInScreen.tsx
│ ├── PlayerScreen.tsx
│ └── mainSlice.tsx
├── styles
│ └── index.tsx
└── types
│ └── index.tsx
└── tsconfig.json
/.expo-shared/assets.json:
--------------------------------------------------------------------------------
1 | {
2 | "e997a5256149a4b76e6bfd6cbf519c5e5a0f1d278a3d8fa1253022b03c90473b": true,
3 | "af683c96e0ffd2cf81287651c9433fa44debc1220ca7cb431fe482747f34a505": true,
4 | "12bb71342c6255bbf50437ec8f4441c083f47cdb74bd89160c15e4f43e52a1cb": true,
5 | "40b842e832070c58deac6aa9e08fa459302ee3f9da492c7e77d93d2fbf4a56fd": true
6 | }
7 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | node_modules/
2 | .expo/
3 | dist/
4 | npm-debug.*
5 | *.jks
6 | *.p8
7 | *.p12
8 | *.key
9 | *.mobileprovision
10 | *.orig.*
11 | web-build/
12 |
13 | # macOS
14 | .DS_Store
15 |
--------------------------------------------------------------------------------
/App.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import Navigator from "./src/navigation/app";
3 | import store from "./src/app/store";
4 | import { Provider } from "react-redux";
5 | export default function App() {
6 | return (
7 |
8 |
9 |
10 | );
11 | }
12 |
--------------------------------------------------------------------------------
/Readme.md:
--------------------------------------------------------------------------------
1 | # React Native Audio Player
2 |
3 | It is a basic example of an expo-av library which includes many other features.
4 |
5 |
10 |
11 | ## Technologies
12 |
13 | - React Navigation V6
14 | - Redux Toolkit
15 | - RTK Query
16 | - Formik & Yup
17 | - TypeScript
18 | - React Native Testing Library
19 |
20 | ## Features
21 |
22 | - Authentication flow with React Navigation, Redux Toolkit, Expo Secure Store
23 | - Form validation with Formik and Yup
24 | - Api query management with RTK Query
25 | - State Management by Redux Toolkit
26 | - Displaying SVG files in Production Mode
27 | - Testing screens and components with jest&react native testing library (I just commit login screen test but its going to be continue to other screens)
28 |
29 | ## Installation
30 |
31 | `$ npm install`
32 |
33 | #### In case you do not have Expo installed
34 |
35 | `$ npm install --global expo-cli`
36 |
37 | or [please see](https://docs.expo.dev/)
38 |
39 | ## Usage
40 |
41 | `expo start`
42 |
43 | ## Thanks
44 |
45 | The UI design belongs to [Artur Dziuła](https://dribbble.com/arturdz?ref=uistore.design)
46 |
--------------------------------------------------------------------------------
/app.json:
--------------------------------------------------------------------------------
1 | {
2 | "expo": {
3 | "name": "audio-player",
4 | "slug": "audio-player",
5 | "version": "1.0.0",
6 | "orientation": "portrait",
7 | "icon": "./assets/images/icon.png",
8 | "scheme": "myapp",
9 | "userInterfaceStyle": "automatic",
10 | "splash": {
11 | "image": "./assets/images/splash.png",
12 | "resizeMode": "contain",
13 | "backgroundColor": "#09121C"
14 | },
15 | "updates": {
16 | "fallbackToCacheTimeout": 0
17 | },
18 | "assetBundlePatterns": [ "**/*" ],
19 | "ios": {
20 | "supportsTablet": true
21 | },
22 | "android": {
23 | "adaptiveIcon": {
24 | "foregroundImage": "./assets/images/adaptive-icon.png",
25 | "backgroundColor": "#ffffff"
26 | }
27 | },
28 | "web": {
29 | "favicon": "./assets/images/favicon.png"
30 | }
31 | }
32 | }
33 |
--------------------------------------------------------------------------------
/assets/fonts/SpaceMono-Regular.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ahmetfarukekinci/React-Native-Audio-Player/59002136345760572ea7b92627bf4fa93598d8f0/assets/fonts/SpaceMono-Regular.ttf
--------------------------------------------------------------------------------
/assets/icons/Art.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import Svg, { SvgProps, Path } from "react-native-svg";
3 |
4 | const SvgArt = (props: SvgProps) => (
5 |
19 | );
20 |
21 | export default SvgArt;
22 |
--------------------------------------------------------------------------------
/assets/icons/Back.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import Svg, { SvgProps, Path } from "react-native-svg";
3 |
4 | const SvgBack = (props: SvgProps) => (
5 |
19 | );
20 |
21 | export default SvgBack;
22 |
--------------------------------------------------------------------------------
/assets/icons/BackIcon.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import Svg, { SvgProps, Path } from "react-native-svg";
3 |
4 | const SvgBackIcon = (props: SvgProps) => (
5 |
19 | );
20 |
21 | export default SvgBackIcon;
22 |
--------------------------------------------------------------------------------
/assets/icons/Basic.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import Svg, { SvgProps, Path } from "react-native-svg";
3 |
4 | const SvgBasic = (props: SvgProps) => (
5 |
19 | );
20 |
21 | export default SvgBasic;
22 |
--------------------------------------------------------------------------------
/assets/icons/Ellipse.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import Svg, { SvgProps, Circle } from "react-native-svg";
3 |
4 | const SvgEllipse = (props: SvgProps) => (
5 |
14 | );
15 |
16 | export default SvgEllipse;
17 |
--------------------------------------------------------------------------------
/assets/icons/Entertainment.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import Svg, { SvgProps, Path } from "react-native-svg";
3 |
4 | const SvgEntertainment = (props: SvgProps) => (
5 |
19 | );
20 |
21 | export default SvgEntertainment;
22 |
--------------------------------------------------------------------------------
/assets/icons/Forward.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import Svg, { SvgProps, Path } from "react-native-svg";
3 |
4 | const SvgForward = (props: SvgProps) => (
5 |
19 | );
20 |
21 | export default SvgForward;
22 |
--------------------------------------------------------------------------------
/assets/icons/General.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import Svg, { SvgProps, Path } from "react-native-svg";
3 |
4 | const SvgGeneral = (props: SvgProps) => (
5 |
25 | );
26 |
27 | export default SvgGeneral;
28 |
--------------------------------------------------------------------------------
/assets/icons/Like.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import Svg, { SvgProps, Path } from "react-native-svg";
3 |
4 | const SvgLike = (props: SvgProps) => (
5 |
19 | );
20 |
21 | export default SvgLike;
22 |
--------------------------------------------------------------------------------
/assets/icons/Logo.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import Svg, {
3 | SvgProps,
4 | Path,
5 | G,
6 | Defs,
7 | LinearGradient,
8 | Stop,
9 | } from "react-native-svg";
10 |
11 | const SvgLogo = (props: SvgProps) => (
12 |
59 | );
60 |
61 | export default SvgLogo;
62 |
--------------------------------------------------------------------------------
/assets/icons/Search.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import Svg, { SvgProps, Path } from "react-native-svg";
3 |
4 | const SvgSearch = (props: SvgProps) => (
5 |
19 | );
20 |
21 | export default SvgSearch;
22 |
--------------------------------------------------------------------------------
/assets/icons/Secur.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import Svg, { SvgProps, Path } from "react-native-svg";
3 |
4 | const SvgSecur = (props: SvgProps) => (
5 |
19 | );
20 |
21 | export default SvgSecur;
22 |
--------------------------------------------------------------------------------
/assets/icons/Shape.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import Svg, { SvgProps, Path } from "react-native-svg";
3 |
4 | const SvgShape = (props: SvgProps) => (
5 |
18 | );
19 |
20 | export default SvgShape;
21 |
--------------------------------------------------------------------------------
/assets/icons/Technology.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import Svg, { SvgProps, Path } from "react-native-svg";
3 |
4 | const SvgTechnology = (props: SvgProps) => (
5 |
19 | );
20 |
21 | export default SvgTechnology;
22 |
--------------------------------------------------------------------------------
/assets/icons/Unlike.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import Svg, { SvgProps, Path } from "react-native-svg";
3 |
4 | const SvgUnlike = (props: SvgProps) => (
5 |
19 | );
20 |
21 | export default SvgUnlike;
22 |
--------------------------------------------------------------------------------
/assets/icons/index.ts:
--------------------------------------------------------------------------------
1 | export { default as Back } from "./Back";
2 | export { default as BackIcon } from "./BackIcon";
3 | export { default as Basic } from "./Basic";
4 | export { default as Ellipse } from "./Ellipse";
5 | export { default as Forward } from "./Forward";
6 | export { default as Like } from "./Like";
7 | export { default as Logo } from "./Logo";
8 | export { default as Secur } from "./Secur";
9 | export { default as Shape } from "./Shape";
10 | export { default as Unlike } from "./Unlike";
11 | export { default as Art } from "./Art";
12 | export { default as Entertainment } from "./Entertainment";
13 | export { default as General } from "./General";
14 | export { default as Search } from "./Search";
15 | export { default as Technology } from "./Technology";
16 |
--------------------------------------------------------------------------------
/assets/images/1.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ahmetfarukekinci/React-Native-Audio-Player/59002136345760572ea7b92627bf4fa93598d8f0/assets/images/1.gif
--------------------------------------------------------------------------------
/assets/images/2.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ahmetfarukekinci/React-Native-Audio-Player/59002136345760572ea7b92627bf4fa93598d8f0/assets/images/2.gif
--------------------------------------------------------------------------------
/assets/images/3.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ahmetfarukekinci/React-Native-Audio-Player/59002136345760572ea7b92627bf4fa93598d8f0/assets/images/3.gif
--------------------------------------------------------------------------------
/assets/images/adaptive-icon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ahmetfarukekinci/React-Native-Audio-Player/59002136345760572ea7b92627bf4fa93598d8f0/assets/images/adaptive-icon.png
--------------------------------------------------------------------------------
/assets/images/background.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ahmetfarukekinci/React-Native-Audio-Player/59002136345760572ea7b92627bf4fa93598d8f0/assets/images/background.png
--------------------------------------------------------------------------------
/assets/images/favicon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ahmetfarukekinci/React-Native-Audio-Player/59002136345760572ea7b92627bf4fa93598d8f0/assets/images/favicon.png
--------------------------------------------------------------------------------
/assets/images/icon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ahmetfarukekinci/React-Native-Audio-Player/59002136345760572ea7b92627bf4fa93598d8f0/assets/images/icon.png
--------------------------------------------------------------------------------
/assets/images/splash.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ahmetfarukekinci/React-Native-Audio-Player/59002136345760572ea7b92627bf4fa93598d8f0/assets/images/splash.png
--------------------------------------------------------------------------------
/babel.config.js:
--------------------------------------------------------------------------------
1 | const tsconfig = require("./tsconfig.json");
2 | let rawAlias = tsconfig.compilerOptions.paths;
3 | let alias = {};
4 |
5 | for (let x in rawAlias) {
6 | alias[x.replace("/*", "")] = rawAlias[x].map((p) => p.replace("/*", ""));
7 | }
8 |
9 | module.exports = function (api) {
10 | api.cache(true);
11 |
12 | return {
13 | presets: ["babel-preset-expo"],
14 | plugins: [
15 | [
16 | "module-resolver",
17 | {
18 | root: ["./"],
19 | extensions: [".ios.js", ".android.js", ".js", ".ts", ".tsx", ".json"],
20 | alias,
21 | },
22 | ],
23 | ],
24 | };
25 | };
26 |
--------------------------------------------------------------------------------
/jest/jest.setup.ts:
--------------------------------------------------------------------------------
1 | import { server } from "./mocks/server";
2 | import "@testing-library/jest-native";
3 | // Establish API mocking before all tests.
4 | //@ts-ignore
5 | // const nodeFetch = require("node-fetch");
6 | // //@ts-ignore
7 | // global.fetch = nodeFetch;
8 | // //@ts-ignore
9 | // global.Request = nodeFetch.Request;
10 |
11 | import fetch, { Headers, Request, Response } from "node-fetch";
12 | import AbortController from "abort-controller";
13 | global.fetch = fetch as any;
14 | global.Headers = Headers as any;
15 | global.Request = Request as any;
16 | global.Response = Response as any;
17 | global.AbortController = AbortController;
18 | beforeAll(() => server.listen({ onUnhandledRequest: "error" }));
19 | afterEach(() => server.resetHandlers());
20 | afterAll(() => server.close());
21 |
22 | process.on("unhandledRejection", (error) => {
23 | // eslint-disable-next-line no-undef
24 | fail(error);
25 | });
26 |
--------------------------------------------------------------------------------
/jest/mocks/handlers.ts:
--------------------------------------------------------------------------------
1 | import { rest } from "msw";
2 | import { IPlayerScreeenParams } from "../../src/navigation/index";
3 | export const handlers = [
4 | rest.post("https://nox-podcast-api.vercel.app/login", (req, res, ctx) => {
5 | return res(ctx.status(500));
6 | }),
7 | rest.get("https://nox-podcast-api.vercel.app/search", (req, res, ctx) => {
8 | return res(
9 | ctx.json([
10 | {
11 | audio_url: "url",
12 | author: "author",
13 | category: "category",
14 | description: "description",
15 | dislikes: 12,
16 | file_size: 123,
17 | likes: 11,
18 | title: "title",
19 | },
20 | ])
21 | );
22 | }),
23 | ];
24 |
--------------------------------------------------------------------------------
/jest/mocks/server.ts:
--------------------------------------------------------------------------------
1 | // src/mocks/server.js
2 | import { setupServer } from "msw/node";
3 | import { handlers } from "./handlers";
4 |
5 | // This configures a request mocking server with the given request handlers.
6 | export const server = setupServer(...handlers);
7 |
--------------------------------------------------------------------------------
/jest/setup.js:
--------------------------------------------------------------------------------
1 | import "react-native-gesture-handler/jestSetup";
2 |
3 | jest.mock("react-native-reanimated", () => {
4 | const Reanimated = require("react-native-reanimated/mock");
5 |
6 | // The mock for `call` immediately calls the callback which is incorrect
7 | // So we override it with a no-op
8 | Reanimated.default.call = () => {};
9 |
10 | return Reanimated;
11 | });
12 |
13 | // Silence the warning: Animated: `useNativeDriver` is not supported because the native animated module is missing
14 | jest.mock("react-native/Libraries/Animated/NativeAnimatedHelper");
15 |
--------------------------------------------------------------------------------
/jest/test-utils.tsx:
--------------------------------------------------------------------------------
1 | import React, { ReactElement } from "react";
2 | import { render, RenderOptions } from "@testing-library/react-native";
3 | import { NavigationContainer } from "@react-navigation/native";
4 | import { Provider } from "react-redux";
5 | import store from "../src/app/store";
6 | type OptionType = RenderOptions | undefined;
7 | interface ProviderArg {
8 | children: {};
9 | }
10 | const AllTheProviders = ({ children }: ProviderArg) => {
11 | return (
12 |
13 | {children}
14 |
15 | );
16 | };
17 |
18 | const customRender = (ui: ReactElement, options?: OptionType) =>
19 | render(ui, { wrapper: AllTheProviders, ...options });
20 |
21 | export * from "@testing-library/react-native";
22 |
23 | export { customRender as render };
24 |
--------------------------------------------------------------------------------
/metro.config.js:
--------------------------------------------------------------------------------
1 | const { getDefaultConfig } = require("expo/metro-config");
2 |
3 | module.exports = (() => {
4 | const config = getDefaultConfig(__dirname);
5 |
6 | const { transformer, resolver } = config;
7 |
8 | config.transformer = {
9 | ...transformer,
10 | babelTransformerPath: require.resolve("react-native-svg-transformer"),
11 | };
12 | config.resolver = {
13 | ...resolver,
14 | assetExts: resolver.assetExts.filter((ext) => ext !== "svg"),
15 | sourceExts: [...resolver.sourceExts, "svg"],
16 | };
17 |
18 | return config;
19 | })();
20 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "main": "node_modules/expo/AppEntry.js",
3 | "scripts": {
4 | "start": "expo start",
5 | "android": "expo start --android",
6 | "ios": "expo start --ios",
7 | "web": "expo start --web",
8 | "eject": "expo eject",
9 | "test": "jest --watchAll"
10 | },
11 | "jest": {
12 | "preset": "jest-expo",
13 | "setupFiles": [
14 | "/jest/setup.js"
15 | ],
16 | "transformIgnorePatterns": [
17 | "node_modules/(?!((jest-)?react-native|@react-native(-community)?)|expo(nent)?|@expo(nent)?/.*|@expo-google-fonts/.*|react-navigation|@react-navigation/.*|@unimodules/.*|unimodules|sentry-expo|native-base|react-native-svg/.*|react-native-svg-transformer/.*)"
18 | ],
19 | "setupFilesAfterEnv": [
20 | "./jest/jest.setup.ts",
21 | "@testing-library/jest-native/extend-expect"
22 | ],
23 | "transform": {
24 | "^.+\\.[jt]sx?$": "babel-jest"
25 | }
26 | },
27 | "dependencies": {
28 | "@expo/vector-icons": "^13.0.0",
29 | "@react-native-community/slider": "4.2.1",
30 | "@react-navigation/native": "^6.0.11",
31 | "@react-navigation/native-stack": "^6.7.0",
32 | "@reduxjs/toolkit": "^1.8.3",
33 | "@types/react-redux": "^7.1.24",
34 | "babel-plugin-module-resolver": "^4.1.0",
35 | "expo": "^45.0.6",
36 | "expo-app-loading": "~2.0.0",
37 | "expo-asset": "~8.5.0",
38 | "expo-av": "~11.2.3",
39 | "expo-constants": "~13.1.1",
40 | "expo-font": "~10.1.0",
41 | "expo-linear-gradient": "~11.3.0",
42 | "expo-linking": "~3.1.0",
43 | "expo-secure-store": "~11.2.0",
44 | "expo-splash-screen": "^0.15.1",
45 | "expo-status-bar": "~1.3.0",
46 | "expo-web-browser": "~10.2.1",
47 | "formik": "^2.2.9",
48 | "metro-react-native-babel-preset": "^0.69.1",
49 | "react": "17.0.2",
50 | "react-devtools": "^4.25.0",
51 | "react-dom": "17.0.2",
52 | "react-native": "0.68.2",
53 | "react-native-gesture-handler": "~2.2.1",
54 | "react-native-reanimated": "~2.8.0",
55 | "react-native-responsive-fontsize": "^0.5.1",
56 | "react-native-responsive-screen": "^1.4.2",
57 | "react-native-safe-area-context": "4.2.4",
58 | "react-native-screens": "~3.11.1",
59 | "react-native-svg": "12.3.0",
60 | "react-native-svg-transformer": "^1.0.0",
61 | "react-native-web": "0.17.7",
62 | "react-redux": "^7.2.8",
63 | "yup": "^0.32.11"
64 | },
65 | "devDependencies": {
66 | "@babel/core": "^7.18.6",
67 | "@testing-library/jest-native": "^4.0.5",
68 | "@testing-library/react-native": "^9.1.0",
69 | "@types/jest": "^27.5.2",
70 | "@types/react": "~17.0.21",
71 | "@types/react-native": "^0.67.12",
72 | "jest": "^26.6.3",
73 | "jest-expo": "^45.0.1",
74 | "msw": "^0.39.2",
75 | "react-test-renderer": "17.0.1",
76 | "typescript": "~4.3.5"
77 | },
78 | "private": true
79 | }
80 |
--------------------------------------------------------------------------------
/src/__test__/LoginScreen.test.tsx:
--------------------------------------------------------------------------------
1 | import { render, fireEvent } from "@test";
2 | import { createNativeStackNavigator } from "@react-navigation/native-stack";
3 | import LogInScreen from "src/screens/LogInScreen";
4 | import ErrorModalScreen from "src/screens/ErrorModal";
5 |
6 | describe("Should show error alerts when inputs are not corretly given", () => {
7 | const { Navigator, Screen, Group } = createNativeStackNavigator();
8 | const Component = () => (
9 |
10 |
11 |
12 |
13 |
14 |
15 | );
16 | test("Should show two error texts when pressing submit button without typing any inputs", async () => {
17 | const { getByRole, findAllByRole } = render();
18 | const button = getByRole("button");
19 | fireEvent.press(button);
20 | const errorTexts = await findAllByRole("alert");
21 | expect(errorTexts).toHaveLength(2);
22 | expect(errorTexts[0]).toHaveTextContent("E-Mail is required");
23 | expect(errorTexts[1]).toHaveTextContent("Password is required");
24 | });
25 | test("Should show error when email format is not correct", async () => {
26 | const { getByRole, getByTestId, findAllByRole } = render();
27 | const emailInput = getByTestId("emailInput");
28 | const button = getByRole("button");
29 | fireEvent.changeText(emailInput, "a.a.a");
30 | fireEvent.press(button);
31 | const alerts = await findAllByRole("alert");
32 | expect(alerts[0]).toHaveTextContent("Please enter valid email");
33 | });
34 | test("Should show error when password character length is less then 6", async () => {
35 | const { getByRole, getByTestId, findAllByRole } = render();
36 | const passwordInput = getByTestId("passwordInput");
37 | const button = getByRole("button");
38 | fireEvent.changeText(passwordInput, "12345");
39 | fireEvent.press(button);
40 | const alerts = await findAllByRole("alert");
41 | expect(alerts[1]).toHaveTextContent(
42 | "Password must be at least 6 characters"
43 | );
44 | });
45 | test("Should show error modal when email or password is not correct", async () => {
46 | const { findByRole, getByRole, getByTestId } = render();
47 | const emailInput = getByTestId("emailInput");
48 | const passwordInput = getByTestId("passwordInput");
49 | const button = getByRole("button");
50 | fireEvent.changeText(emailInput, "wrong@mailinput.com");
51 | fireEvent.changeText(passwordInput, "wrongpassword");
52 | fireEvent.press(button);
53 | const errorModal = await findByRole("alert");
54 | expect(errorModal).toHaveTextContent("Something went wrong...");
55 | });
56 | });
57 |
--------------------------------------------------------------------------------
/src/app/apiSlice.tsx:
--------------------------------------------------------------------------------
1 | import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react';
2 | import * as SecureStore from 'expo-secure-store';
3 | export const apiSlice = createApi({
4 | reducerPath: 'api',
5 | baseQuery: fetchBaseQuery({
6 | baseUrl: 'https://nox-podcast-api.vercel.app/',
7 | prepareHeaders: async (headers, { getState }) => {
8 | const token = await SecureStore.getItemAsync('access_token');
9 | if (token) {
10 | headers.set('Authorization', `Bearer ${token}`);
11 | }
12 | return headers;
13 | }
14 | }),
15 |
16 | endpoints: (builder) => ({
17 | logIn: builder.mutation({
18 | query: (data) => {
19 | return {
20 | url: 'login',
21 | method: 'POST',
22 | headers: {
23 | 'Content-Type': 'application/json'
24 | },
25 | body: data
26 | };
27 | }
28 | }),
29 | getPodcastList: builder.mutation({
30 | query: (data: string) => ({
31 | url: `search?${data}`,
32 | method: 'GET'
33 | })
34 | })
35 | })
36 | });
37 |
38 | export const { useLogInMutation, useGetPodcastListMutation } = apiSlice;
39 |
--------------------------------------------------------------------------------
/src/app/store.tsx:
--------------------------------------------------------------------------------
1 | import { configureStore } from "@reduxjs/toolkit";
2 | import main from "@mainSlice";
3 | import { apiSlice } from "./apiSlice";
4 | import { useDispatch } from "react-redux";
5 | const store = configureStore({
6 | reducer: {
7 | main,
8 | [apiSlice.reducerPath]: apiSlice.reducer,
9 | },
10 | middleware: (getDefaultMiddleware) =>
11 | getDefaultMiddleware().concat(apiSlice.middleware),
12 | });
13 |
14 | export type RootState = ReturnType;
15 | export type AppDispatch = typeof store.dispatch;
16 | export const useAppDispatch = () => useDispatch();
17 | export default store;
18 |
--------------------------------------------------------------------------------
/src/components/AuthenticationButton.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import {
3 | StyleSheet,
4 | TouchableOpacity,
5 | TouchableOpacityProps,
6 | Text,
7 | } from "react-native";
8 | import { fs, wp, hp, colors } from "@styles";
9 | interface IAuthenticationButton extends TouchableOpacityProps {
10 | text: string;
11 | }
12 | const AuthenticationButton: React.FC = ({
13 | text,
14 | ...props
15 | }) => {
16 | return (
17 |
18 | {text}
19 |
20 | );
21 | };
22 |
23 | const styles = StyleSheet.create({
24 | container: {
25 | width: wp(276),
26 | height: hp(51),
27 | alignSelf: "center",
28 | justifyContent: "center",
29 | alignItems: "center",
30 | backgroundColor: colors.sliderColor,
31 | borderRadius: fs(99),
32 | shadowColor: "rgb(51, 105, 255)",
33 | shadowOffset: {
34 | width: 0,
35 | height: 0,
36 | },
37 | shadowOpacity: 0.9,
38 | shadowRadius: 20.0,
39 |
40 | elevation: 24,
41 | },
42 | text: {
43 | color: "white",
44 | fontSize: fs(16),
45 | },
46 | });
47 | export { AuthenticationButton };
48 |
--------------------------------------------------------------------------------
/src/components/AuthenticationInput.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import {
3 | TextInput,
4 | TextInputProps,
5 | ViewStyle,
6 | View,
7 | StyleSheet,
8 | StyleProp,
9 | Text,
10 | } from "react-native";
11 | import { fs, wp, hp, colors } from "@styles";
12 | import { Secur, Basic } from "@icons";
13 | type iconType = "mail" | "password";
14 | interface AuthenticationInputProps extends TextInputProps {
15 | iconType: iconType;
16 | style?: StyleProp;
17 | error: string | undefined;
18 | touched: boolean | undefined;
19 | }
20 | const AuthenticationInput: React.FC = ({
21 | style,
22 | iconType,
23 | error,
24 | touched,
25 | ...props
26 | }: AuthenticationInputProps) => {
27 | const icon = iconType === "password" ? : ;
28 | return (
29 |
30 |
31 | {icon}
32 |
37 |
38 | {error && touched && (
39 |
40 | {error}
41 |
42 | )}
43 |
44 | );
45 | };
46 |
47 | const styles = StyleSheet.create({
48 | container: {
49 | width: wp(276),
50 | height: hp(58),
51 | borderWidth: fs(1),
52 | alignSelf: "center",
53 | borderColor: "rgba(255, 255, 255, 0.15)",
54 | borderTopLeftRadius: fs(16),
55 | borderTopRightRadius: fs(16),
56 | borderBottomLeftRadius: fs(16),
57 | paddingLeft: wp(25),
58 | flexDirection: "row",
59 | },
60 | input: {
61 | flex: 1,
62 | fontSize: fs(14),
63 | paddingLeft: wp(25),
64 | color: "#ffff",
65 | },
66 | iconWrapper: {
67 | justifyContent: "center",
68 | alignItems: "center",
69 | },
70 | errorText: {
71 | fontSize: fs(13),
72 | color: colors.white,
73 | marginTop: hp(3),
74 | },
75 | });
76 | export { AuthenticationInput };
77 |
--------------------------------------------------------------------------------
/src/components/CategoryButton.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { StyleSheet, TouchableOpacity, TouchableOpacityProps, Text, View } from "react-native";
3 | import { fs, wp, hp, colors } from "@styles";
4 | interface ICategoryButton extends TouchableOpacityProps {
5 | isActive: boolean;
6 | title: string;
7 | }
8 | const CategoryButton: React.FC = ({ isActive, title, children, ...props }) => {
9 | return (
10 |
11 |
12 |
20 | {children}
21 |
22 |
30 | {title}
31 |
32 |
33 |
34 | );
35 | };
36 |
37 | const styles = StyleSheet.create({
38 | container: {
39 | height: hp(90),
40 | width: wp(80),
41 | justifyContent: "space-between",
42 | alignItems: "center",
43 | },
44 | circle: {
45 | width: fs(56),
46 | height: fs(56),
47 | borderRadius: fs(28),
48 | justifyContent: "center",
49 | alignItems: "center",
50 | },
51 | text: {
52 | color: colors.gray1,
53 | fontSize: fs(12),
54 | },
55 | });
56 | export { CategoryButton };
57 |
--------------------------------------------------------------------------------
/src/components/CategorySelectButtons.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { FlatList, FlatListProps, StyleSheet, View } from "react-native";
3 | import { hp } from "@styles";
4 | import { ICategoryButton } from "@types";
5 | const CategorySelectionButtons = ({
6 | renderItem,
7 | data,
8 | }: FlatListProps) => {
9 | return (
10 |
11 | item.id}
14 | renderItem={renderItem}
15 | horizontal
16 | showsHorizontalScrollIndicator={false}
17 | />
18 |
19 | );
20 | };
21 | const styles = StyleSheet.create({
22 | categoryButtonsWrapper: {
23 | height: hp(173),
24 | width: "100%",
25 | paddingTop: hp(32),
26 | },
27 | });
28 |
29 | export { CategorySelectionButtons };
30 |
--------------------------------------------------------------------------------
/src/components/ListButton.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import {
3 | StyleSheet,
4 | TouchableOpacity,
5 | TouchableOpacityProps,
6 | Text,
7 | } from "react-native";
8 | import { fs, wp, hp, colors } from "@styles";
9 | interface IListButton extends TouchableOpacityProps {
10 | title: string;
11 | author: string;
12 | }
13 | const ListButton: React.FC = ({ title, author, ...props }) => {
14 | return (
15 |
16 | {title}
17 | {author}
18 |
19 | );
20 | };
21 |
22 | const styles = StyleSheet.create({
23 | container: {
24 | width: wp(309),
25 | height: hp(180),
26 | backgroundColor: colors.darkBlue,
27 | borderRadius: fs(24),
28 | borderBottomRightRadius: 0,
29 | paddingTop: hp(25),
30 | paddingHorizontal: wp(30),
31 | marginTop: hp(20),
32 | },
33 | title: {
34 | color: colors.white,
35 | fontSize: fs(24),
36 | },
37 | author: {
38 | color: colors.white,
39 | fontSize: fs(13),
40 | fontWeight: "400",
41 | marginTop: hp(20),
42 | },
43 | });
44 | export { ListButton };
45 |
--------------------------------------------------------------------------------
/src/components/SearchInput.tsx:
--------------------------------------------------------------------------------
1 | import React, { useRef } from "react";
2 | import { TextInput, View, TouchableWithoutFeedback, TextInputProps, StyleSheet } from "react-native";
3 | import { fs, wp, hp, colors } from "@styles";
4 | import { Search } from "@icons";
5 | const SearchInput: React.FC = ({ ...props }) => {
6 | const inputRef = useRef(null);
7 | return (
8 | inputRef.current?.focus()}>
9 |
10 |
21 |
22 |
23 |
24 | );
25 | };
26 | const styles = StyleSheet.create({
27 | container: {
28 | width: wp(312),
29 | height: hp(48),
30 | borderRadius: fs(16),
31 | backgroundColor: colors.darkBlue,
32 | flexDirection: "row",
33 | alignItems: "center",
34 | justifyContent: "space-between",
35 | paddingHorizontal: wp(14),
36 | paddingVertical: hp(11),
37 | opacity: 0.4,
38 | },
39 | input: {
40 | flex: 1,
41 | color: colors.white,
42 | fontSize: fs(18),
43 | },
44 | });
45 |
46 | export { SearchInput };
47 |
--------------------------------------------------------------------------------
/src/components/SpinnerHOC.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { View, StyleSheet, ActivityIndicator } from "react-native";
3 | import { colors } from "@styles";
4 | interface ISpinnerHoc {
5 | loading: boolean;
6 | children: React.ReactChild;
7 | }
8 | const SpinnerHOC =
9 | (WrappedComponent: React.ElementType) =>
10 | ({ loading, children }: ISpinnerHoc) => {
11 | return (
12 |
13 | {children}
14 | {loading && (
15 |
24 |
25 |
26 | )}
27 |
28 | );
29 | };
30 | export { SpinnerHOC };
31 |
--------------------------------------------------------------------------------
/src/components/index.tsx:
--------------------------------------------------------------------------------
1 | export * from "./AuthenticationInput";
2 | export * from "./AuthenticationButton";
3 | export * from "./SpinnerHOC";
4 | export * from "./SearchInput";
5 | export * from "./CategoryButton";
6 | export * from "./ListButton";
7 | export * from "./CategorySelectButtons";
8 |
--------------------------------------------------------------------------------
/src/constants/index.tsx:
--------------------------------------------------------------------------------
1 | import { ICategoryButton } from "@types";
2 | import { Art, Technology, General, Entertainment } from "@icons";
3 | export const CategoryButtonsListData: ICategoryButton[] = [
4 | {
5 | id: "1",
6 | param: "art",
7 | Icon: ,
8 | title: "Art",
9 | },
10 | {
11 | id: "2",
12 | param: "technology",
13 | Icon: ,
14 | title: "Technology",
15 | },
16 | {
17 | id: "3",
18 | param: "general",
19 | Icon: ,
20 | title: "General",
21 | },
22 | {
23 | id: "4",
24 | param: "entertainment",
25 | Icon: ,
26 | title: "Entertainment",
27 | },
28 | ];
29 |
--------------------------------------------------------------------------------
/src/navigation/app.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { NavigationContainer } from "@react-navigation/native";
3 | import { createNativeStackNavigator } from "@react-navigation/native-stack";
4 | import { RootStackParamList } from "./index";
5 | import ListScreen from "../screens/ListScreen";
6 | import LogInScreen from "../screens/LogInScreen";
7 | import PlayerScreen from "../screens/PlayerScreen";
8 | import ErrorModalScreen from "../screens/ErrorModal";
9 | import * as SecureStore from "expo-secure-store";
10 | import AppLoading from "expo-app-loading";
11 | import { useSelector } from "react-redux";
12 | import { RootState, useAppDispatch } from "@store/store";
13 | import { setAccessToken } from "@mainSlice";
14 | const RootStack = createNativeStackNavigator();
15 |
16 | const Navigator = () => {
17 | const dispatch = useAppDispatch();
18 | const [loading, setLoading] = React.useState(true);
19 | const [isTokenValid, setIsTokenValid] = React.useState(false);
20 | const access_token = useSelector(
21 | (state) => state.main.access_token
22 | );
23 | const initAuthenticationControls = () => {
24 | if (access_token !== "") {
25 | setIsTokenValid(true);
26 | setLoading(false);
27 | } else {
28 | checkAccessTokenFromLocalStorage();
29 | }
30 | };
31 | const checkAccessTokenFromLocalStorage = async () => {
32 | const token = await SecureStore.getItemAsync("access_token");
33 | if (token) {
34 | dispatch(setAccessToken(token));
35 | setIsTokenValid(true);
36 | setLoading(false);
37 | } else {
38 | setLoading(false);
39 | setIsTokenValid(false);
40 | }
41 | };
42 | React.useEffect(() => {
43 | initAuthenticationControls();
44 | }, [access_token]);
45 | if (loading) {
46 | return ;
47 | }
48 | return (
49 |
50 |
51 | {!isTokenValid ? (
52 |
53 |
54 |
57 |
61 |
62 |
63 | ) : (
64 |
65 |
66 |
67 |
70 |
74 |
75 |
76 | )}
77 |
78 |
79 | );
80 | };
81 | export default Navigator;
82 |
--------------------------------------------------------------------------------
/src/navigation/index.tsx:
--------------------------------------------------------------------------------
1 | import { NativeStackScreenProps } from '@react-navigation/native-stack';
2 |
3 | declare global {
4 | namespace ReactNavigation { interface RootParamList extends RootStackParamList {} }
5 | }
6 | export interface IPlayerScreeenParams {
7 | audio_url: string;
8 | author: string;
9 | category: string;
10 | description: string;
11 | dislikes: number;
12 | file_size: number;
13 | likes: number;
14 | title: string;
15 | }
16 | export interface IErrorModalScreenParams {
17 | text: string;
18 | }
19 | export type RootStackParamList = {
20 | LogInScreen: undefined;
21 | ListScreen: undefined;
22 | PlayerScreen: IPlayerScreeenParams;
23 | ErrorModalScreen: IErrorModalScreenParams;
24 | };
25 |
26 | export type RootStackScreenProps = NativeStackScreenProps<
27 | RootStackParamList,
28 | Screen
29 | >;
30 |
--------------------------------------------------------------------------------
/src/screens/ErrorModal.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { View, Text, StyleSheet } from "react-native";
3 | import { fs, colors, wp, hp } from "@styles";
4 | import { RootStackScreenProps } from "@navigation";
5 | import { AntDesign } from "@expo/vector-icons";
6 | import { AuthenticationButton as Button } from "@components";
7 | export default function ErrorModalScreen({
8 | navigation,
9 | route: {
10 | params: { text },
11 | },
12 | }: RootStackScreenProps<"ErrorModalScreen">) {
13 | return (
14 |
15 |
16 |
17 | Something went wrong...
18 |
19 | {text}
20 |
22 | );
23 | }
24 | const styles = StyleSheet.create({
25 | container: {
26 | flex: 1,
27 | backgroundColor: colors.background,
28 | alignItems: "center",
29 | justifyContent: "center",
30 | paddingBottom: hp(200),
31 | },
32 | title: {
33 | color: colors.white,
34 | fontSize: fs(30),
35 | fontWeight: "bold",
36 | marginTop: hp(10),
37 | },
38 | content: {
39 | color: colors.gray1,
40 | fontSize: fs(20),
41 | marginTop: hp(30),
42 | marginBottom: hp(50),
43 | width: wp(280),
44 | textAlign: "justify",
45 | },
46 | });
47 |
--------------------------------------------------------------------------------
/src/screens/ListScreen.tsx:
--------------------------------------------------------------------------------
1 | import React, { useState, useEffect, useCallback, useRef } from "react";
2 | import { StyleSheet, Text, View, FlatList } from "react-native";
3 | import { RootStackScreenProps, IPlayerScreeenParams } from "@navigation";
4 | import { SearchInput, CategoryButton, ListButton, SpinnerHOC, CategorySelectionButtons } from "@components";
5 | import { hp, wp, fs, colors } from "@styles";
6 | import { useGetPodcastListMutation } from "@store/apiSlice";
7 | import { debounce } from "lodash";
8 | import { Logo } from "@icons";
9 | import { ICategoryButton, CategoryParamType } from "@types";
10 | import { CategoryButtonsListData } from "@constants";
11 |
12 | const SpinnerView = SpinnerHOC(View);
13 |
14 | export default function ListScreen({ navigation }: RootStackScreenProps<"ListScreen">) {
15 | const [getPodcastList, { isLoading, isSuccess }] = useGetPodcastListMutation();
16 | const [list, setList] = useState([]);
17 | const [query, setQuery] = useState("");
18 | const [activeCategory, setActiveCategory] = useState<{ index: number | undefined; param: CategoryParamType }>({
19 | index: undefined,
20 | param: undefined,
21 | });
22 | const [searchInputText, setSearchInputText] = useState("");
23 | const firstMount = useRef(true);
24 | const flatlistRef = useRef(null);
25 | useEffect(() => {
26 | const promisedGetPodcastList = getPodcastList(query);
27 | promisedGetPodcastList
28 | .unwrap()
29 | .then((data) => {
30 | setList(data);
31 | })
32 | .catch(() => {
33 | navigation.navigate("ErrorModalScreen", {
34 | text: "We could not access the list now. Please try again!",
35 | });
36 | });
37 |
38 | () => {
39 | promisedGetPodcastList.abort();
40 | firstMount.current = false;
41 | };
42 | }, [query]);
43 |
44 | useEffect(() => {
45 | if (!firstMount.current) {
46 | debouncedSearch();
47 | }
48 | firstMount.current = false;
49 | }, [searchInputText, activeCategory]);
50 |
51 | const debouncedSearch = useCallback(
52 | debounce(() => {
53 | activeCategory.param
54 | ? setQuery(`text=${searchInputText}&category=${activeCategory.param}`)
55 | : setQuery(`text=${searchInputText}`);
56 | }, 500),
57 | [searchInputText, activeCategory.index, activeCategory.param]
58 | );
59 |
60 | const categorButtonOnPressed = (index: number, item: ICategoryButton): void => {
61 | if (activeCategory?.index === index) {
62 | setActiveCategory({ index: undefined, param: undefined });
63 | return;
64 | }
65 | setActiveCategory({ index, param: item.param });
66 | };
67 |
68 | return (
69 |
70 |
71 |
72 | flatlistRef.current?.scrollToIndex({ index: 0 })}>
73 | Browse
74 |
75 | setSearchInputText(text)} />
76 | item.title}
80 | scrollsToTop
81 | ListHeaderComponent={
82 |
83 | (
86 | categorButtonOnPressed(index, item)}
90 | >
91 | {item.Icon}
92 |
93 | )}
94 | />
95 | Podcast ({list.length})
96 |
97 | }
98 | renderItem={({ item }) => (
99 | navigation.navigate("PlayerScreen", item)}
103 | />
104 | )}
105 | />
106 |
107 |
108 | );
109 | }
110 |
111 | const styles = StyleSheet.create({
112 | container: {
113 | flex: 1,
114 | backgroundColor: colors.background,
115 | paddingTop: hp(56),
116 | paddingLeft: wp(30),
117 | },
118 | header: {
119 | color: colors.white,
120 | fontSize: fs(48),
121 | fontWeight: "bold",
122 | marginTop: hp(38),
123 | marginBottom: hp(10),
124 | },
125 | listHeader: {
126 | color: colors.gray1,
127 | fontSize: fs(16),
128 | },
129 | });
130 |
--------------------------------------------------------------------------------
/src/screens/LogInScreen.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import {
3 | StyleSheet,
4 | Text,
5 | View,
6 | ImageBackground,
7 | Keyboard,
8 | TouchableWithoutFeedback,
9 | KeyboardAvoidingView,
10 | Platform,
11 | } from "react-native";
12 | import { RootStackScreenProps } from "@navigation";
13 | import {
14 | AuthenticationInput as Input,
15 | AuthenticationButton as Button,
16 | SpinnerHOC,
17 | } from "@components";
18 | import { hp, wp, fs, colors } from "@styles";
19 | import { Formik } from "formik";
20 | import { LinearGradient } from "expo-linear-gradient";
21 | import * as Yup from "yup";
22 | import { useLogInMutation } from "@store/apiSlice";
23 | import * as SecureStore from "expo-secure-store";
24 | import { useAppDispatch } from "../app/store";
25 | import { setAccessToken } from "@mainSlice";
26 | import { Logo } from "@icons";
27 | export interface Values {
28 | email: string;
29 | password: string;
30 | }
31 | const initialValues: Values = { email: "", password: "" };
32 | const validationSchema = Yup.object().shape({
33 | email: Yup.string()
34 | .email("Please enter valid email")
35 | .required("E-Mail is required"),
36 | password: Yup.string()
37 | .min(6, ({ min }) => `Password must be at least ${min} characters`)
38 | .required("Password is required")
39 | .label("Password"),
40 | });
41 |
42 | const SpinnerView = SpinnerHOC(View);
43 |
44 | export default function LogInScreen({
45 | navigation: { navigate },
46 | }: RootStackScreenProps<"LogInScreen">) {
47 | const [logIn, { isLoading }] = useLogInMutation();
48 | const dispatch = useAppDispatch();
49 | const onSubmit = async (values: Values) => {
50 | try {
51 | const response = await logIn(values).unwrap();
52 | console.log("response", response);
53 | const { access_token } = response;
54 | await SecureStore.setItemAsync("access_token", access_token);
55 | dispatch(setAccessToken(access_token));
56 | } catch (error) {
57 | navigate("ErrorModalScreen", { text: "Mail or Password is wrong!" });
58 | }
59 | };
60 | return (
61 | onSubmit(values)}
65 | >
66 | {({ handleChange, handleBlur, handleSubmit, errors, touched }) => {
67 | return (
68 |
69 |
73 |
77 | Keyboard.dismiss()}>
78 |
79 |
89 |
90 |
91 | Episodic series of digital audio.
92 |
93 |
105 |
118 |
125 |
126 |
127 |
128 |
129 |
130 | );
131 | }}
132 |
133 | );
134 | }
135 | const linearGradientColors = [
136 | "rgba(9, 18, 28, 0.9)",
137 | "rgba(9, 18, 28, 1)",
138 | "rgba(9, 18, 28, 0.95)",
139 | ];
140 |
141 | const styles = StyleSheet.create({
142 | container: {
143 | width: wp(342),
144 | height: hp(759),
145 | borderBottomLeftRadius: 0,
146 | borderBottomRightRadius: fs(24),
147 | borderTopLeftRadius: 0,
148 | borderTopRightRadius: 0,
149 | overflow: "hidden",
150 | },
151 | text: {
152 | height: hp(60),
153 | width: wp(195),
154 | fontSize: fs(24),
155 | color: colors.white,
156 | alignSelf: "baseline",
157 | marginBottom: hp(72),
158 | marginTop: hp(48),
159 | },
160 | });
161 |
--------------------------------------------------------------------------------
/src/screens/PlayerScreen.tsx:
--------------------------------------------------------------------------------
1 | import React, { useEffect, useState } from "react";
2 | import { StyleSheet, Text, View, TouchableOpacity } from "react-native";
3 | import { RootStackScreenProps } from "@navigation";
4 | import { Audio, AVPlaybackStatus } from "expo-av";
5 | import Slider from "@react-native-community/slider";
6 | import { fs, wp, hp, colors } from "@styles";
7 | import { SpinnerHOC } from "@components";
8 | import { Ionicons } from "@expo/vector-icons";
9 | import { Back, BackIcon, Forward, Like, Unlike } from "@icons";
10 | const SpinnerView = SpinnerHOC(View);
11 | export default function PlayerScreen({
12 | navigation,
13 | route: { params },
14 | }: RootStackScreenProps<"PlayerScreen">) {
15 | const { title, author, audio_url, description, dislikes, likes } = params;
16 | const [playbackInstance, setPlaybackInstance] = useState(
17 | null
18 | );
19 | const [shouldPlayAtEndOfSeek, setShouldPlayAtEndOfSeek] =
20 | useState(false);
21 | const [isSeeking, setIsSeeking] = useState(false);
22 | const [state, setState] = useState({
23 | playbackInstanceName: "LOADING_STRING",
24 | muted: false,
25 | playbackInstancePosition: 0,
26 | playbackInstanceDuration: 0,
27 | shouldPlay: false,
28 | isPlaying: false,
29 | isBuffering: false,
30 | isLoading: true,
31 | shouldCorrectPitch: true,
32 | volume: 1.0,
33 | });
34 |
35 | useEffect(() => {
36 | Audio.setAudioModeAsync({
37 | allowsRecordingIOS: false,
38 | staysActiveInBackground: false,
39 | interruptionModeIOS: Audio.INTERRUPTION_MODE_IOS_DO_NOT_MIX,
40 | playsInSilentModeIOS: true,
41 | shouldDuckAndroid: true,
42 | interruptionModeAndroid: Audio.INTERRUPTION_MODE_ANDROID_DO_NOT_MIX,
43 | playThroughEarpieceAndroid: false,
44 | });
45 | loadNewPlaybackInstance(false);
46 | }, []);
47 | const loadNewPlaybackInstance = async (playing: boolean) => {
48 | if (playbackInstance !== null) {
49 | await playbackInstance.unloadAsync();
50 | setPlaybackInstance(null);
51 | }
52 | const source = { uri: audio_url };
53 | const initialStatus = {
54 | shouldPlay: playing,
55 | shouldCorrectPitch: state.shouldCorrectPitch,
56 | };
57 |
58 | const { sound } = await Audio.Sound.createAsync(
59 | source,
60 | initialStatus,
61 | onPlaybackStatusUpdate
62 | );
63 | setPlaybackInstance(sound);
64 | setState((prev) => {
65 | return {
66 | ...prev,
67 | isLoading: false,
68 | playbackInstanceName: title,
69 | };
70 | });
71 | };
72 |
73 | const onPlaybackStatusUpdate = (status: AVPlaybackStatus) => {
74 | if (status.isLoaded) {
75 | setState((prev) => {
76 | return {
77 | ...prev,
78 | playbackInstancePosition: status.positionMillis,
79 | playbackInstanceDuration: status.durationMillis as number,
80 | shouldPlay: status.shouldPlay,
81 | isPlaying: status.isPlaying,
82 | isBuffering: status.isBuffering,
83 | muted: status.isMuted,
84 | volume: status.volume,
85 | shouldCorrectPitch: status.shouldCorrectPitch,
86 | };
87 | });
88 | } else {
89 | if (status.error) {
90 | console.log(`FATAL PLAYER ERROR: ${status.error}`);
91 | navigation.navigate("ErrorModalScreen", {
92 | text: "Please re start the application!",
93 | });
94 | }
95 | }
96 | };
97 | const onPlayPausePressed = () => {
98 | if (playbackInstance != null) {
99 | if (state.isPlaying) {
100 | playbackInstance.pauseAsync();
101 | } else {
102 | playbackInstance.playAsync();
103 | }
104 | }
105 | };
106 | const onSeekSliderValueChange = () => {
107 | if (playbackInstance != null && !isSeeking) {
108 | setIsSeeking(true);
109 | setShouldPlayAtEndOfSeek(state.shouldPlay);
110 | playbackInstance.pauseAsync();
111 | }
112 | };
113 | const onSeekSliderSlidingComplete = async (value: number) => {
114 | if (playbackInstance != null) {
115 | setIsSeeking(false);
116 | const seekPosition = value * state.playbackInstanceDuration;
117 | if (shouldPlayAtEndOfSeek) {
118 | playbackInstance.playFromPositionAsync(seekPosition);
119 | } else {
120 | playbackInstance.setPositionAsync(seekPosition);
121 | }
122 | }
123 | };
124 | const getSeekSliderPosition = () => {
125 | if (
126 | playbackInstance != null &&
127 | state.playbackInstancePosition != null &&
128 | state.playbackInstanceDuration != null
129 | ) {
130 | return state.playbackInstancePosition / state.playbackInstanceDuration;
131 | }
132 | return 0;
133 | };
134 | const getMMSSFromMillis = (millis: number) => {
135 | const totalSeconds = millis / 1000;
136 | const seconds = Math.floor(totalSeconds % 60);
137 | const minutes = Math.floor(totalSeconds / 60);
138 |
139 | const padWithZero = (number: number) => {
140 | const string = number.toString();
141 | if (number < 10) {
142 | return "0" + string;
143 | }
144 | return string;
145 | };
146 | return padWithZero(minutes) + ":" + padWithZero(seconds);
147 | };
148 |
149 | const getTimestamp = () => {
150 | if (
151 | playbackInstance != null &&
152 | state.playbackInstancePosition != null &&
153 | state.playbackInstanceDuration != null
154 | ) {
155 | return `${getMMSSFromMillis(
156 | state.playbackInstancePosition
157 | )} / ${getMMSSFromMillis(state.playbackInstanceDuration)}`;
158 | }
159 | return "";
160 | };
161 | const goTenSecondForwardOrBackward = (value: number) => {
162 | playbackInstance?.setStatusAsync({
163 | positionMillis: state.playbackInstancePosition + value,
164 | });
165 | };
166 | return (
167 |
168 |
169 | navigation.goBack()}
172 | >
173 |
174 |
175 | {title}
176 | {author}
177 |
178 | goTenSecondForwardOrBackward(-10000)}
181 | >
182 |
183 |
184 | onPlayPausePressed()}
187 | >
188 |
193 |
194 | goTenSecondForwardOrBackward(10000)}
197 | >
198 |
199 |
200 |
201 |
202 | onSeekSliderValueChange}
205 | onSlidingComplete={onSeekSliderSlidingComplete}
206 | disabled={state.isLoading}
207 | minimumTrackTintColor={colors.sliderColor}
208 | maximumTrackTintColor={colors.white}
209 | thumbTintColor={colors.sliderColor}
210 | />
211 |
218 |
219 |
220 | {likes}
221 |
222 |
223 |
224 | {state.isBuffering ? "...BUFFERING..." : ""}
225 |
226 | {getTimestamp()}
227 |
228 |
229 |
230 |
231 | {dislikes}
232 |
233 |
234 |
235 |
236 | {description}
237 |
238 |
239 |
240 | );
241 | }
242 |
243 | const styles = StyleSheet.create({
244 | container: {
245 | flex: 1,
246 | backgroundColor: colors.background,
247 | paddingTop: hp(64),
248 | },
249 | title: {
250 | fontSize: fs(24),
251 | fontWeight: "500",
252 | color: "#ffff",
253 | width: wp(236),
254 | alignSelf: "center",
255 | textAlign: "center",
256 | marginTop: hp(48),
257 | },
258 | author: {
259 | fontSize: fs(14),
260 | fontWeight: "400",
261 | color: "#898F97",
262 | width: wp(236),
263 | alignSelf: "center",
264 | textAlign: "center",
265 | marginTop: hp(12),
266 | },
267 | playerButtonWrapper: {
268 | height: hp(120),
269 | width: "100%",
270 | flexDirection: "row",
271 | alignItems: "center",
272 | justifyContent: "space-between",
273 | paddingHorizontal: wp(112),
274 | },
275 | playPauseButton: {
276 | backgroundColor: "#FF334B",
277 | height: fs(50),
278 | width: fs(50),
279 | borderRadius: fs(25),
280 | justifyContent: "center",
281 | alignItems: "center",
282 | },
283 | bottomWrapper: {
284 | flexGrow: 1,
285 | borderTopLeftRadius: fs(24),
286 | borderTopRightRadius: fs(24),
287 | backgroundColor: "#0f1d2e",
288 | paddingHorizontal: wp(33),
289 | paddingVertical: hp(34),
290 | },
291 | row: {
292 | flexDirection: "row",
293 | alignItems: "center",
294 | },
295 | likeText: {
296 | fontSize: fs(14),
297 | fontWeight: "400",
298 | color: "#fff",
299 | alignSelf: "center",
300 | textAlign: "center",
301 | },
302 | divider: {
303 | width: wp(309),
304 | borderBottomWidth: 1,
305 | borderColor: "#898F97",
306 | marginTop: hp(23),
307 | },
308 | description: {
309 | color: "#898F97",
310 | fontWeight: "400",
311 | fontSize: fs(13),
312 | marginTop: hp(20),
313 | },
314 | });
315 |
--------------------------------------------------------------------------------
/src/screens/mainSlice.tsx:
--------------------------------------------------------------------------------
1 | import { createSlice, PayloadAction } from '@reduxjs/toolkit';
2 |
3 | export const slice = createSlice({
4 | name: 'main',
5 | initialState: {
6 | access_token: ''
7 | },
8 | reducers: {
9 | setAccessToken: (state, { payload }: PayloadAction) => {
10 | state.access_token = payload;
11 | }
12 | }
13 | });
14 |
15 | export const { setAccessToken } = slice.actions;
16 |
17 | export default slice.reducer;
18 |
--------------------------------------------------------------------------------
/src/styles/index.tsx:
--------------------------------------------------------------------------------
1 | import { StyleSheet } from "react-native";
2 | import { heightPercentageToDP, widthPercentageToDP } from "react-native-responsive-screen";
3 | import { RFValue } from "react-native-responsive-fontsize";
4 |
5 | export const colors = {
6 | background: "#09121C",
7 | white: "#ffffff",
8 | black: "#000000",
9 | darkBlue: "#010304",
10 | gray1: "#898F97",
11 | sliderColor: "#3369FF",
12 | gray: "gray",
13 | activeBlue: "#19232F",
14 | };
15 |
16 | const referenceWidth = 376;
17 | const referenceHeight = 812;
18 |
19 | export const wp = (width: number) => {
20 | const givenWidth = (width * 100) / referenceWidth;
21 | const result = widthPercentageToDP(givenWidth);
22 | return result;
23 | };
24 |
25 | export const hp = (height: number) => {
26 | const givenHeight = (height * 100) / referenceHeight;
27 | const result = heightPercentageToDP(givenHeight);
28 | return result;
29 | };
30 | export const commonStyle = StyleSheet.create({
31 | row: {
32 | flexDirection: "row",
33 | alignItems: "center",
34 | },
35 | });
36 | export const fs = (fontSize: number): number => RFValue(fontSize, referenceHeight);
37 |
--------------------------------------------------------------------------------
/src/types/index.tsx:
--------------------------------------------------------------------------------
1 | export type CategoryParamType = "art" | "technology" | "general" | "entertainment" | "" | undefined;
2 | export interface ICategoryButton {
3 | id: string;
4 | title: string;
5 | param: CategoryParamType;
6 | Icon?: JSX.Element;
7 | }
8 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "expo/tsconfig.base",
3 | "compilerOptions": {
4 | "strict": true,
5 | "baseUrl": ".",
6 | "paths": {
7 | "@components": ["./src/components/index.tsx"],
8 | "@styles": ["./src/styles/index.tsx"],
9 | "@navigation": ["./src/navigation/index.tsx"],
10 | "@store/*": ["./src/app/*"],
11 | "@mainSlice": ["./src/screens/mainSlice.tsx"],
12 | "@icons": ["./assets/icons/index.ts"],
13 | "@test": ["./jest/test-utils.tsx"],
14 | "@constants": ["./src/constants/index.tsx"],
15 | "@types": ["./src/types/index.tsx"]
16 | }
17 | }
18 | }
19 |
--------------------------------------------------------------------------------