Skip to content

Commit

Permalink
Add scanning (#9)
Browse files Browse the repository at this point in the history
* Update plan

* Fix same key error

* Add a dialog

* Add naive scanning.

* Add naive code handling.

* Replace codeType by codeFormat.

* Fix ts warning, make card code input disabled by default.

* Adjust scanner

* Add explanation text.

* Exclude .next from tests.

* Make layout responsive.

* Create scannerReducer

* Use scannerReducer

* Move scanner reducer to create-card-form

* Cleanup

* Adjust names.

* Cleanup in scanner

* Display toggle button conditionally.

* Disable strict mode test.

* Set device id directly

* Comment constrains

* Enable strict mode

* Cleanup.

* Increase required devices count for switch.

* Cleanup

* Update reducer.

* Use isModalVisible

* Adjust reducer names.
  • Loading branch information
lukasbicus authored Oct 31, 2024
1 parent 244ab1f commit 047350f
Show file tree
Hide file tree
Showing 16 changed files with 527 additions and 132 deletions.
2 changes: 1 addition & 1 deletion app/(homescreens)/my-cards/my-cards.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ export default function MyCards() {
return (
<ul className="menu menu-sm rounded-box gap-2">
{state.cards.map(card => (
<li key={card.name}>
<li key={card.id}>
<Link
href={{
pathname: Routes.Card,
Expand Down
302 changes: 202 additions & 100 deletions app/create-card/create-card-form.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,17 @@
'use client';

import Scanner from '@/app/create-card/scanner';
import {
initialState,
CreateCardFormActions,
CreateCardFormActionTypes,
createCardFormReducer,
CreateCardFormState,
} from '@/app/create-card/createCardFormReducer';
import useAppState from '@/app/lib/app-state/app-state';
import { predefinedCompanies } from '@/app/lib/predefined-companies';
import {
CardIcon,
CodeType,
colorNames,
iconsMap,
Routes,
Expand All @@ -13,16 +20,22 @@ import {
import { DropdownField } from '@/app/ui/dropdown-field';
import { TextAreaField } from '@/app/ui/text-area-field';
import { TextField } from '@/app/ui/text-field';
import { IconCamera, IconPalette } from '@tabler/icons-react';
import Link from 'next/link';
import {
IconCamera,
IconPalette,
IconRefresh,
IconX,
} from '@tabler/icons-react';
import { Html5QrcodeResult, Html5QrcodeSupportedFormats } from 'html5-qrcode';
import Image from 'next/image';
import { useRouter, useSearchParams } from 'next/navigation';
import { Reducer, useCallback, useEffect, useReducer, useRef } from 'react';
import { useForm } from 'react-hook-form';

enum FormNames {
Name = 'name',
Code = 'code',
CodeType = 'codeType',
CodeFormat = 'codeFormat',
Note = 'note',
Color = 'color',
Icon = 'Icon',
Expand All @@ -31,7 +44,7 @@ enum FormNames {
type CreateCardForm = {
[FormNames.Name]: string;
[FormNames.Code]: string;
[FormNames.CodeType]: string;
[FormNames.CodeFormat]: number;
[FormNames.Note]: string;
[FormNames.Color]: string | null;
[FormNames.Icon]: string | SvgProps;
Expand All @@ -43,116 +56,205 @@ export default function CreateCardForm() {
const predefinedCompany = predefinedCompanies.find(
c => c.name === predefinedCompanyName
);
const { register, handleSubmit, control, watch } = useForm<CreateCardForm>({
defaultValues: predefinedCompany
? {
[FormNames.Name]: predefinedCompany.name,
[FormNames.Color]: null,
[FormNames.Icon]: predefinedCompany.svg,
[FormNames.CodeType]: predefinedCompany.codeType,
}
: {
[FormNames.CodeType]: CodeType.Barcode,
},
});
const [, dispatch] = useAppState();
const { register, handleSubmit, control, watch, setValue } =
useForm<CreateCardForm>({
defaultValues: predefinedCompany
? {
[FormNames.Name]: predefinedCompany.name,
[FormNames.Color]: null,
[FormNames.Icon]: predefinedCompany.svg,
[FormNames.CodeFormat]: predefinedCompany.codeFormat,
}
: {
[FormNames.CodeFormat]: Html5QrcodeSupportedFormats.EAN_13,
},
});
const [, appDispatch] = useAppState();
const [{ devices, activeDevice, isModalVisible }, dispatch] = useReducer<
Reducer<CreateCardFormState, CreateCardFormActions>
>(createCardFormReducer, initialState);
const router = useRouter();
const cameraModalRef = useRef<HTMLDialogElement>(null);
const handleCodeDetected = useCallback(
(text: string, { result }: Html5QrcodeResult) => {
setValue(FormNames.Code, text);
if (result.format?.format) {
setValue(FormNames.CodeFormat, result.format.format);
}
cameraModalRef.current?.close();
},
[setValue]
);
useEffect(() => {
const cameraModal = cameraModalRef.current;

const observer = new MutationObserver(function observerCallback(
mutations: MutationRecord[]
) {
for (const mutation of mutations) {
if (
mutation.type === 'attributes' &&
mutation.attributeName === 'open'
) {
if (cameraModal) {
dispatch({
type: CreateCardFormActionTypes.UPDATE_MODAL_VISIBILITY,
payload: cameraModal.open,
});
}
}
}
});

if (cameraModal) {
observer.observe(cameraModal, { attributes: true });
}
return () => {
if (cameraModal) {
observer.disconnect();
}
};
}, []);

return (
<form
className="px-4 py-6 w-full h-full"
onSubmit={handleSubmit(data => {
console.log('data', data);
dispatch({
type: 'ADD_CARD',
payload: {
name: data[FormNames.Name],
code: data[FormNames.Code],
note: data[FormNames.Note] || undefined,
bgColor: data[FormNames.Color] || null,
icon: (data[FormNames.Icon] as CardIcon) || null,
codeType: data[FormNames.CodeType] as CodeType,
},
});
router.replace(Routes.MyCards);
})}
>
<div className="flex gap-4">
<>
<form
className="px-4 py-6 w-full h-full"
onSubmit={handleSubmit(data => {
appDispatch({
type: 'ADD_CARD',
payload: {
name: data[FormNames.Name],
code: data[FormNames.Code],
note: data[FormNames.Note] || undefined,
bgColor: data[FormNames.Color] || null,
icon: (data[FormNames.Icon] as CardIcon) || null,
codeFormat: data[
FormNames.CodeFormat
] as Html5QrcodeSupportedFormats,
},
});
router.replace(Routes.MyCards);
})}
>
<div className="flex gap-4">
<TextField
label="Card code"
name={FormNames.Code}
register={register}
disabled
/>
<button
className="btn btn-primary btn-square mt-9"
onClick={() => {
cameraModalRef.current?.showModal();
}}
type="button"
>
<IconCamera className="w-6 h-6" />
</button>
</div>
<TextField
label="Card code"
name={FormNames.Code}
label="Card name"
name={FormNames.Name}
register={register}
/>
<Link
className="btn btn-primary btn-square mt-9"
href={Routes.ScanCard}
>
<IconCamera className="w-6 h-6" />
</Link>
</div>
<TextField label="Card name" name={FormNames.Name} register={register} />
<TextAreaField label="Note" name={FormNames.Note} register={register} />
{predefinedCompany ? (
<input type="hidden" {...register(FormNames.Color)} />
) : (
<div className="flex gap-4">
<TextAreaField label="Note" name={FormNames.Note} register={register} />
{predefinedCompany ? (
<input type="hidden" {...register(FormNames.Color)} />
) : (
<div className="flex gap-4">
<DropdownField
label="Background color"
dropdownClassName="dropdown-top"
options={Object.entries(colorNames).map(([hex, name]) => ({
label: (
<div className="flex gap-2 items-center">
<div className="w-4 h-4" style={{ backgroundColor: hex }} />
<span>{name}</span>
</div>
),
value: hex,
}))}
control={control}
name={FormNames.Color}
watch={watch}
/>
<button className="btn btn-primary btn-square mt-9">
<IconPalette className="w-6 h-6" />
</button>
</div>
)}
{predefinedCompany ? (
<label className={'form-control w-full'}>
<div className="label">
<span className="label-text">Company logo</span>
</div>
<div className="bg-background">
<input type="hidden" {...register(FormNames.Icon)} />
<Image
{...predefinedCompany.svg}
alt={predefinedCompany.name}
className="w-24 h-24"
/>
</div>
</label>
) : (
<DropdownField
label="Background color"
label="Icon"
dropdownClassName="dropdown-top"
options={Object.entries(colorNames).map(([hex, name]) => ({
options={Object.entries(iconsMap).map(([key, Icon]) => ({
label: (
<div className="flex gap-2 items-center">
<div className="w-4 h-4" style={{ backgroundColor: hex }} />
<span>{name}</span>
</div>
<span>
<Icon className="w-6 h-6" />
</span>
),
value: hex,
value: key,
}))}
control={control}
name={FormNames.Color}
name={FormNames.Icon}
watch={watch}
/>
<button className="btn btn-primary btn-square mt-9">
<IconPalette className="w-6 h-6" />
)}
<div className="h-32" />
<footer className="btm-nav btm-nav-md text-base-content px-4">
<button className="btn btn-primary w-full" type="submit">
Create card
</button>
</div>
)}
{predefinedCompany ? (
<label className={'form-control w-full'}>
<div className="label">
<span className="label-text">Company logo</span>
</footer>
</form>
<dialog id="camera-modal" className="modal" ref={cameraModalRef}>
<div className="modal-box w-full h-full max-h-full max-w-full overflow-clip lg:w-11/12 lg:h-5/6 lg:max-w-5xl lg:max-h-5xl">
<div className="grid justify-between items-center pb-4 grid-cols-[auto_1fr_auto] gap-4">
<button
className="btn btn-sm btn-circle btn-ghost"
onClick={() => cameraModalRef.current?.close()}
>
<IconX className="w-6 h-6" />
</button>
<h3 className="font-bold text-lg">Scan your code!</h3>
{devices.length > 1 ? (
<button
className="btn btn-sm btn-circle btn-ghost"
onClick={() => {
dispatch({
type: CreateCardFormActionTypes.TOGGLE_ACTIVE_DEVICE,
});
}}
>
<IconRefresh className="w-6 h-6" />
</button>
) : null}
</div>
<div className="bg-background">
<input type="hidden" {...register(FormNames.Icon)} />
<Image
{...predefinedCompany.svg}
alt={predefinedCompany.name}
className="w-24 h-24"
{isModalVisible && (
<Scanner
onCodeDetected={handleCodeDetected}
activeDevice={activeDevice}
dispatch={dispatch}
/>
</div>
</label>
) : (
<DropdownField
label="Icon"
dropdownClassName="dropdown-top"
options={Object.entries(iconsMap).map(([key, Icon]) => ({
label: (
<span>
<Icon className="w-6 h-6" />
</span>
),
value: key,
}))}
control={control}
name={FormNames.Icon}
watch={watch}
/>
)}
<div className="h-32" />
<footer className="btm-nav btm-nav-md text-base-content px-4">
<button className="btn btn-primary w-full" type="submit">
Create card
</button>
</footer>
</form>
)}
</div>
</dialog>
</>
);
}
Loading

0 comments on commit 047350f

Please sign in to comment.