├── .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 |
6 | 7 | 8 | 9 |
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 | 12 | 18 | 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 | 12 | 18 | 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 | 12 | 18 | 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 | 12 | 18 | 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 | 12 | 13 | 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 | 12 | 18 | 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 | 12 | 18 | 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 | 12 | 18 | 24 | 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 | 12 | 18 | 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 | 19 | 23 | 28 | 33 | 37 | 41 | 42 | 43 | 44 | 45 | 53 | 54 | 55 | 56 | 57 | 58 | 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 | 12 | 18 | 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 | 12 | 18 | 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 | 12 | 17 | 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 | 12 | 18 | 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 | 12 | 18 | 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 |