Skip to content

Commit 45e6e04

Browse files
feat: camera pick (ADORSYS-GIS#61)
1 parent befaccd commit 45e6e04

17 files changed

+661
-12
lines changed

.gitignore

+3-1
Original file line numberDiff line numberDiff line change
@@ -130,4 +130,6 @@ docs/gen
130130

131131
# API codegen files
132132
*.gen.*
133-
gen
133+
gen
134+
135+
.certs

docs/pdf_download.md

+9-1
Original file line numberDiff line numberDiff line change
@@ -1,26 +1,34 @@
11
# PDF Documentation Generation
22

33
## Overview
4+
45
This workflow generates PDF documentation for the frontend using **typedoc** and the converting tool **md-to-pdf**.
56

67
## Workflow Steps
8+
79
### Checkout Repository
10+
811
Clones the repository to the workflow runner.
912

1013
### Install dependencies
14+
1115
Installs all the needed dependencies using the command `yarn install`
1216

1317
### Generate docs to markdown
18+
1419
Generate docs in markdown format using `yarn run typedoc`
1520

1621
### Convert md to pdf files
22+
1723
Convert md files to pdf using a custom script with `yarn run convert-md-to-pdf`
1824

1925
### Upload PDF Artifact
26+
2027
Uploads the generated PDF files as an artifact using [actions/upload-artifact@v3]
2128

2229
## How to Download the generated PDF file
30+
2331
1. After the workflow completes, navigate to the "Actions" tab of your repository.
2432
2. Select the latest workflow run for "Generate documentation for the frontend".
2533
3. Click on the "Artifacts" dropdown menu.
26-
4. Choose "documentations" to download the generated PDF documentations.
34+
4. Choose "documentations" to download the generated PDF documentations.

package.json

+9-2
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
"version": "0.0.0",
55
"type": "module",
66
"scripts": {
7-
"dev": "vite",
7+
"dev": "vite --host 0.0.0.0",
88
"build": "tsc && vite build",
99
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
1010
"preview": "vite preview",
@@ -24,6 +24,8 @@
2424
"@yudiel/react-qr-scanner": "^2.0.4",
2525
"autoprefixer": "^10.4.19",
2626
"axios": "^1.7.2",
27+
"compromise": "^14.14.0",
28+
"formik": "^2.4.6",
2729
"i18next": "^23.11.4",
2830
"i18next-chained-backend": "^4.6.2",
2931
"i18next-http-backend": "^2.5.2",
@@ -34,6 +36,7 @@
3436
"react-daisyui": "^5.0.0",
3537
"react-dom": "^18.2.0",
3638
"react-feather": "^2.0.10",
39+
"react-html5-camera-photo": "^1.5.11",
3740
"react-i18next": "^14.1.1",
3841
"react-qr-code": "^2.0.15",
3942
"react-redux": "^9.1.2",
@@ -43,7 +46,9 @@
4346
"redux-persist": "^6.0.0",
4447
"redux-toolkit": "^1.1.2",
4548
"tailwindcss": "^3.4.3",
46-
"theme-change": "^2.5.0"
49+
"tesseract.js": "^5.1.1",
50+
"theme-change": "^2.5.0",
51+
"yup": "^1.4.0"
4752
},
4853
"devDependencies": {
4954
"@commitlint/cli": "^19.3.0",
@@ -55,6 +60,7 @@
5560
"@types/i18next-browser-languagedetector": "^3.0.0",
5661
"@types/react": "^18.2.66",
5762
"@types/react-dom": "^18.2.22",
63+
"@types/react-html5-camera-photo": "^1.5.3",
5864
"@types/redux-logger": "^3.0.13",
5965
"@typescript-eslint/eslint-plugin": "^7.7.1",
6066
"@typescript-eslint/parser": "^7.7.1",
@@ -85,6 +91,7 @@
8591
"typescript": "^5.4.5",
8692
"vite": "^5.2.0",
8793
"vite-plugin-eslint": "^1.8.1",
94+
"vite-plugin-mkcert": "^1.17.5",
8895
"vite-tsconfig-paths": "^4.3.2"
8996
},
9097
"lint-staged": {

src/components/camera-input.tsx

+71
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
import { useCallback, useRef, useState } from 'react';
2+
import { TakePicture } from '@components/take-picture.tsx';
3+
import { useAiLoadingState, useExtractDataFromText } from '@store';
4+
import { Button, Progress } from 'react-daisyui';
5+
import { Camera, X } from 'react-feather';
6+
7+
export interface CameraInputProps {
8+
name: string;
9+
id?: string;
10+
}
11+
12+
export function CameraInput(props: CameraInputProps) {
13+
const ref = useRef<HTMLDialogElement | null>(null);
14+
const openModal = useCallback(() => ref.current?.showModal(), [ref]);
15+
const extractedData = useExtractDataFromText();
16+
const aiLoading = useAiLoadingState();
17+
const [shouldTake, setShouldTake] = useState(false);
18+
const [dataUri, setDataUri] = useState<string | null>(null);
19+
20+
const onPicture = useCallback((dataUri: string) => {
21+
setDataUri(dataUri);
22+
setShouldTake(false);
23+
}, []);
24+
25+
return (
26+
<>
27+
<button className="btn" onClick={openModal}>
28+
open modal
29+
</button>
30+
<dialog
31+
id={props.id}
32+
ref={ref}
33+
className="modal modal-bottom sm:modal-middle"
34+
>
35+
<div className="modal-box flex flex-col gap-2 items-center">
36+
<form className="w-full" method="dialog">
37+
<h2>{props.name}</h2>
38+
{/* if there is a button in form, it will close the modal */}
39+
<Button
40+
color="ghost"
41+
shape="circle"
42+
className="absolute right-2 top-2 z-10"
43+
>
44+
<X />
45+
</Button>
46+
</form>
47+
48+
{shouldTake && <TakePicture onTakePhoto={onPicture} />}
49+
{dataUri && !shouldTake && (
50+
<div className="rounded-lg relative overflow-hidden">
51+
<img src={dataUri} alt="image" />
52+
</div>
53+
)}
54+
{!shouldTake && (
55+
<Button shape="circle" onClick={() => setShouldTake(true)}>
56+
<Camera />
57+
</Button>
58+
)}
59+
{extractedData && !shouldTake && (
60+
<pre className="bg-base-200 rounded-xl overflow-scroll h-[100px] w-full">
61+
{JSON.stringify(extractedData, null, 2)}
62+
</pre>
63+
)}
64+
{dataUri && aiLoading > 0 && aiLoading < 3 && (
65+
<Progress className="w-full" value={aiLoading} max={3} />
66+
)}
67+
</div>
68+
</dialog>
69+
</>
70+
);
71+
}

src/components/take-picture.tsx

+37
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import Camera, { CameraProps } from 'react-html5-camera-photo';
2+
import { useAddErrorNotification, useExtractText } from '@store';
3+
import 'react-html5-camera-photo/build/css/index.css';
4+
import { useCallback } from 'react';
5+
6+
interface TakePictureProps {
7+
onTakePhoto: Required<CameraProps>['onTakePhoto'];
8+
}
9+
10+
export function TakePicture({ onTakePhoto }: TakePictureProps) {
11+
const extractText = useExtractText();
12+
const addErrorNotification = useAddErrorNotification();
13+
const handleTakePhoto: Required<CameraProps>['onTakePhoto'] = useCallback(
14+
(dataUri) => {
15+
extractText(dataUri);
16+
onTakePhoto(dataUri);
17+
},
18+
[extractText, onTakePhoto]
19+
);
20+
21+
const onCameraError: Required<CameraProps>['onCameraError'] = useCallback(
22+
(error) => {
23+
addErrorNotification(error.message);
24+
},
25+
[addErrorNotification]
26+
);
27+
28+
return (
29+
<div>
30+
<Camera
31+
onCameraError={onCameraError}
32+
onTakePhoto={handleTakePhoto}
33+
isMaxResolution={true}
34+
/>
35+
</div>
36+
);
37+
}

src/main.tsx

+1
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import './index.scss';
2+
import 'barcode-detector/side-effects';
23
import { isElectron, setupLogging } from '@shared';
34
import * as Sentry from '@sentry/react';
45
import { i18nFn } from '@i18n';

src/screens/scan.screen.tsx

+4-1
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import React from 'react';
22
import { Header } from '../components/header.tsx';
3+
import { CameraInput } from '@components/camera-input.tsx';
34

45
/**
56
* Scan screen
@@ -9,7 +10,9 @@ export const Component: React.FC = () => {
910
return (
1011
<>
1112
<Header title="Scan" back=".." />
12-
<div className="bg-base-100">TODO: Form & Camera will be here</div>
13+
<div className="bg-base-100">
14+
<CameraInput name="image" />
15+
</div>
1316
</>
1417
);
1518
};

src/store/hooks.ts

+36-2
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,12 @@
11
import { useCallback } from 'react';
2-
import { fetchConfigUrl } from './thunks';
2+
import { extractBarcode, extractText, fetchConfigUrl } from './thunks';
33
import { useAppDispatch, useAppSelector } from './types';
4-
import { selectConfigUrl } from '@store/selectors.ts';
4+
import {
5+
selectAiData,
6+
selectAiLoadingState,
7+
selectConfigUrl,
8+
} from '@store/selectors.ts';
9+
import { addNotification } from '@store/slices';
510

611
export function useFetchConfigUrl() {
712
const dispatch = useAppDispatch();
@@ -13,3 +18,32 @@ export function useFetchConfigUrl() {
1318
export function useConfigData() {
1419
return useAppSelector(selectConfigUrl);
1520
}
21+
22+
export function useExtractText() {
23+
const dispatch = useAppDispatch();
24+
return useCallback(
25+
(imgUri: string) => {
26+
dispatch(extractText({ imgUri }));
27+
dispatch(extractBarcode({ imgUri }));
28+
},
29+
[dispatch]
30+
);
31+
}
32+
33+
export function useExtractDataFromText() {
34+
return useAppSelector(selectAiData);
35+
}
36+
37+
export function useAiLoadingState() {
38+
return useAppSelector(selectAiLoadingState);
39+
}
40+
41+
export function useAddErrorNotification() {
42+
const dispatch = useAppDispatch();
43+
return useCallback(
44+
(msg: string) => {
45+
dispatch(addNotification(msg));
46+
},
47+
[dispatch]
48+
);
49+
}

src/store/selectors.ts

+23
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,30 @@ export const selectNotification = createSelector(
55
(ro: RootState) => ro.notification.messages,
66
(p) => p.filter(({ message }) => message.length > 0)
77
);
8+
89
export const selectConfigUrl = createSelector(
910
(ro: RootState) => ro.config,
1011
({ url, loading }) => (loading ? undefined : JSON.stringify({ url }))
1112
);
13+
14+
export const selectAiData = createSelector(
15+
(ro: RootState) => ro.ai,
16+
({ loadingData, loadingBarCodes, data, barCodes }) =>
17+
loadingData || loadingBarCodes
18+
? undefined
19+
: {
20+
data,
21+
barCodes,
22+
}
23+
);
24+
25+
export const selectAiLoadingState = createSelector(
26+
(ro: RootState) => ro.ai,
27+
({ loadingData, loadingBarCodes, loadingText }) => {
28+
let total = 0;
29+
if (!loadingData) total++;
30+
if (!loadingBarCodes) total++;
31+
if (!loadingText) total++;
32+
return total;
33+
}
34+
);

src/store/slices/ai.slice.ts

+71
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
import { createSlice } from '@reduxjs/toolkit';
2+
import { PURGE } from 'redux-persist';
3+
import {
4+
extractBarcode,
5+
extractDataFromText,
6+
extractText,
7+
} from '@store/thunks';
8+
9+
export interface AIState {
10+
text?: string;
11+
data?: Record<string, unknown>;
12+
barCodes?: string[];
13+
loadingText: boolean;
14+
loadingData: boolean;
15+
loadingBarCodes: boolean;
16+
}
17+
18+
const initialState = {
19+
text: undefined,
20+
data: undefined,
21+
barCodes: undefined,
22+
loadingText: true,
23+
loadingData: true,
24+
loadingBarCodes: true,
25+
} satisfies AIState as AIState;
26+
27+
const aiSlice = createSlice({
28+
name: 'ai',
29+
initialState,
30+
reducers: {},
31+
extraReducers: (builder) => {
32+
// Add reducers for additional action types here, and handle loading state as needed
33+
builder
34+
.addCase(extractText.fulfilled, (state, action) => {
35+
// Add user to the state array
36+
state.text = action.payload;
37+
state.loadingText = false;
38+
})
39+
.addCase(extractText.pending, (state) => {
40+
// Add user to the state array
41+
state.loadingText = true;
42+
})
43+
.addCase(extractBarcode.fulfilled, (state, action) => {
44+
// Add user to the state array
45+
state.barCodes = action.payload;
46+
state.loadingBarCodes = false;
47+
})
48+
.addCase(extractBarcode.pending, (state) => {
49+
// Add user to the state array
50+
state.loadingBarCodes = true;
51+
})
52+
.addCase(extractDataFromText.fulfilled, (state, action) => {
53+
// Add user to the state array
54+
state.data = action.payload;
55+
state.loadingData = false;
56+
})
57+
.addCase(extractDataFromText.pending, (state) => {
58+
// Add user to the state array
59+
state.loadingData = true;
60+
})
61+
.addCase(PURGE, (state) => {
62+
state.data = initialState.data;
63+
state.text = initialState.text;
64+
state.loadingData = initialState.loadingData;
65+
state.loadingText = initialState.loadingText;
66+
state.loadingBarCodes = initialState.loadingBarCodes;
67+
});
68+
},
69+
});
70+
71+
export const reducerAI = aiSlice.reducer;

src/store/slices/index.ts

+1
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,3 @@
1+
export * from './ai.slice';
12
export * from './config.slice';
23
export * from './notification.slice';

0 commit comments

Comments
 (0)