good enough for government (or habitat) work

This commit is contained in:
Jordan 2024-07-01 12:23:45 -07:00
parent 379f43dcd9
commit ecdc9db085
18 changed files with 325 additions and 169 deletions

View File

@ -2,11 +2,20 @@ import { Product } from "@/lib/product";
export const products = [ export const products = [
// Sheet goods // Sheet goods
new Product(25, {l: 4, w : 8, u: "feet"}, { name: "Plywood" }), new Product(25, {l: 4, w : 8, u: "ft"}, { name: "Plywood 1/4\"" }),
new Product(35, {l: 4, w : 8, u: "feet"}, { name: "MDF" }), new Product(20, {l: 4, w : 8, u: "ft"}, { name: "Plywood 1/2\"" }),
new Product(40, {l: 4, w : 8, u: "feet"}, { name: "OSB" }), new Product(25, {l: 4, w : 8, u: "ft"}, { name: "Plywood 3/4\"" }),
new Product(45, {l: 4, w : 8, u: "feet"}, { name: "Sheetrock" }), new Product(5, {l: 4, w : 8, u: "ft"}, { name: "Thin Panel Board" }),
// Beams and trim new Product(10, {l: 4, w : 8, u: "ft"}, { name: "Sheetrock" }),
new Product(1, {l: 0.50, u : "feet"}, { name: "trim 3 inches" }), new Product(15, {l: 4, w : 8, u: "ft"}, { name: "OSB / Particle" }),
new Product(1, {l: 0.75, u : "feet"}, { name: "trim 3 inches" }), new Product(20, {l: 4, w : 8, u: "ft"}, { name: "MDF" }),
new Product(15, {l: 4, w : 8, u: "ft"}, { name: "Pegboard" }),
new Product(5, {l: 3, w : 5, u: "ft"}, { name: "Cement" }),
// trim
new Product(1, {l: 0.50, u : "ft"}, { name: "trim <= 3 inches" }),
new Product(1, {l: 0.75, u : "ft"}, { name: "trim > 3 inches" }),
// siding
new Product(1, {l: 1, u: "ft"}, {name: "house siding"}),
new Product(1, {l: 1, u: "ft"}, {name: "metal / shelf bars"}),
new Product(0.5, {l: 1, u: "ft"}, {name: "gutter spouts"}),
]; ];

View File

