Compare commits

...

2 Commits

Author SHA1 Message Date
Jordan
49266bbc97 work on area rug functions. 2024-07-12 06:14:14 -07:00
Jordan
f6a151337a add screenshots and images for docs. add icon. 2024-07-05 15:00:08 -07:00
24 changed files with 215 additions and 93 deletions

107
README.md
View File

@ -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
```

View File

@ -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'];

Binary file not shown.

After

Width:  |  Height:  |  Size: 259 KiB

View File

@ -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
View 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: {
},
})

View File

@ -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";

View File

@ -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';

View File

@ -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";

View File

@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 722 B

BIN
doc/images/icons/scale.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 980 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

View File

@ -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';

View 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
View 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,
};
}

View File

@ -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;

View File

@ -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>;

View File

@ -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",

View File

@ -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)