good enough for government (or habitat) work
This commit is contained in:
parent
379f43dcd9
commit
ecdc9db085
@ -2,11 +2,20 @@ import { Product } from "@/lib/product";
|
||||
|
||||
export const products = [
|
||||
// Sheet goods
|
||||
new Product(25, {l: 4, w : 8, u: "feet"}, { name: "Plywood" }),
|
||||
new Product(35, {l: 4, w : 8, u: "feet"}, { name: "MDF" }),
|
||||
new Product(40, {l: 4, w : 8, u: "feet"}, { name: "OSB" }),
|
||||
new Product(45, {l: 4, w : 8, u: "feet"}, { name: "Sheetrock" }),
|
||||
// Beams and trim
|
||||
new Product(1, {l: 0.50, u : "feet"}, { name: "trim 3 inches" }),
|
||||
new Product(1, {l: 0.75, u : "feet"}, { name: "trim 3 inches" }),
|
||||
new Product(25, {l: 4, w : 8, u: "ft"}, { name: "Plywood 1/4\"" }),
|
||||
new Product(20, {l: 4, w : 8, u: "ft"}, { name: "Plywood 1/2\"" }),
|
||||
new Product(25, {l: 4, w : 8, u: "ft"}, { name: "Plywood 3/4\"" }),
|
||||
new Product(5, {l: 4, w : 8, u: "ft"}, { name: "Thin Panel Board" }),
|
||||
new Product(10, {l: 4, w : 8, u: "ft"}, { name: "Sheetrock" }),
|
||||
new Product(15, {l: 4, w : 8, u: "ft"}, { name: "OSB / Particle" }),
|
||||
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"}),
|
||||
];
|
@ -22,9 +22,9 @@ export default function TabLayout() {
|
||||
<Tabs.Screen
|
||||
name="index"
|
||||
options={{
|
||||
title: 'Conversion',
|
||||
title: 'Home Screen',
|
||||
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={{
|
||||
title: 'Products',
|
||||
tabBarIcon: ({ color, focused }) => (
|
||||
<TabBarIcon name={focused ? 'recording' : 'recording-outline'} color={color} />
|
||||
<TabBarIcon name={focused ? 'list' : 'list-outline'} color={color} />
|
||||
),
|
||||
}}
|
||||
/>
|
||||
|
@ -1,13 +1,10 @@
|
||||
import ProductCalculatorSelector from '@/components/ProductCalculatorSelector';
|
||||
import { SafeAreaView, View } from 'react-native';
|
||||
import { SafeAreaView, Text, View } from 'react-native';
|
||||
|
||||
|
||||
const fallbackImage = require("@/assets/images/board-stock-lightened-blurred.png");
|
||||
|
||||
export const HomeScreen = () => {
|
||||
export default function Convert () {
|
||||
return (
|
||||
<SafeAreaView>
|
||||
<View>
|
||||
<ProductCalculatorSelector />
|
||||
</SafeAreaView>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
@ -1,7 +1,7 @@
|
||||
import { MeasurementInput } from "./MeasurementInput";
|
||||
import { area_t, dimensions_t } from "@/lib/product";
|
||||
import { useState } from "react";
|
||||
import { View } from "react-native";
|
||||
import { StyleSheet, View } from "react-native";
|
||||
|
||||
export type AreaInputProps = {
|
||||
onMeasurementSet?: (area : dimensions_t) => any,
|
||||
@ -12,7 +12,7 @@ export type 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)
|
||||
|
||||
@ -39,19 +39,23 @@ export function AreaInput({onMeasurementSet, lengthLabel, widthLabel, defaultVal
|
||||
}
|
||||
|
||||
return (
|
||||
<View>
|
||||
<View style={styles.areaInputWrapper}>
|
||||
<MeasurementInput
|
||||
units={area.u}
|
||||
defaultValue={area.l}
|
||||
defaultValue={{l: area.l, u: area.u}}
|
||||
onValueSet={doOnLengthSet}
|
||||
label={lengthLabel}
|
||||
/>
|
||||
<MeasurementInput
|
||||
units={area.u}
|
||||
defaultValue={area.w}
|
||||
defaultValue={{l: area.w, u: area.u}}
|
||||
onValueSet={doOnWidthSet}
|
||||
label={widthLabel}
|
||||
/>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
areaInputWrapper: {
|
||||
flexDirection: "row"
|
||||
}
|
||||
})
|
@ -1,5 +1,6 @@
|
||||
import { dimensions_t, length_t } from "@/lib/product";
|
||||
import { Length } from "convert";
|
||||
import { useState } from "react";
|
||||
import { StyleSheet, Text, TextInput, View } from "react-native";
|
||||
|
||||
export type t_length_unit = "foot" | "inch"
|
||||
@ -11,8 +12,12 @@ export type 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) {
|
||||
setMValue(mValue);
|
||||
const iVal = parseFloat(value) || parseInt(value);
|
||||
onValueSet && onValueSet({
|
||||
...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 (
|
||||
<View>
|
||||
<View style={styles.inputWrapper}>
|
||||
<TextInput
|
||||
clearTextOnFocus={true}
|
||||
defaultValue={sDefValue}
|
||||
@ -32,14 +37,17 @@ export function MeasurementInput({onValueSet, defaultValue, label}: MeasurementI
|
||||
style={styles.lengthInput}
|
||||
aria-label={label || "Enter measurement"}
|
||||
/>
|
||||
<Text style={styles.unitHints}>{defaultValue.u}</Text>
|
||||
<Text style={styles.unitHints}>{mValue.u}</Text>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
inputWrapper: {
|
||||
alignItems: "flex-start",
|
||||
flexDirection: "row"
|
||||
},
|
||||
unitHints: {
|
||||
fontSize: 30,
|
||||
padding: 10,
|
||||
},
|
||||
lengthInput: {
|
||||
@ -48,7 +56,7 @@ const styles = StyleSheet.create({
|
||||
borderColor: "grey",
|
||||
padding: 4,
|
||||
margin: 4,
|
||||
fontSize: 30,
|
||||
width: 200,
|
||||
fontSize: 25,
|
||||
width: 100,
|
||||
},
|
||||
})
|
44
components/PercentDamange.tsx
Normal file
44
components/PercentDamange.tsx
Normal 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,
|
||||
}
|
||||
})
|
@ -32,7 +32,7 @@ export const styles = StyleSheet.create({
|
||||
bigPrice: {
|
||||
alignSelf: "center",
|
||||
fontSize: 40,
|
||||
marginTop: 100,
|
||||
marginBottom: 100,
|
||||
marginTop: 50,
|
||||
marginBottom: 50,
|
||||
}
|
||||
});
|
@ -1,48 +1,75 @@
|
||||
import { useAppSelector } from '@/app/store';
|
||||
import { selectProducts } from '@/features/product/productSlice';
|
||||
import { Product, dimensions_t } from '@/lib/product';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { View, Text, TextInput, Button, FlatList, StyleSheet } from 'react-native';
|
||||
import { TouchableHighlight } from 'react-native-gesture-handler';
|
||||
import { View, Text, StyleSheet } from 'react-native';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
import PriceDisplay from './Price';
|
||||
import { AreaInput } from './AreaInput';
|
||||
import { MeasurementInput } from './MeasurementInput';
|
||||
import ProductList from './ProductList';
|
||||
import UnitChooser from './UnitChooser';
|
||||
import { Length } from 'convert';
|
||||
import convert, { Length } from 'convert';
|
||||
import PercentDamage from './PercentDamange';
|
||||
|
||||
|
||||
export default function ProductCalculatorSelector() {
|
||||
|
||||
const products = useAppSelector(selectProducts);
|
||||
const [activeProduct, setActiveProduct] = useState(null as Product | null);
|
||||
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 () {
|
||||
const iv = setInterval(function () {
|
||||
if (!(activeProduct && measurement)) return;
|
||||
setPrice(
|
||||
activeProduct.priceFor(measurement)
|
||||
activeProduct.priceFor(measurement, percentDamage)
|
||||
)
|
||||
}, 50);
|
||||
return function () {
|
||||
clearInterval(iv);
|
||||
};
|
||||
}, [activeProduct, measurement]);
|
||||
}, [activeProduct, measurement, percentDamage]);
|
||||
|
||||
function onMeasurementSet(dimensions: dimensions_t) {
|
||||
setMeasurement(dimensions);
|
||||
activeProduct && setPrice(
|
||||
activeProduct.priceFor(measurement, percentDamage)
|
||||
)
|
||||
}
|
||||
|
||||
function onUnitChosen(unit : Length) {
|
||||
function onUnitChosen(unit: Length) {
|
||||
setMeasurement({
|
||||
...measurement,
|
||||
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 (
|
||||
<SafeAreaView style={styles.wrapper}>
|
||||
<PriceDisplay price={price} />
|
||||
@ -73,7 +100,14 @@ export default function ProductCalculatorSelector() {
|
||||
}
|
||||
</View>
|
||||
</View>
|
||||
<ProductList onProductSelected={setActiveProduct} />
|
||||
{activeProduct &&
|
||||
(<View >
|
||||
<PercentDamage
|
||||
onSetPercentage={onSetPercentDamage}
|
||||
/>
|
||||
</View>)
|
||||
}
|
||||
<ProductList onProductSelected={onProductSelected} />
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
@ -81,6 +115,7 @@ export default function ProductCalculatorSelector() {
|
||||
|
||||
export const styles = StyleSheet.create({
|
||||
wrapper: {
|
||||
overflow: "scroll"
|
||||
},
|
||||
bigPriceWrapper: {
|
||||
alignContent: "center",
|
||||
|
@ -39,8 +39,8 @@ export const ProductEditor = ({}) => {
|
||||
}
|
||||
|
||||
return (
|
||||
<SafeAreaView>
|
||||
<h1 style={styles.h1}>Edit Products</h1>
|
||||
<SafeAreaView style={{overflow: "scroll"}}>
|
||||
<Text>Edit Products</Text>
|
||||
<FlatList
|
||||
data={products}
|
||||
keyExtractor={(p, i) => `product-${p.id}`}
|
||||
|
@ -1,12 +1,10 @@
|
||||
import { Id, Product, dimensions_t } from "@/lib/product"
|
||||
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 { TextInput } from "react-native-gesture-handler";
|
||||
import { useAppSelector } from "@/app/store";
|
||||
import rfdc from "rfdc";
|
||||
import SelectDropdown from "react-native-select-dropdown";
|
||||
import { Dropdown } from 'react-native-element-dropdown';
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { Length } from "convert";
|
||||
|
||||
export type ProductAddedFunc = () => 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));
|
||||
}
|
||||
|
||||
function onUnitsChanged(newUnits: "foot" | "inch") {
|
||||
function onUnitsChanged(newUnits: Length) {
|
||||
props.onDimensionsUpdated && props.onDimensionsUpdated(product.id, {
|
||||
...((product.area || product.length) as dimensions_t),
|
||||
...(product.dimensions as dimensions_t),
|
||||
u: newUnits,
|
||||
})
|
||||
}
|
||||
@ -60,16 +58,16 @@ export const ProductEditorItem = (props: ProductEditorItemProps) => {
|
||||
function onChangeLength(len: string) {
|
||||
const l = parseFloat(len) || parseInt(len);
|
||||
props.onDimensionsUpdated && props.onDimensionsUpdated(product.id, {
|
||||
...((product.area || product.length) as dimensions_t),
|
||||
...(product.dimensions as dimensions_t),
|
||||
l,
|
||||
})
|
||||
}
|
||||
|
||||
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, {
|
||||
...((product.area || product.length) as dimensions_t),
|
||||
w,
|
||||
...(product.dimensions as dimensions_t),
|
||||
...(w ? {w} : {}),
|
||||
})
|
||||
}
|
||||
|
||||
@ -77,31 +75,36 @@ export const ProductEditorItem = (props: ProductEditorItemProps) => {
|
||||
props.onProductDeleted && props.onProductDeleted(product.id);
|
||||
}
|
||||
|
||||
const length = new String(product.area?.l || product.length?.l || "0") as string;
|
||||
const width = new String(product.area?.w || "") as string;
|
||||
const dimension = product.area?.u || product.length?.u || "foot";
|
||||
const length = new String(product.dimensions.l || product.dimensions.l || "0") as string;
|
||||
const width = new String(product.dimensions.w || "") as string;
|
||||
const dimension = product.dimensions.u || product.dimensions.u || "foot";
|
||||
|
||||
return (
|
||||
<View>
|
||||
<TouchableHighlight
|
||||
onPress={() => setShowAttributes(!showAttributes)}
|
||||
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}
|
||||
<View style={styles.productListHeader}>
|
||||
<TouchableHighlight
|
||||
onPress={() => setShowAttributes(!showAttributes)}
|
||||
aria-label="Product Item"
|
||||
style={styles.productItemName}
|
||||
>
|
||||
<Ionicons style={styles.deleteProductButton} name="trash-outline" />
|
||||
</TouchableHighlight>
|
||||
<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"
|
||||
/>
|
||||
</TouchableHighlight>
|
||||
</View>
|
||||
{showAttributes &&
|
||||
(
|
||||
<View>
|
||||
<View style={styles.detailsWrapper}>
|
||||
<View style={styles.priceSpecWrapper}>
|
||||
<Text style={styles.priceLabel}></Text>
|
||||
<Text style={styles.priceLabel}>$</Text>
|
||||
<TextInput inputMode="decimal"
|
||||
defaultValue={new String(product.pricePerUnit) as string}
|
||||
aria-label="price per unit"
|
||||
@ -109,14 +112,18 @@ export const ProductEditorItem = (props: ProductEditorItemProps) => {
|
||||
style={styles.priceInput}
|
||||
/>
|
||||
<Text style={styles.per}>per</Text>
|
||||
<Text style={styles.unitsLabel}>Units: </Text>
|
||||
<select
|
||||
onChange={(e) => onUnitsChanged(e.target.value as "foot" | "inch")}
|
||||
<Dropdown
|
||||
data={[
|
||||
{label: "feet", value: "ft"},
|
||||
{label: "inches", value: "in"},
|
||||
]}
|
||||
style={styles.unitsSelect}
|
||||
aria-label="units">
|
||||
<option value="foot" selected={dimension === "foot"}>feet</option>
|
||||
<option value="inch" selected={dimension === "inch"}>inches</option>
|
||||
</select>
|
||||
mode="modal"
|
||||
labelField="label"
|
||||
valueField="value"
|
||||
value={product.dimensions.u || "ft"}
|
||||
onChange={(item) => onUnitsChanged(item.value as Length)}
|
||||
/>
|
||||
<TextInput
|
||||
inputMode="decimal"
|
||||
defaultValue={length}
|
||||
@ -124,7 +131,7 @@ export const ProductEditorItem = (props: ProductEditorItemProps) => {
|
||||
style={styles.lengthInput}
|
||||
aria-label="length"
|
||||
/>
|
||||
<Text>x</Text>
|
||||
<Text style={{flex: 1,}}>x</Text>
|
||||
<TextInput
|
||||
inputMode="decimal"
|
||||
defaultValue={width}
|
||||
@ -158,33 +165,50 @@ export const ProductEditorItem = (props: ProductEditorItemProps) => {
|
||||
const styles = StyleSheet.create({
|
||||
deleteProductHighlight: {
|
||||
|
||||
padding: 5,
|
||||
borderWidth: 1,
|
||||
},
|
||||
deleteProductButton: {
|
||||
fontSize: 20,
|
||||
},
|
||||
detailsWrapper: {
|
||||
|
||||
},
|
||||
priceSpecWrapper: {
|
||||
|
||||
flexDirection: "row",
|
||||
},
|
||||
priceLabel: {
|
||||
|
||||
},
|
||||
priceInput: {
|
||||
|
||||
flex: 1,
|
||||
borderWidth: 2,
|
||||
borderColor: "lightgrey",
|
||||
borderStyle: "solid",
|
||||
},
|
||||
per: {
|
||||
|
||||
padding: 5,
|
||||
},
|
||||
unitsLabel: {
|
||||
|
||||
unitsLabel: {
|
||||
},
|
||||
unitsSelect: {
|
||||
|
||||
flex: 1,
|
||||
padding: 5,
|
||||
},
|
||||
lengthInput: {
|
||||
|
||||
flex: 1,
|
||||
borderWidth: 2,
|
||||
borderColor: "lightgrey",
|
||||
borderStyle: "solid",
|
||||
},
|
||||
widthInput: {
|
||||
|
||||
flex: 1,
|
||||
borderWidth: 2,
|
||||
borderColor: "lightgrey",
|
||||
borderStyle: "solid",
|
||||
},
|
||||
productListHeader: {
|
||||
flexDirection: "row",
|
||||
padding: 5,
|
||||
},
|
||||
productNameText: {
|
||||
paddingLeft: 10,
|
||||
|
@ -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 { Product } from "@/lib/product";
|
||||
import { useState } from "react";
|
||||
import { Id, Product } from "@/lib/product";
|
||||
import { Key, useEffect, useState } from "react";
|
||||
import { useSelector } from "react-redux";
|
||||
import { selectProducts } from "@/features/product/productSlice";
|
||||
import { useAppSelector } from "@/app/store";
|
||||
|
||||
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) {
|
||||
setActiveProduct(product);
|
||||
onProductSelected && onProductSelected(product);
|
||||
}
|
||||
|
||||
return (
|
||||
<FlatList
|
||||
data={products}
|
||||
style={styles.productSelectorFlatList}
|
||||
renderItem={({ item }) => {
|
||||
return (
|
||||
<ScrollView scrollToOverflowEnabled={true}>
|
||||
{products.map(product => {
|
||||
return (
|
||||
<ProductTile
|
||||
product={item}
|
||||
onProductSelected={doOnProductSelected}
|
||||
isActive={activeProduct === item}
|
||||
/>
|
||||
<ProductTile
|
||||
product={product}
|
||||
onProductSelected={doOnProductSelected}
|
||||
isActive={activeProduct === product}
|
||||
key={product.id}
|
||||
/>
|
||||
);
|
||||
} } />
|
||||
|
||||
)
|
||||
})}
|
||||
</ScrollView>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
|
||||
productSelectorFlatList: {
|
||||
padding: 10,
|
||||
margin: 10,
|
||||
},
|
||||
|
||||
|
||||
productSelectorFlatList: {
|
||||
padding: 10,
|
||||
margin: 10,
|
||||
},
|
||||
|
||||
})
|
@ -6,49 +6,50 @@ export type OnProductSelectedFunc = (product : Product) => any;
|
||||
|
||||
type MyStyle = StyleProp<AnimatedStyle<StyleProp<ViewStyle>>>;
|
||||
|
||||
type StyleSpec = {
|
||||
highlight?: MyStyle,
|
||||
text?: MyStyle,
|
||||
image?: MyStyle,
|
||||
}
|
||||
|
||||
|
||||
export type ProductTileProps = {
|
||||
product: (Product),
|
||||
onProductSelected?: OnProductSelectedFunc,
|
||||
isActive: boolean,
|
||||
style?: {
|
||||
default?: {
|
||||
highlight?: MyStyle,
|
||||
text?: MyStyle,
|
||||
image?: MyStyle,
|
||||
}
|
||||
active?: {
|
||||
highlight?: MyStyle,
|
||||
text?: MyStyle,
|
||||
image?: MyStyle,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const FALLBACK_IMAGE = "";
|
||||
|
||||
export function ProductTile ({product, onProductSelected, isActive, style} : ProductTileProps) {
|
||||
const _style = (isActive ? style?.active : style?.default) || {};
|
||||
export function ProductTile ({product, onProductSelected, isActive} : ProductTileProps) {
|
||||
const k = isActive ? "active" : "default";
|
||||
return (
|
||||
|
||||
<TouchableHighlight
|
||||
style={_style.highlight || styles.highlight}
|
||||
style={styles[k].highlight}
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
highlight: {
|
||||
|
||||
},
|
||||
image: {
|
||||
|
||||
},
|
||||
text: {
|
||||
|
||||
},
|
||||
tile: {
|
||||
|
||||
},
|
||||
})
|
||||
const styles = {
|
||||
active: StyleSheet.create({
|
||||
highlight: {
|
||||
padding: 10,
|
||||
margin: 2,
|
||||
color: "lightblue",
|
||||
},
|
||||
text: {
|
||||
}
|
||||
}),
|
||||
default: StyleSheet.create({
|
||||
highlight: {
|
||||
padding: 10,
|
||||
margin: 2,
|
||||
backgroundColor: "lightgrey",
|
||||
},
|
||||
text: {
|
||||
}
|
||||
}),
|
||||
}
|
@ -13,7 +13,7 @@ export default function UnitChooser({ choices, onChoicePressed, activeColor, def
|
||||
const [value, setValue] = useState(choices[0] as Length);
|
||||
|
||||
activeColor = activeColor || "lightblue";
|
||||
defaultColor = activeColor || "lightgrey";
|
||||
defaultColor = defaultColor || "lightgrey";
|
||||
|
||||
function doChoiceClicked(choice: Length) {
|
||||
setValue(choice);
|
||||
|
@ -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 ProductCalculatorSelector from '@/components/ProductCalculatorSelector';
|
||||
import { renderWithProviders } from '@/lib/rendering';
|
||||
@ -61,10 +61,12 @@ describe('ProductCalculatorSelector', () => {
|
||||
fireEvent.changeText(widthInput, "4");
|
||||
});
|
||||
|
||||
jest.advanceTimersByTime(500);
|
||||
jest.advanceTimersByTime(3000);
|
||||
|
||||
const price = mockAreaProduct.priceFor({l: 2, w: 4, u: "ft"});
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
@ -146,10 +146,14 @@ const productsState = createSlice({
|
||||
}
|
||||
});
|
||||
|
||||
export const selectProducts = (state: RootState) => {
|
||||
return state.products.map(obj => Product.fromObject(obj));
|
||||
export const selectProductsDatas = (state: RootState) => {
|
||||
return state.products;
|
||||
}
|
||||
|
||||
export const selectProducts = createSelector([selectProductsDatas], productsData => {
|
||||
return productsData.map(d => Product.fromObject(d));
|
||||
})
|
||||
|
||||
export const selectProductIds = createSelector([selectProducts], products => {
|
||||
return products.map(p => p.id);
|
||||
})
|
||||
|
@ -66,7 +66,7 @@ export function dimensionArea(d: dimensions_t) {
|
||||
|
||||
export class Product {
|
||||
|
||||
public id? : Id;
|
||||
public id?: Id;
|
||||
|
||||
constructor(public pricePerUnit: number, public dimensions: dimensions_t, public attributes: ProductAttributes = {},
|
||||
id?: Id,
|
||||
@ -74,12 +74,13 @@ export class Product {
|
||||
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);
|
||||
return (
|
||||
dim.w ? dimensionArea(dim) / dimensionArea(this.dimensions) * this.pricePerUnit
|
||||
: (dim.l / this.dimensions.l) * this.pricePerUnit
|
||||
)
|
||||
) * (1.0 - damage);
|
||||
}
|
||||
|
||||
get priceDisplay() {
|
||||
@ -91,9 +92,9 @@ export class Product {
|
||||
|
||||
get pricePerUnitDisplay() {
|
||||
const p = this.priceDisplay;
|
||||
const {l, u} = this.dimensions;
|
||||
const { l, u } = this.dimensions;
|
||||
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}`
|
||||
}
|
||||
|
||||
@ -113,16 +114,16 @@ export class Product {
|
||||
);
|
||||
}
|
||||
|
||||
get asObject() : ProductData {
|
||||
get asObject(): ProductData {
|
||||
return {
|
||||
id: this.id,
|
||||
pricePerUnit: this.pricePerUnit,
|
||||
dimensions: JSON.parse(JSON.stringify(this.dimensions)),
|
||||
dimensions: this.dimensions,
|
||||
attributes: this.attributes,
|
||||
}
|
||||
}
|
||||
|
||||
static fromObject({id, pricePerUnit, dimensions, attributes} : ProductData) {
|
||||
static fromObject({ id, pricePerUnit, dimensions, attributes }: ProductData) {
|
||||
return new Product(
|
||||
pricePerUnit,
|
||||
dimensions,
|
||||
|
@ -5,7 +5,7 @@
|
||||
"scripts": {
|
||||
"start": "expo start",
|
||||
"reset-project": "node ./scripts/reset-project.js",
|
||||
"android": "expo start --android",
|
||||
"android": "expo start --android --offline",
|
||||
"ios": "expo start --ios",
|
||||
"web": "expo start --web --offline",
|
||||
"test": "jest --watchAll",
|
||||
@ -15,9 +15,11 @@
|
||||
"@babel/runtime": "^7.24.7",
|
||||
"@expo/vector-icons": "^14.0.2",
|
||||
"@react-native-async-storage/async-storage": "^1.23.1",
|
||||
"@react-native-community/slider": "^4.5.2",
|
||||
"@react-native/assets-registry": "^0.74.84",
|
||||
"@react-navigation/native": "^6.1.17",
|
||||
"@reduxjs/toolkit": "^2.2.5",
|
||||
"@slider": "link:@react-native-community/@slider",
|
||||
"@testing-library/react-native": "^12.5.1",
|
||||
"@types/js-quantities": "^1.6.6",
|
||||
"class-transformer": "^0.5.1",
|
||||
@ -38,6 +40,7 @@
|
||||
"react": "18.2.0",
|
||||
"react-dom": "18.2.0",
|
||||
"react-native": "0.74.2",
|
||||
"react-native-element-dropdown": "^2.12.1",
|
||||
"react-native-flex-grid": "^1.0.4",
|
||||
"react-native-gesture-handler": "~2.16.2",
|
||||
"react-native-reanimated": "~3.10.1",
|
||||
|
25
pnpm-lock.yaml
generated
25
pnpm-lock.yaml
generated
@ -14,6 +14,9 @@ dependencies:
|
||||
'@react-native-async-storage/async-storage':
|
||||
specifier: ^1.23.1
|
||||
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':
|
||||
specifier: ^0.74.84
|
||||
version: 0.74.84
|
||||
@ -23,6 +26,9 @@ dependencies:
|
||||
'@reduxjs/toolkit':
|
||||
specifier: ^2.2.5
|
||||
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':
|
||||
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)
|
||||
@ -83,6 +89,9 @@ dependencies:
|
||||
react-native:
|
||||
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)
|
||||
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:
|
||||
specifier: ^1.0.4
|
||||
version: 1.0.4(react-native@0.74.2)(react@18.2.0)
|
||||
@ -2718,6 +2727,10 @@ packages:
|
||||
- utf-8-validate
|
||||
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:
|
||||
resolution: {integrity: sha512-dzUhwyaX04QosWZ8zyaaNB/WYZIdeDN1lcpfQbqiOhZJShRH+FLTDVONE/dqlMQrP+EO7lDqF0RrlIt9lnOCQQ==}
|
||||
engines: {node: '>=18'}
|
||||
@ -8771,6 +8784,18 @@ packages:
|
||||
/react-is@18.3.1:
|
||||
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):
|
||||
resolution: {integrity: sha512-VFadQy3JpgBM2fNsn7W/TdebZ0JNeZgedxPJ0Xi6o+HQJU8j43YGUsGot72rEMWlzaAJCGQnMQXkW9vX+E2n5w==}
|
||||
peerDependencies:
|
||||
|
Loading…
Reference in New Issue
Block a user