@ -22,9 +22,9 @@ export default function TabLayout() {
<Tabs.Screen <Tabs.Screen
name="index" name="index"
options={{ options={{
title: 'Conversion', title: 'Home Screen',
tabBarIcon: ({ color, focused }) => ( tabBarIcon: ({ color, focused }) => (
<TabBarIcon name={focused ? 'recording' : 'recording-outline'} color={color} /> <TabBarIcon name={focused ? 'scale' : 'scale-outline'} color={color} />
), ),
}} }}
/> />
@ -33,7 +33,7 @@ export default function TabLayout() {
options={{ options={{
title: 'Products', title: 'Products',
tabBarIcon: ({ color, focused }) => ( tabBarIcon: ({ color, focused }) => (
<TabBarIcon name={focused ? 'recording' : 'recording-outline'} color={color} /> <TabBarIcon name={focused ? 'list' : 'list-outline'} color={color} />
), ),
}} }}
/> />

View File

@ -1,13 +1,10 @@
import ProductCalculatorSelector from '@/components/ProductCalculatorSelector'; import ProductCalculatorSelector from '@/components/ProductCalculatorSelector';
import { SafeAreaView, View } from 'react-native'; import { SafeAreaView, Text, View } from 'react-native';
export default function Convert () {
const fallbackImage = require("@/assets/images/board-stock-lightened-blurred.png");
export const HomeScreen = () => {
return ( return (
<SafeAreaView> <View>
<ProductCalculatorSelector /> <ProductCalculatorSelector />
</SafeAreaView> </View>
) )
} }

View File

@ -1,7 +1,7 @@
import { MeasurementInput } from "./MeasurementInput"; import { MeasurementInput } from "./MeasurementInput";
import { area_t, dimensions_t } from "@/lib/product"; import { area_t, dimensions_t } from "@/lib/product";
import { useState } from "react"; import { useState } from "react";
import { View } from "react-native"; import { StyleSheet, View } from "react-native";
export type AreaInputProps = { export type AreaInputProps = {
onMeasurementSet?: (area : dimensions_t) => any, onMeasurementSet?: (area : dimensions_t) => any,
@ -12,7 +12,7 @@ export type AreaInputProps = {
export function AreaInput({onMeasurementSet, lengthLabel, widthLabel, defaultValue} : AreaInputProps) { export function AreaInput({onMeasurementSet, lengthLabel, widthLabel, defaultValue} : AreaInputProps) {
defaultValue = defaultValue || {l: 0, w: 0, u: "foot"} defaultValue = defaultValue || {l: 0, w: 0, u: "ft"}
const [area, setArea] = useState(defaultValue) const [area, setArea] = useState(defaultValue)
@ -39,19 +39,23 @@ export function AreaInput({onMeasurementSet, lengthLabel, widthLabel, defaultVal
} }
return ( return (
<View> <View style={styles.areaInputWrapper}>
<MeasurementInput <MeasurementInput
units={area.u} defaultValue={{l: area.l, u: area.u}}
defaultValue={area.l}
onValueSet={doOnLengthSet} onValueSet={doOnLengthSet}
label={lengthLabel} label={lengthLabel}
/> />
<MeasurementInput <MeasurementInput
units={area.u} defaultValue={{l: area.w, u: area.u}}
defaultValue={area.w}
onValueSet={doOnWidthSet} onValueSet={doOnWidthSet}
label={widthLabel} label={widthLabel}
/> />
</View> </View>
) )
} }
const styles = StyleSheet.create({
areaInputWrapper: {
flexDirection: "row"
}
})

View File

@ -1,5 +1,6 @@
import { dimensions_t, length_t } from "@/lib/product"; import { dimensions_t, length_t } from "@/lib/product";
import { Length } from "convert"; import { Length } from "convert";
import { useState } from "react";
import { StyleSheet, Text, TextInput, View } from "react-native"; import { StyleSheet, Text, TextInput, View } from "react-native";
export type t_length_unit = "foot" | "inch" export type t_length_unit = "foot" | "inch"
@ -11,8 +12,12 @@ export type MeasurementInputProps = {
} }
export function MeasurementInput({onValueSet, defaultValue, label}: MeasurementInputProps) { export function MeasurementInput({onValueSet, defaultValue, label}: MeasurementInputProps) {
1
const [mValue, setMValue] = useState(defaultValue)
const defValue = Number.isNaN(defaultValue.l) ? 0 : defaultValue.l
function doOnValueSet(value : string) { function doOnValueSet(value : string) {
setMValue(mValue);
const iVal = parseFloat(value) || parseInt(value); const iVal = parseFloat(value) || parseInt(value);
onValueSet && onValueSet({ onValueSet && onValueSet({
...defaultValue, ...defaultValue,
@ -20,10 +25,10 @@ export function MeasurementInput({onValueSet, defaultValue, label}: MeasurementI
}) })
} }
const sDefValue = new String(defaultValue.l).valueOf() const sDefValue = new String(defValue).valueOf()
return ( return (
<View> <View style={styles.inputWrapper}>
<TextInput <TextInput
clearTextOnFocus={true} clearTextOnFocus={true}
defaultValue={sDefValue} defaultValue={sDefValue}
@ -32,14 +37,17 @@ export function MeasurementInput({onValueSet, defaultValue, label}: MeasurementI
style={styles.lengthInput} style={styles.lengthInput}
aria-label={label || "Enter measurement"} aria-label={label || "Enter measurement"}
/> />
<Text style={styles.unitHints}>{defaultValue.u}</Text> <Text style={styles.unitHints}>{mValue.u}</Text>
</View> </View>
) )
} }
const styles = StyleSheet.create({ const styles = StyleSheet.create({
inputWrapper: {
alignItems: "flex-start",
flexDirection: "row"
},
unitHints: { unitHints: {
fontSize: 30,
padding: 10, padding: 10,
}, },
lengthInput: { lengthInput: {
@ -48,7 +56,7 @@ const styles = StyleSheet.create({
borderColor: "grey", borderColor: "grey",
padding: 4, padding: 4,
margin: 4, margin: 4,
fontSize: 30, fontSize: 25,
width: 200, width: 100,
}, },
}) })

View File

@ -0,0 +1,44 @@
import { StyleSheet, Text, TextInput, View } from "react-native";
import Slider from '@react-native-community/slider';
import { useState } from "react";
type PercentDamageProps = {
onSetPercentage: (percent: number) => any;
}
export default function PercentDamage ({onSetPercentage} : PercentDamageProps) {
const [damage, setDamage] = useState(0);
function doOnChangeText (val : number) {
setDamage(val);
onSetPercentage(val / 100);
}
return (
<View style={styles.wrapper}>
<Slider
value={damage}
minimumValue={0}
maximumValue={100}
step={5}
onValueChange={doOnChangeText}
/>
<Text style={styles.label}> {damage}% Damage</Text>
</View>
)
}
const styles = StyleSheet.create({
wrapper: {
padding: 5,
},
input: {
flex: 1,
margin: 5,
padding: 5,
borderWidth: 2,
borderColor: "lightgrey",
borderStyle: "solid",
},
label: {
margin: 5,
}
})

View File

@ -32,7 +32,7 @@ export const styles = StyleSheet.create({
bigPrice: { bigPrice: {
alignSelf: "center", alignSelf: "center",
fontSize: 40, fontSize: 40,
marginTop: 100, marginTop: 50,
marginBottom: 100, marginBottom: 50,
} }
}); });

View File

@ -1,48 +1,75 @@
import { useAppSelector } from '@/app/store';
import { selectProducts } from '@/features/product/productSlice';
import { Product, dimensions_t } from '@/lib/product'; import { Product, dimensions_t } from '@/lib/product';
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { View, Text, TextInput, Button, FlatList, StyleSheet } from 'react-native'; import { View, Text, StyleSheet } from 'react-native';
import { TouchableHighlight } from 'react-native-gesture-handler';
import { SafeAreaView } from 'react-native-safe-area-context'; import { SafeAreaView } from 'react-native-safe-area-context';
import PriceDisplay from './Price'; import PriceDisplay from './Price';
import { AreaInput } from './AreaInput'; import { AreaInput } from './AreaInput';
import { MeasurementInput } from './MeasurementInput'; import { MeasurementInput } from './MeasurementInput';
import ProductList from './ProductList'; import ProductList from './ProductList';
import UnitChooser from './UnitChooser'; import UnitChooser from './UnitChooser';
import { Length } from 'convert'; import convert, { Length } from 'convert';
import PercentDamage from './PercentDamange';
export default function ProductCalculatorSelector() { export default function ProductCalculatorSelector() {
const products = useAppSelector(selectProducts);
const [activeProduct, setActiveProduct] = useState(null as Product | null); const [activeProduct, setActiveProduct] = useState(null as Product | null);
const [price, setPrice] = useState(0); const [price, setPrice] = useState(0);
const [measurement, setMeasurement] = useState({l: 0, w: 0, u: "ft"} as dimensions_t); const [measurement, setMeasurement] = useState({ l: 0, w: 0, u: "ft" } as dimensions_t);
const [percentDamage, setPercentDamange] = useState(0.0);
useEffect(function () { useEffect(function () {
const iv = setInterval(function () { const iv = setInterval(function () {
if (!(activeProduct && measurement)) return; if (!(activeProduct && measurement)) return;
setPrice( setPrice(
activeProduct.priceFor(measurement) activeProduct.priceFor(measurement, percentDamage)
) )
}, 50); }, 50);
return function () { return function () {
clearInterval(iv); clearInterval(iv);
}; };
}, [activeProduct, measurement]); }, [activeProduct, measurement, percentDamage]);
function onMeasurementSet(dimensions: dimensions_t) { function onMeasurementSet(dimensions: dimensions_t) {
setMeasurement(dimensions); setMeasurement(dimensions);
activeProduct && setPrice(
activeProduct.priceFor(measurement, percentDamage)
)
} }
function onUnitChosen(unit : Length) { function onUnitChosen(unit: Length) {
setMeasurement({ setMeasurement({
...measurement, ...measurement,
u: unit, u: unit,
}); });
} }
function onSetPercentDamage(pct: number) {
setPercentDamange(pct);
}
function onProductSelected(product: Product) {
setActiveProduct(product);
setMeasurement(
{
l: convert(
product.dimensions.l,
product.dimensions.u,
).to(measurement.u),
u: measurement.u,
...(
("w" in measurement && "w" in product.dimensions) ? {
w: convert(
product.dimensions.w,
product.dimensions.u,
).to(measurement.u),
u: measurement.u,
} : {}
)
}
);
}
return ( return (
<SafeAreaView style={styles.wrapper}> <SafeAreaView style={styles.wrapper}>
<PriceDisplay price={price} /> <PriceDisplay price={price} />
@ -73,7 +100,14 @@ export default function ProductCalculatorSelector() {
} }
</View> </View>
</View> </View>
<ProductList onProductSelected={setActiveProduct} /> {activeProduct &&
(<View >
<PercentDamage
onSetPercentage={onSetPercentDamage}
/>
</View>)
}
<ProductList onProductSelected={onProductSelected} />
</SafeAreaView> </SafeAreaView>
); );
} }
@ -81,6 +115,7 @@ export default function ProductCalculatorSelector() {
export const styles = StyleSheet.create({ export const styles = StyleSheet.create({
wrapper: { wrapper: {
overflow: "scroll"
}, },
bigPriceWrapper: { bigPriceWrapper: {
alignContent: "center", alignContent: "center",

View File

@ -39,8 +39,8 @@ export const ProductEditor = ({}) => {
} }
return ( return (
<SafeAreaView> <SafeAreaView style={{overflow: "scroll"}}>
<h1 style={styles.h1}>Edit Products</h1> <Text>Edit Products</Text>
<FlatList <FlatList
data={products} data={products}
keyExtractor={(p, i) => `product-${p.id}`} keyExtractor={(p, i) => `product-${p.id}`}

View File

@ -1,12 +1,10 @@
import { Id, Product, dimensions_t } from "@/lib/product" import { Id, Product, dimensions_t } from "@/lib/product"
import { useState } from "react" import { useState } from "react"
import { Button, FlatList, StyleSheet, Text, Touchable, TouchableHighlight, View } from "react-native" import { Button, FlatList, StyleSheet, Text, TextInput, Touchable, TouchableHighlight, View } from "react-native"
import { ProductAttributeEditor } from "./ProductAttributeEditor"; import { ProductAttributeEditor } from "./ProductAttributeEditor";
import { TextInput } from "react-native-gesture-handler"; import { Dropdown } from 'react-native-element-dropdown';
import { useAppSelector } from "@/app/store";
import rfdc from "rfdc";
import SelectDropdown from "react-native-select-dropdown";
import { Ionicons } from "@expo/vector-icons"; import { Ionicons } from "@expo/vector-icons";
import { Length } from "convert";
export type ProductAddedFunc = () => any; export type ProductAddedFunc = () => any;
export type ProductDeletedFunc = (product_id: Id) => any; export type ProductDeletedFunc = (product_id: Id) => any;
@ -50,9 +48,9 @@ export const ProductEditorItem = (props: ProductEditorItemProps) => {
props.onPriceUpdated && props.onPriceUpdated(product.id, parseFloat(pricePerUnit) || parseInt(pricePerUnit)); props.onPriceUpdated && props.onPriceUpdated(product.id, parseFloat(pricePerUnit) || parseInt(pricePerUnit));
} }
function onUnitsChanged(newUnits: "foot" | "inch") { function onUnitsChanged(newUnits: Length) {
props.onDimensionsUpdated && props.onDimensionsUpdated(product.id, { props.onDimensionsUpdated && props.onDimensionsUpdated(product.id, {
...((product.area || product.length) as dimensions_t), ...(product.dimensions as dimensions_t),
u: newUnits, u: newUnits,
}) })
} }
@ -60,16 +58,16 @@ export const ProductEditorItem = (props: ProductEditorItemProps) => {
function onChangeLength(len: string) { function onChangeLength(len: string) {
const l = parseFloat(len) || parseInt(len); const l = parseFloat(len) || parseInt(len);
props.onDimensionsUpdated && props.onDimensionsUpdated(product.id, { props.onDimensionsUpdated && props.onDimensionsUpdated(product.id, {
...((product.area || product.length) as dimensions_t), ...(product.dimensions as dimensions_t),
l, l,
}) })
} }
function onChangeWidth(width: string) { function onChangeWidth(width: string) {
const w = parseFloat(width) || parseInt(width); const w = width.length == 0 ? null : parseFloat(width) || parseInt(width);
props.onDimensionsUpdated && props.onDimensionsUpdated(product.id, { props.onDimensionsUpdated && props.onDimensionsUpdated(product.id, {
...((product.area || product.length) as dimensions_t), ...(product.dimensions as dimensions_t),
w, ...(w ? {w} : {}),
}) })
} }
@ -77,31 +75,36 @@ export const ProductEditorItem = (props: ProductEditorItemProps) => {
props.onProductDeleted && props.onProductDeleted(product.id); props.onProductDeleted && props.onProductDeleted(product.id);
} }
const length = new String(product.area?.l || product.length?.l || "0") as string; const length = new String(product.dimensions.l || product.dimensions.l || "0") as string;
const width = new String(product.area?.w || "") as string; const width = new String(product.dimensions.w || "") as string;
const dimension = product.area?.u || product.length?.u || "foot"; const dimension = product.dimensions.u || product.dimensions.u || "foot";
return ( return (
<View> <View>
<TouchableHighlight <View style={styles.productListHeader}>
onPress={() => setShowAttributes(!showAttributes)} <TouchableHighlight
aria-label="Product Item" onPress={() => setShowAttributes(!showAttributes)}
style={styles.productItemName} aria-label="Product Item"
> style={styles.productItemName}
<Text style={styles.productNameText}>{product.attributes.name || `Product ${product.id}`}</Text>
</TouchableHighlight>
<TouchableHighlight
onPress={() => onDeleteProduct()}
aria-label="delete product"
style={styles.deleteProductHighlight}
> >
<Ionicons style={styles.deleteProductButton} name="trash-outline" /> <Text style={styles.productNameText}>{product.attributes.name || `Product ${product.id}`}</Text>
</TouchableHighlight> </TouchableHighlight>
<TouchableHighlight
onPress={() => onDeleteProduct()}
aria-label="delete product"
style={styles.deleteProductHighlight}
>
<Ionicons
style={styles.deleteProductButton}
name="trash-outline"
/>
</TouchableHighlight>
</View>
{showAttributes && {showAttributes &&
( (
<View> <View style={styles.detailsWrapper}>
<View style={styles.priceSpecWrapper}> <View style={styles.priceSpecWrapper}>
<Text style={styles.priceLabel}></Text> <Text style={styles.priceLabel}>$</Text>
<TextInput inputMode="decimal" <TextInput inputMode="decimal"
defaultValue={new String(product.pricePerUnit) as string} defaultValue={new String(product.pricePerUnit) as string}
aria-label="price per unit" aria-label="price per unit"
@ -109,14 +112,18 @@ export const ProductEditorItem = (props: ProductEditorItemProps) => {
style={styles.priceInput} style={styles.priceInput}
/> />
<Text style={styles.per}>per</Text> <Text style={styles.per}>per</Text>
<Text style={styles.unitsLabel}>Units: </Text> <Dropdown
<select data={[
onChange={(e) => onUnitsChanged(e.target.value as "foot" | "inch")} {label: "feet", value: "ft"},
{label: "inches", value: "in"},
]}
style={styles.unitsSelect} style={styles.unitsSelect}
aria-label="units"> mode="modal"
<option value="foot" selected={dimension === "foot"}>feet</option> labelField="label"
<option value="inch" selected={dimension === "inch"}>inches</option> valueField="value"
</select> value={product.dimensions.u || "ft"}
onChange={(item) => onUnitsChanged(item.value as Length)}
/>
<TextInput <TextInput
inputMode="decimal" inputMode="decimal"
defaultValue={length} defaultValue={length}
@ -124,7 +131,7 @@ export const ProductEditorItem = (props: ProductEditorItemProps) => {
style={styles.lengthInput} style={styles.lengthInput}
aria-label="length" aria-label="length"
/> />
<Text>x</Text> <Text style={{flex: 1,}}>x</Text>
<TextInput <TextInput
inputMode="decimal" inputMode="decimal"
defaultValue={width} defaultValue={width}
@ -158,33 +165,50 @@ export const ProductEditorItem = (props: ProductEditorItemProps) => {
const styles = StyleSheet.create({ const styles = StyleSheet.create({
deleteProductHighlight: { deleteProductHighlight: {
padding: 5,
borderWidth: 1,
}, },
deleteProductButton: { deleteProductButton: {
fontSize: 20,
},
detailsWrapper: {
}, },
priceSpecWrapper: { priceSpecWrapper: {
flexDirection: "row",
}, },
priceLabel: { priceLabel: {
}, },
priceInput: { priceInput: {
flex: 1,
borderWidth: 2,
borderColor: "lightgrey",
borderStyle: "solid",
}, },
per: { per: {
padding: 5,
}, },
unitsLabel: { unitsLabel: {
}, },
unitsSelect: { unitsSelect: {
flex: 1,
padding: 5,
}, },
lengthInput: { lengthInput: {
flex: 1,
borderWidth: 2,
borderColor: "lightgrey",
borderStyle: "solid",
}, },
widthInput: { widthInput: {
flex: 1,
borderWidth: 2,
borderColor: "lightgrey",
borderStyle: "solid",
},
productListHeader: {
flexDirection: "row",
padding: 5,
}, },
productNameText: { productNameText: {
paddingLeft: 10, paddingLeft: 10,

View File

@ -1,47 +1,46 @@
import { FlatList, StyleSheet, Text, TouchableHighlight } from "react-native"; import { FlatList, ScrollView, StyleSheet, Text, TouchableHighlight } from "react-native";
import { ProductTile } from "./ProductTile"; import { ProductTile } from "./ProductTile";
import { Product } from "@/lib/product"; import { Id, Product } from "@/lib/product";
import { useState } from "react"; import { Key, useEffect, useState } from "react";
import { useSelector } from "react-redux"; import { useSelector } from "react-redux";
import { selectProducts } from "@/features/product/productSlice"; import { selectProducts } from "@/features/product/productSlice";
import { useAppSelector } from "@/app/store";
export type ProductSelectionProps = { export type ProductSelectionProps = {
onProductSelected?: (product : Product) => any; onProductSelected?: (product: Product) => any;
} }
export default function ProductList ({onProductSelected} : ProductSelectionProps) { export default function ProductList({ onProductSelected }: ProductSelectionProps) {
const products = useSelector(selectProducts); const [activeProduct, setActiveProduct] = useState(null as null | Product);
const products = useAppSelector(selectProducts).filter(p => (!!p.dimensions));
const [activeProduct, setActiveProduct] = useState(null as null | Product); function doOnProductSelected(product: Product) {
setActiveProduct(product);
onProductSelected && onProductSelected(product);
}
function doOnProductSelected(product : Product) { return (
setActiveProduct(product); <ScrollView scrollToOverflowEnabled={true}>
onProductSelected && onProductSelected(product); {products.map(product => {
}
return (
<FlatList
data={products}
style={styles.productSelectorFlatList}
renderItem={({ item }) => {
return ( return (
<ProductTile <ProductTile
product={item} product={product}
onProductSelected={doOnProductSelected} onProductSelected={doOnProductSelected}
isActive={activeProduct === item} isActive={activeProduct === product}
/> key={product.id}
/>
); );
} } /> })}
</ScrollView>
) )
} }
const styles = StyleSheet.create({ const styles = StyleSheet.create({
productSelectorFlatList: { productSelectorFlatList: {
padding: 10, padding: 10,
margin: 10, margin: 10,
}, },
}) })

View File

@ -6,49 +6,50 @@ export type OnProductSelectedFunc = (product : Product) => any;
type MyStyle = StyleProp<AnimatedStyle<StyleProp<ViewStyle>>>; type MyStyle = StyleProp<AnimatedStyle<StyleProp<ViewStyle>>>;
type StyleSpec = {
highlight?: MyStyle,
text?: MyStyle,
image?: MyStyle,
}
export type ProductTileProps = { export type ProductTileProps = {
product: (Product), product: (Product),
onProductSelected?: OnProductSelectedFunc, onProductSelected?: OnProductSelectedFunc,
isActive: boolean, isActive: boolean,
style?: {
default?: {
highlight?: MyStyle,
text?: MyStyle,
image?: MyStyle,
}
active?: {
highlight?: MyStyle,
text?: MyStyle,
image?: MyStyle,
}
}
} }
const FALLBACK_IMAGE = ""; const FALLBACK_IMAGE = "";
export function ProductTile ({product, onProductSelected, isActive, style} : ProductTileProps) { export function ProductTile ({product, onProductSelected, isActive} : ProductTileProps) {
const _style = (isActive ? style?.active : style?.default) || {}; const k = isActive ? "active" : "default";
return ( return (
<TouchableHighlight <TouchableHighlight
style={_style.highlight || styles.highlight} style={styles[k].highlight}
onPress={() => onProductSelected && onProductSelected(product)}> onPress={() => onProductSelected && onProductSelected(product)}>
<Text style={_style.text || styles.text}>{product.attributes.name || `Product ${product.id}`} ({product.pricePerUnitDisplay})</Text> <Text style={styles[k].text}>{product.attributes.name || `Product ${product.id}`} ({product.pricePerUnitDisplay})</Text>
</TouchableHighlight> </TouchableHighlight>
); );
} }
const styles = StyleSheet.create({ const styles = {
highlight: { active: StyleSheet.create({
highlight: {
}, padding: 10,
image: { margin: 2,
color: "lightblue",
}, },
text: { text: {
}
}, }),
tile: { default: StyleSheet.create({
highlight: {
}, padding: 10,
}) margin: 2,
backgroundColor: "lightgrey",
},
text: {
}
}),
}

View File

@ -13,7 +13,7 @@ export default function UnitChooser({ choices, onChoicePressed, activeColor, def
const [value, setValue] = useState(choices[0] as Length); const [value, setValue] = useState(choices[0] as Length);
activeColor = activeColor || "lightblue"; activeColor = activeColor || "lightblue";
defaultColor = activeColor || "lightgrey"; defaultColor = defaultColor || "lightgrey";
function doChoiceClicked(choice: Length) { function doChoiceClicked(choice: Length) {
setValue(choice); setValue(choice);

View File

@ -1,4 +1,4 @@
import { render, fireEvent, screen, act } from '@testing-library/react-native'; import { render, fireEvent, screen, act, within } from '@testing-library/react-native';
import { Provider } from 'react-redux'; import { Provider } from 'react-redux';
import ProductCalculatorSelector from '@/components/ProductCalculatorSelector'; import ProductCalculatorSelector from '@/components/ProductCalculatorSelector';
import { renderWithProviders } from '@/lib/rendering'; import { renderWithProviders } from '@/lib/rendering';
@ -61,10 +61,12 @@ describe('ProductCalculatorSelector', () => {
fireEvent.changeText(widthInput, "4"); fireEvent.changeText(widthInput, "4");
}); });
jest.advanceTimersByTime(500); jest.advanceTimersByTime(3000);
const price = mockAreaProduct.priceFor({l: 2, w: 4, u: "ft"}); const price = mockAreaProduct.priceFor({l: 2, w: 4, u: "ft"});
const sPrice = price.toLocaleString(undefined, {maximumFractionDigits: 2, minimumFractionDigits: 2,}); const sPrice = price.toLocaleString(undefined, {maximumFractionDigits: 2, minimumFractionDigits: 2,});
expect(screen.getByLabelText("calculated price").find().toBeTruthy(); const element = screen.getByLabelText("calculated price");
const {getByText} = within(element);
expect(getByText(sPrice)).toBeTruthy();
}); });
}); });

View File

@ -146,10 +146,14 @@ const productsState = createSlice({
} }
}); });
export const selectProducts = (state: RootState) => { export const selectProductsDatas = (state: RootState) => {
return state.products.map(obj => Product.fromObject(obj)); return state.products;
} }
export const selectProducts = createSelector([selectProductsDatas], productsData => {
return productsData.map(d => Product.fromObject(d));
})
export const selectProductIds = createSelector([selectProducts], products => { export const selectProductIds = createSelector([selectProducts], products => {
return products.map(p => p.id); return products.map(p => p.id);
}) })

View File

@ -66,7 +66,7 @@ export function dimensionArea(d: dimensions_t) {
export class Product { export class Product {
public id? : Id; public id?: Id;
constructor(public pricePerUnit: number, public dimensions: dimensions_t, public attributes: ProductAttributes = {}, constructor(public pricePerUnit: number, public dimensions: dimensions_t, public attributes: ProductAttributes = {},
id?: Id, id?: Id,
@ -74,12 +74,13 @@ export class Product {
this.id = id || uuid.v4().toString(); this.id = id || uuid.v4().toString();
} }
public priceFor(dimensions: dimensions_t): number { public priceFor(dimensions: dimensions_t, damage : number): number {
if (Number.isNaN(damage)) damage = 0;
const dim = matchDimensions(dimensions, this.dimensions); const dim = matchDimensions(dimensions, this.dimensions);
return ( return (
dim.w ? dimensionArea(dim) / dimensionArea(this.dimensions) * this.pricePerUnit dim.w ? dimensionArea(dim) / dimensionArea(this.dimensions) * this.pricePerUnit
: (dim.l / this.dimensions.l) * this.pricePerUnit : (dim.l / this.dimensions.l) * this.pricePerUnit
) ) * (1.0 - damage);
} }
get priceDisplay() { get priceDisplay() {
@ -91,9 +92,9 @@ export class Product {
get pricePerUnitDisplay() { get pricePerUnitDisplay() {
const p = this.priceDisplay; const p = this.priceDisplay;
const {l, u} = this.dimensions; const { l, u } = this.dimensions;
const w = (this.dimensions as area_t).w || null; const w = (this.dimensions as area_t).w || null;
const d = w ? `${l}${u} x ${l}${u}` : `${l}${u}`; const d = w ? `${l}${u} x ${w}${u}` : `${l}${u}`;
return `$${p} per ${d}` return `$${p} per ${d}`
} }
@ -113,16 +114,16 @@ export class Product {
); );
} }
get asObject() : ProductData { get asObject(): ProductData {
return { return {
id: this.id, id: this.id,
pricePerUnit: this.pricePerUnit, pricePerUnit: this.pricePerUnit,
dimensions: JSON.parse(JSON.stringify(this.dimensions)), dimensions: this.dimensions,
attributes: this.attributes, attributes: this.attributes,
} }
} }
static fromObject({id, pricePerUnit, dimensions, attributes} : ProductData) { static fromObject({ id, pricePerUnit, dimensions, attributes }: ProductData) {
return new Product( return new Product(
pricePerUnit, pricePerUnit,
dimensions, dimensions,

View File

@ -5,7 +5,7 @@
"scripts": { "scripts": {
"start": "expo start", "start": "expo start",
"reset-project": "node ./scripts/reset-project.js", "reset-project": "node ./scripts/reset-project.js",
"android": "expo start --android", "android": "expo start --android --offline",
"ios": "expo start --ios", "ios": "expo start --ios",
"web": "expo start --web --offline", "web": "expo start --web --offline",
"test": "jest --watchAll", "test": "jest --watchAll",
@ -15,9 +15,11 @@
"@babel/runtime": "^7.24.7", "@babel/runtime": "^7.24.7",
"@expo/vector-icons": "^14.0.2", "@expo/vector-icons": "^14.0.2",
"@react-native-async-storage/async-storage": "^1.23.1", "@react-native-async-storage/async-storage": "^1.23.1",
"@react-native-community/slider": "^4.5.2",
"@react-native/assets-registry": "^0.74.84", "@react-native/assets-registry": "^0.74.84",
"@react-navigation/native": "^6.1.17", "@react-navigation/native": "^6.1.17",
"@reduxjs/toolkit": "^2.2.5", "@reduxjs/toolkit": "^2.2.5",
"@slider": "link:@react-native-community/@slider",
"@testing-library/react-native": "^12.5.1", "@testing-library/react-native": "^12.5.1",
"@types/js-quantities": "^1.6.6", "@types/js-quantities": "^1.6.6",
"class-transformer": "^0.5.1", "class-transformer": "^0.5.1",
@ -38,6 +40,7 @@
"react": "18.2.0", "react": "18.2.0",
"react-dom": "18.2.0", "react-dom": "18.2.0",
"react-native": "0.74.2", "react-native": "0.74.2",
"react-native-element-dropdown": "^2.12.1",
"react-native-flex-grid": "^1.0.4", "react-native-flex-grid": "^1.0.4",
"react-native-gesture-handler": "~2.16.2", "react-native-gesture-handler": "~2.16.2",
"react-native-reanimated": "~3.10.1", "react-native-reanimated": "~3.10.1",

View File

@ -14,6 +14,9 @@ dependencies:
'@react-native-async-storage/async-storage': '@react-native-async-storage/async-storage':
specifier: ^1.23.1 specifier: ^1.23.1
version: 1.23.1(react-native@0.74.2) version: 1.23.1(react-native@0.74.2)
'@react-native-community/slider':
specifier: ^4.5.2
version: 4.5.2
'@react-native/assets-registry': '@react-native/assets-registry':
specifier: ^0.74.84 specifier: ^0.74.84
version: 0.74.84 version: 0.74.84
@ -23,6 +26,9 @@ dependencies:
'@reduxjs/toolkit': '@reduxjs/toolkit':
specifier: ^2.2.5 specifier: ^2.2.5
version: 2.2.5(react-redux@9.1.2)(react@18.2.0) version: 2.2.5(react-redux@9.1.2)(react@18.2.0)
'@slider':
specifier: link:@react-native-community/@slider
version: link:@react-native-community/@slider
'@testing-library/react-native': '@testing-library/react-native':
specifier: ^12.5.1 specifier: ^12.5.1
version: 12.5.1(jest@29.7.0)(react-native@0.74.2)(react-test-renderer@18.2.0)(react@18.2.0) version: 12.5.1(jest@29.7.0)(react-native@0.74.2)(react-test-renderer@18.2.0)(react@18.2.0)
@ -83,6 +89,9 @@ dependencies:
react-native: react-native:
specifier: 0.74.2 specifier: 0.74.2
version: 0.74.2(@babel/core@7.24.7)(@babel/preset-env@7.24.7)(@types/react@18.2.79)(react@18.2.0) version: 0.74.2(@babel/core@7.24.7)(@babel/preset-env@7.24.7)(@types/react@18.2.79)(react@18.2.0)
react-native-element-dropdown:
specifier: ^2.12.1
version: 2.12.1(react-native@0.74.2)(react@18.2.0)
react-native-flex-grid: react-native-flex-grid:
specifier: ^1.0.4 specifier: ^1.0.4
version: 1.0.4(react-native@0.74.2)(react@18.2.0) version: 1.0.4(react-native@0.74.2)(react@18.2.0)
@ -2718,6 +2727,10 @@ packages:
- utf-8-validate - utf-8-validate
dev: false dev: false
/@react-native-community/slider@4.5.2:
resolution: {integrity: sha512-DbFyCyI7rwl0FkBkp0lzEVp+5mNfS5qU/nM2sK2aSguWhj0Odkt1aKHP2iW/ljruOhgS/O4dEixXlne4OdZJDQ==}
dev: false
/@react-native/assets-registry@0.74.84: /@react-native/assets-registry@0.74.84:
resolution: {integrity: sha512-dzUhwyaX04QosWZ8zyaaNB/WYZIdeDN1lcpfQbqiOhZJShRH+FLTDVONE/dqlMQrP+EO7lDqF0RrlIt9lnOCQQ==} resolution: {integrity: sha512-dzUhwyaX04QosWZ8zyaaNB/WYZIdeDN1lcpfQbqiOhZJShRH+FLTDVONE/dqlMQrP+EO7lDqF0RrlIt9lnOCQQ==}
engines: {node: '>=18'} engines: {node: '>=18'}
@ -8771,6 +8784,18 @@ packages:
/react-is@18.3.1: /react-is@18.3.1:
resolution: {integrity: sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==} resolution: {integrity: sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==}
/react-native-element-dropdown@2.12.1(react-native@0.74.2)(react@18.2.0):
resolution: {integrity: sha512-Z3uWNFBoezDEsy9AZJxoDc9DxoAdfeprUjaInmbuzYOk6R0Y0UZ659JIalX20XNvrNRWJUfSZwbM94jWYNsIyw==}
engines: {node: '>= 16.0.0'}
peerDependencies:
react: '*'
react-native: '*'
dependencies:
lodash: 4.17.21
react: 18.2.0
react-native: 0.74.2(@babel/core@7.24.7)(@babel/preset-env@7.24.7)(@types/react@18.2.79)(react@18.2.0)
dev: false
/react-native-flex-grid@1.0.4(react-native@0.74.2)(react@18.2.0): /react-native-flex-grid@1.0.4(react-native@0.74.2)(react@18.2.0):
resolution: {integrity: sha512-VFadQy3JpgBM2fNsn7W/TdebZ0JNeZgedxPJ0Xi6o+HQJU8j43YGUsGot72rEMWlzaAJCGQnMQXkW9vX+E2n5w==} resolution: {integrity: sha512-VFadQy3JpgBM2fNsn7W/TdebZ0JNeZgedxPJ0Xi6o+HQJU8j43YGUsGot72rEMWlzaAJCGQnMQXkW9vX+E2n5w==}
peerDependencies: peerDependencies: