Compare commits
2 Commits
snapshot-2
...
develop
Author | SHA1 | Date | |
---|---|---|---|
|
49266bbc97 | ||
|
f6a151337a |
107
README.md
@ -1,5 +1,7 @@
|
||||
# PliWould - Measure And Price Sheet Good Merchandise
|
||||
|
||||
![PlyWould's ugly logo](./assets/images/pli-would-512.png)
|
||||
|
||||
Working behind the register at Habitat for Humanity ReStore I found sheet goods often came in partials.
|
||||
|
||||
As a cashier, I would often have to determine the correct price for merchandise based on the area or length
|
||||
@ -10,60 +12,87 @@ a manager to assist. Instead, I took it upon myself to find a solution.
|
||||
|
||||
Hence, I created PliWould, an app that determines the cost of a product based on its dimensions.
|
||||
|
||||
By default my store's prices are in the database. However, the prices are editable and you can add or remove
|
||||
By default my store's prices are in the database.
|
||||
However, the prices are editable and you can add or remove
|
||||
products as needed.
|
||||
|
||||
## Usage
|
||||
|
||||
[[ TODO ]]
|
||||
![App Overview](./doc/images/screenshots/index.png)
|
||||
|
||||
### ![Scale Tab](./doc/images/icons/scale.png) Measure Tab
|
||||
|
||||
This tab is used to determine the price based on measurements.
|
||||
|
||||
Select a product from a list of products.
|
||||
|
||||
![Plywood sheet selected](./doc/images/screenshots/plywood-sheet-4-by-8-inches.png)
|
||||
|
||||
There are 2 different types of products:
|
||||
|
||||
1. Length prodcuts
|
||||
2. Area products.
|
||||
|
||||
Typically length products have a Square button that measures per length,
|
||||
but some do not, so I included them.
|
||||
|
||||
Area products on the Square console are "partials," so they are listed in the product list.
|
||||
|
||||
Select one you wish to price.
|
||||
|
||||
Automatically the measurements for the "base measurement" will be filled in.
|
||||
|
||||
Using a tape measure, measure the sheet's length and width. You can either put in
|
||||
inches or feet (switch using the `in`/`ft` button selector).
|
||||
|
||||
If the product is damaged, use the slider to select the amount of damage
|
||||
|
||||
![Plywood sheet selected, with 25% damage](./doc/images/screenshots/plywood-sheet-4-by-8-feet-25-damage.png)
|
||||
|
||||
If you select a length product, only the length field will be present. Proceed as you
|
||||
would with area products.
|
||||
|
||||
![Length product selected](./doc/images/screenshots/house-siding-length-input-feet.png)
|
||||
|
||||
### ![Product Editor Tab](./doc/images/icons/list.png) Product Editor (WIP)
|
||||
|
||||
In the product editor, you can add or remove products as needed.
|
||||
|
||||
You can even edit or add attributes.
|
||||
|
||||
Note that the `name` attribute is highly recommended as it's the name of the product.
|
||||
|
||||
Otherwise, it will display as `Product <UUID>`.
|
||||
|
||||
# Development Docs
|
||||
|
||||
This is an [Expo](https://expo.dev) project created with [`create-expo-app`](https://www.npmjs.com/package/create-expo-app).
|
||||
This is an [Expo](https://expo.dev) project.
|
||||
|
||||
The `develop` branch is used to develop features until it's ready to be merged
|
||||
into main.
|
||||
|
||||
## Get started
|
||||
|
||||
1. Clone the repository.
|
||||
|
||||
```
|
||||
$ git clone https://gittea.dev/srcrr/PliWould
|
||||
```
|
||||
|
||||
2. Install eas-cli **globally**
|
||||
|
||||
```
|
||||
$ npm i -g eas-cli
|
||||
```
|
||||
|
||||
1. Install dependencies
|
||||
|
||||
```bash
|
||||
npm install
|
||||
pnpm install
|
||||
```
|
||||
|
||||
2. Start the app
|
||||
|
||||
```bash
|
||||
npx expo start
|
||||
```
|
||||
|
||||
In the output, you'll find options to open the app in a
|
||||
|
||||
- [development build](https://docs.expo.dev/develop/development-builds/introduction/)
|
||||
- [Android emulator](https://docs.expo.dev/workflow/android-studio-emulator/)
|
||||
- [iOS simulator](https://docs.expo.dev/workflow/ios-simulator/)
|
||||
- [Expo Go](https://expo.dev/go), a limited sandbox for trying out app development with Expo
|
||||
|
||||
You can start developing by editing the files inside the **app** directory. This project uses [file-based routing](https://docs.expo.dev/router/introduction).
|
||||
|
||||
## Get a fresh project
|
||||
|
||||
When you're ready, run:
|
||||
|
||||
```bash
|
||||
npm run reset-project
|
||||
```
|
||||
|
||||
This command will move the starter code to the **app-example** directory and create a blank **app** directory where you can start developing.
|
||||
|
||||
## Learn more
|
||||
|
||||
To learn more about developing your project with Expo, look at the following resources:
|
||||
|
||||
- [Expo documentation](https://docs.expo.dev/): Learn fundamentals, or go into advanced topics with our [guides](https://docs.expo.dev/guides).
|
||||
- [Learn Expo tutorial](https://docs.expo.dev/tutorial/introduction/): Follow a step-by-step tutorial where you'll create a project that runs on Android, iOS, and the web.
|
||||
|
||||
## Join the community
|
||||
|
||||
Join our community of developers creating universal apps.
|
||||
|
||||
- [Expo on GitHub](https://github.com/expo/expo): View our open source platform and contribute.
|
||||
- [Discord community](https://chat.expo.dev): Chat with Expo users and ask questions.
|
||||
pnpx expo start
|
||||
```
|
@ -3,7 +3,7 @@ import { configureStore } from '@reduxjs/toolkit';
|
||||
import { rememberReducer, rememberEnhancer } from 'redux-remember';
|
||||
import reducers from "@/features/product/productSlice"
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
import { ProductData, } from "@/lib/product";
|
||||
import { ProductData } from "@/lib/dimensions_t";
|
||||
import {Length} from "convert"
|
||||
|
||||
const rememberedKeys = ['products'];
|
||||
|
BIN
assets/images/pli-would-512.png
Normal file
After Width: | Height: | Size: 259 KiB |
@ -1,5 +1,5 @@
|
||||
import { MeasurementInput } from "./MeasurementInput";
|
||||
import { area_t, dimensions_t } from "@/lib/product";
|
||||
import { area_t, dimensions_t } from "@/lib/dimensions_t";
|
||||
import { Length } from "convert";
|
||||
import { useState } from "react";
|
||||
import { StyleSheet, Text, View } from "react-native";
|
||||
|
53
components/AreaRugTag.tsx
Normal file
@ -0,0 +1,53 @@
|
||||
import { area_t } from "@/lib/dimensions";
|
||||
import convert, { Area, Length } from "convert";
|
||||
import dayjs, { Dayjs } from "dayjs";
|
||||
import { StyleSheet, Text, View } from "react-native";
|
||||
|
||||
export type AreaRugTagProps = {
|
||||
dimensions: area_t,
|
||||
price_per_area: {
|
||||
price: number,
|
||||
per: {
|
||||
n: number,
|
||||
u: Area,
|
||||
}
|
||||
},
|
||||
date?: Dayjs
|
||||
currencySymbol?: string
|
||||
};
|
||||
|
||||
export const AreaRugTag = (props: AreaRugTagProps) => {
|
||||
const date = props.date || dayjs();
|
||||
const square = props.dimensions.l * props.dimensions.w;
|
||||
const areaUnits = `square ${props.dimensions.u}`;
|
||||
const square2 = convert(square, areaUnits as Area).to(props.price_per_area.per.u)
|
||||
const price = (square2 / props.price_per_area.per.n) * props.price_per_area.price;
|
||||
const sPrice = price.toLocaleString(undefined, {
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
})
|
||||
const currencySymbol = props.currencySymbol || "$";
|
||||
return (
|
||||
<View style={styles.component}>
|
||||
<Text aria-label="area rug dimensions" style={styles.dimensions}>{props.dimensions.l} x {props.dimensions.w}</Text>
|
||||
<Text aria-label="area rug price" style={styles.price}>{currencySymbol} {sPrice}</Text>
|
||||
<Text aria-label="area rug date" style={styles.date}>{date.format("YYYY/MM/DD")}</Text>
|
||||
<Text aria-label="this week's color" style={styles.tagColor}>[Curent Tag Color]</Text>
|
||||
</View>
|
||||
)
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
component: {
|
||||
paddingVertical: 100,
|
||||
flex: 1,
|
||||
},
|
||||
dimensions: {
|
||||
},
|
||||
price: {
|
||||
},
|
||||
date: {
|
||||
},
|
||||
tagColor: {
|
||||
},
|
||||
})
|
@ -1,4 +1,4 @@
|
||||
import { dimensions_t, length_t } from "@/lib/product";
|
||||
import { dimensions_t, length_t } from "@/lib/dimensions_t";
|
||||
import { Length } from "convert";
|
||||
import { useState } from "react";
|
||||
import { StyleSheet, Text, TextInput, View } from "react-native";
|
||||
|
@ -1,4 +1,5 @@
|
||||
import { Product, dimensions_t } from '@/lib/product';
|
||||
import { Product } from '@/lib/product';
|
||||
import { dimensions_t } from "@/lib/dimensions_t";
|
||||
import { useState, useEffect } from 'react';
|
||||
import { View, Text, StyleSheet } from 'react-native';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
|
@ -1,6 +1,7 @@
|
||||
import { useAppDispatch, useAppSelector } from "@/app/store"
|
||||
import { addAttribute, changeKey, deleteAttribute, deleteProduct, selectProductIds, selectProducts, updateAttribute, updateDimensions, updatePrice, updateProduct } from "@/features/product/productSlice"
|
||||
import { Id, Product, dimensions_t } from "@/lib/product";
|
||||
import { Id, Product } from "@/lib/product";
|
||||
import { dimensions_t } from "@/lib/dimensions_t";
|
||||
import { FlatList, SafeAreaView, StyleSheet, Text } from "react-native";
|
||||
import { ProductEditorItem } from "./ProductEditorItem";
|
||||
|
||||
|
@ -1,4 +1,5 @@
|
||||
import { Id, Product, dimensions_t } from "@/lib/product"
|
||||
import { Id, Product } from "@/lib/product"
|
||||
import { dimensions_t } from "@/lib/dimensions_t";
|
||||
import { useState } from "react"
|
||||
import { Button, FlatList, StyleSheet, Text, TextInput, Touchable, TouchableHighlight, View } from "react-native"
|
||||
import { ProductAttributeEditor } from "./ProductAttributeEditor";
|
||||
|
BIN
doc/images/icons/list.png
Normal file
After Width: | Height: | Size: 722 B |
BIN
doc/images/icons/scale.png
Normal file
After Width: | Height: | Size: 980 B |
BIN
doc/images/screenshots/house-siding-length-input-feet.png
Normal file
After Width: | Height: | Size: 29 KiB |
BIN
doc/images/screenshots/index.png
Normal file
After Width: | Height: | Size: 39 KiB |
BIN
doc/images/screenshots/plywood-sheet-4-by-8-feet-25-damage.png
Normal file
After Width: | Height: | Size: 34 KiB |
BIN
doc/images/screenshots/plywood-sheet-4-by-8-feet.png
Normal file
After Width: | Height: | Size: 34 KiB |
BIN
doc/images/screenshots/plywood-sheet-4-by-8-inches.png
Normal file
After Width: | Height: | Size: 33 KiB |
BIN
doc/images/screenshots/product-editor.png
Normal file
After Width: | Height: | Size: 32 KiB |
@ -1,5 +1,6 @@
|
||||
import { createSelector, createSlice, PayloadAction } from '@reduxjs/toolkit';
|
||||
import { dimensions_t, Id, Product, ProductData } from '@/lib/product';
|
||||
import { Id, Product } from '@/lib/product';
|
||||
import { dimensions_t, ProductData } from "@/lib/dimensions_t";
|
||||
import uuid from "react-native-uuid";
|
||||
import { RootState } from '@/app/store';
|
||||
import { Length } from 'convert';
|
||||
|
14
lib/__tests__/dimensions-test.ts
Normal file
@ -0,0 +1,14 @@
|
||||
import { diameterToLength, length_t } from '../dimensions';
|
||||
|
||||
describe('diameterToLength', () => {
|
||||
it('should throw an error if the units of the outer and inner diameters do not match', () => {
|
||||
expect(() => diameterToLength({ l: 10, u: 'inch' }, { l: 8, u: 'foot' }, 2)).toThrow('diameter units must match!');
|
||||
});
|
||||
|
||||
it('should return the correct length for multiple rings with different units', () => {
|
||||
const outer : length_t = {l: 25, u: "in"};
|
||||
const inner: length_t = {l : 1, u: "in"};
|
||||
const l = diameterToLength(outer, inner, 12);
|
||||
expect(l.l).toBeCloseTo(490, -1.0);
|
||||
});
|
||||
});
|
52
lib/dimensions.ts
Normal file
@ -0,0 +1,52 @@
|
||||
import convert from "convert";
|
||||
import { Length } from "convert";
|
||||
|
||||
export type length_t = {
|
||||
l: number; u: Length;
|
||||
};
|
||||
|
||||
export type area_t = length_t & {
|
||||
w: number;
|
||||
};
|
||||
|
||||
export type dimensions_t = area_t | length_t;
|
||||
|
||||
export type product_type_t = "area" | "length";
|
||||
|
||||
export const isArea = (d: dimensions_t) => ("width" in d);
|
||||
export const isLength = (d: dimensions_t) => (!("width" in d));
|
||||
export const dimensionType = (d: dimensions_t) => isArea(d) ? "area" : "length"; export function matchDimensions(d1: dimensions_t, d2: dimensions_t) {
|
||||
if (!(
|
||||
(isArea(d1) && isArea(d2)) ||
|
||||
(isLength(d1) && isLength(d2))
|
||||
)) {
|
||||
throw new Error(`Dimension mismatch: ${JSON.stringify(d1)} / ${JSON.stringify(d1)}`);
|
||||
}
|
||||
|
||||
return {
|
||||
l: convert(d1.l, d1.u).to(d2.u),
|
||||
u: d2.u,
|
||||
...(
|
||||
"w" in d1 ?
|
||||
{ w: convert(d1.w, d1.u).to(d2.u), }
|
||||
: {}
|
||||
)
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the total length of a carpet roll based on a diameter
|
||||
* @param outerDiameter Outer diameter of the carpet roll
|
||||
* @param innerDiameter Inner diameter of the carpet roll (the "hole")
|
||||
* @param numRings Number of "rings" or "layers,"" just like a tree 🙂 🌲
|
||||
*/
|
||||
export function diameterToLength(outerDiameter: length_t, innerDiameter: length_t, numRings: number) {
|
||||
if (outerDiameter.u !== innerDiameter.u) {
|
||||
throw new Error("diameter units must match!")
|
||||
}
|
||||
const thickness = (((outerDiameter.l - innerDiameter.l) / 2) / numRings);
|
||||
return {
|
||||
l: Math.PI * (Math.pow(outerDiameter.l, 2) / 4) - (Math.pow(innerDiameter.l, 2) / 4) / thickness,
|
||||
u: innerDiameter.u,
|
||||
};
|
||||
}
|
@ -1,6 +1,8 @@
|
||||
import uuid from "react-native-uuid";
|
||||
import convert, { Area, Length } from "convert";
|
||||
import { Area } from "convert";
|
||||
import { Transform } from "class-transformer";
|
||||
import { dimensions_t, area_t } from "./dimensions";
|
||||
import { matchDimensions } from "./dimensions";
|
||||
|
||||
export type Id = string;
|
||||
|
||||
@ -15,55 +17,18 @@ export type ProductAttributes = {
|
||||
currency?: Currency,
|
||||
// [index:string]: any,
|
||||
}
|
||||
export type length_t = {
|
||||
l: number, u: Length
|
||||
}
|
||||
|
||||
export type area_t = length_t & {
|
||||
w: number,
|
||||
}
|
||||
|
||||
export type dimensions_t = area_t | length_t;
|
||||
|
||||
export type ProductData = {
|
||||
id?: Id,
|
||||
pricePerUnit: number,
|
||||
dimensions: dimensions_t,
|
||||
attributes?: ProductAttributes,
|
||||
};
|
||||
|
||||
|
||||
export type product_type_t = "area" | "length";
|
||||
|
||||
export const isArea = (d: dimensions_t) => ("width" in d);
|
||||
export const isLength = (d: dimensions_t) => (!("width" in d));
|
||||
export const dimensionType = (d: dimensions_t) => isArea(d) ? "area" : "length"
|
||||
|
||||
export function matchDimensions(d1: dimensions_t, d2: dimensions_t) {
|
||||
if (!
|
||||
(
|
||||
(isArea(d1) && isArea(d2)) ||
|
||||
(isLength(d1) && isLength(d2))
|
||||
)
|
||||
) {
|
||||
throw new Error(`Dimension mismatch: ${JSON.stringify(d1)} / ${JSON.stringify(d1)}`);
|
||||
}
|
||||
|
||||
return {
|
||||
l: convert(d1.l, d1.u).to(d2.u),
|
||||
u: d2.u,
|
||||
...(
|
||||
"w" in d1 ?
|
||||
{ w: convert(d1.w, d1.u).to(d2.u), }
|
||||
: {}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export function dimensionArea(d: dimensions_t) {
|
||||
return "w" in d ? d.w * d.l : 0;
|
||||
}
|
||||
|
||||
export type ProductData = {
|
||||
id?: Id;
|
||||
pricePerUnit: number;
|
||||
dimensions: dimensions_t;
|
||||
attributes?: ProductAttributes;
|
||||
};
|
||||
|
||||
|
||||
export class Product {
|
||||
|
||||
public id?: Id;
|
||||
|
@ -2,7 +2,8 @@ import { RenderOptions, render } from "@testing-library/react-native";
|
||||
import { PropsWithChildren, ReactElement } from "react";
|
||||
import { Provider } from "react-redux";
|
||||
import { setupStore, RootState } from "@/app/store";
|
||||
import { Product, ProductData } from "@/lib/product";
|
||||
import { Product } from "@/lib/product";
|
||||
import { ProductData } from "./product";
|
||||
|
||||
export interface ExtendedRenderOptions extends Omit<RenderOptions, 'queries'> {
|
||||
preloadedState?: Partial<RootState>;
|
||||
|
@ -24,6 +24,7 @@
|
||||
"@reduxjs/toolkit": "^2.2.6",
|
||||
"class-transformer": "^0.5.1",
|
||||
"convert": "^5.3.0",
|
||||
"dayjs": "^1.11.11",
|
||||
"expo": "~51.0.18",
|
||||
"expo-asset": "^10.0.10",
|
||||
"expo-constants": "~16.0.2",
|
||||
|
@ -38,6 +38,9 @@ dependencies:
|
||||
convert:
|
||||
specifier: ^5.3.0
|
||||
version: 5.3.0
|
||||
dayjs:
|
||||
specifier: ^1.11.11
|
||||
version: 1.11.11
|
||||
expo:
|
||||
specifier: ~51.0.18
|
||||
version: 51.0.18(@babel/core@7.24.7)(@babel/preset-env@7.24.7)
|
||||
|