diff options
author | Thomas F. K. Jorna <[email protected]> | 2021-07-14 15:10:31 +0200 |
---|---|---|
committer | Thomas F. K. Jorna <[email protected]> | 2021-07-14 15:10:31 +0200 |
commit | e5021187e96b78b53203bd95d08d6818aea47d17 (patch) | |
tree | 37ec45d00eb963db53cd4bb4f04a770414b351cc /app |
New Ignite 7.0.6 app
Diffstat (limited to 'app')
102 files changed, 3960 insertions, 0 deletions
diff --git a/app/app.tsx b/app/app.tsx new file mode 100644 index 0000000..d737864 --- /dev/null +++ b/app/app.tsx @@ -0,0 +1,81 @@ +/** + * Welcome to the main entry point of the app. In this file, we'll + * be kicking off our app. + * + * Most of this file is boilerplate and you shouldn't need to modify + * it very often. But take some time to look through and understand + * what is going on here. + * + * The app navigation resides in ./app/navigators, so head over there + * if you're interested in adding screens and navigators. + */ +import "./i18n" +import "./utils/ignore-warnings" +import React, { useState, useEffect, useRef } from "react" +import { NavigationContainerRef } from "@react-navigation/native" +import { SafeAreaProvider, initialWindowMetrics } from "react-native-safe-area-context" +import { initFonts } from "./theme/fonts" // expo +import * as storage from "./utils/storage" +import { + useBackButtonHandler, + RootNavigator, + canExit, + setRootNavigation, + useNavigationPersistence, +} from "./navigators" +import { RootStore, RootStoreProvider, setupRootStore } from "./models" +import { ToggleStorybook } from "../storybook/toggle-storybook" + +// This puts screens in a native ViewController or Activity. If you want fully native +// stack navigation, use `createNativeStackNavigator` in place of `createStackNavigator`: +// https://github.com/kmagiera/react-native-screens#using-native-stack-navigator +import { enableScreens } from "react-native-screens" +enableScreens() + +export const NAVIGATION_PERSISTENCE_KEY = "NAVIGATION_STATE" + +/** + * This is the root component of our app. + */ +function App() { + const navigationRef = useRef<NavigationContainerRef>(null) + const [rootStore, setRootStore] = useState<RootStore | undefined>(undefined) + + setRootNavigation(navigationRef) + useBackButtonHandler(navigationRef, canExit) + const { initialNavigationState, onNavigationStateChange } = useNavigationPersistence( + storage, + NAVIGATION_PERSISTENCE_KEY, + ) + + // Kick off initial async loading actions, like loading fonts and RootStore + useEffect(() => { + ;(async () => { + await initFonts() // expo + setupRootStore().then(setRootStore) + })() + }, []) + + // Before we show the app, we have to wait for our state to be ready. + // In the meantime, don't render anything. This will be the background + // color set in native by rootView's background color. You can replace + // with your own loading component if you wish. + if (!rootStore) return null + + // otherwise, we're ready to render the app + return ( + <ToggleStorybook> + <RootStoreProvider value={rootStore}> + <SafeAreaProvider initialMetrics={initialWindowMetrics}> + <RootNavigator + ref={navigationRef} + initialState={initialNavigationState} + onStateChange={onNavigationStateChange} + /> + </SafeAreaProvider> + </RootStoreProvider> + </ToggleStorybook> + ) +} + +export default App diff --git a/app/components/auto-image/auto-image.story.tsx b/app/components/auto-image/auto-image.story.tsx new file mode 100644 index 0000000..f7ecc86 --- /dev/null +++ b/app/components/auto-image/auto-image.story.tsx @@ -0,0 +1,31 @@ +/* eslint-disable */ +import * as React from "react" +import { storiesOf } from "@storybook/react-native" +import { StoryScreen, Story, UseCase } from "../../../storybook/views" +import { AutoImage } from "./auto-image" + +declare let module + +const bowser = require("../../screens/welcome/bowser.png") +const morty = { uri: "https://rickandmortyapi.com/api/character/avatar/2.jpeg" } + +storiesOf("AutoImage", module) + .addDecorator((fn) => <StoryScreen>{fn()}</StoryScreen>) + .add("Style Presets", () => ( + <Story> + <UseCase text="With require()"> + <AutoImage source={bowser} /> + <AutoImage source={bowser} style={{ width: 150 }} /> + <AutoImage source={bowser} style={{ width: 150, height: 150 }} /> + <AutoImage source={bowser} style={{ height: 150 }} /> + <AutoImage source={bowser} style={{ height: 150, resizeMode: "contain" }} /> + </UseCase> + <UseCase text="With URL"> + <AutoImage source={morty} /> + <AutoImage source={morty} style={{ width: 150 }} /> + <AutoImage source={morty} style={{ width: 150, height: 150 }} /> + <AutoImage source={morty} style={{ height: 150 }} /> + <AutoImage source={morty} style={{ height: 150, resizeMode: "contain" }} /> + </UseCase> + </Story> + )) diff --git a/app/components/auto-image/auto-image.tsx b/app/components/auto-image/auto-image.tsx new file mode 100644 index 0000000..39d71ca --- /dev/null +++ b/app/components/auto-image/auto-image.tsx @@ -0,0 +1,46 @@ +import React, { useLayoutEffect, useState } from "react" +import { + Image as RNImage, + ImageProps as DefaultImageProps, + ImageURISource, + Platform, +} from "react-native" + +type ImageProps = DefaultImageProps & { + source: ImageURISource +} + +/** + * An Image wrapper component that autosizes itself to the size of the actual image. + * You can always override by passing a width and height in the style. + * If passing only one of width/height this image component will use the actual + * size of the other dimension. + * + * This component isn't required, but is provided as a convenience so that + * we don't have to remember to explicitly set image sizes on every image instance. + * + * To use as a stand-in replacement import { AutoImage as Image } and remove the + * Image import from react-native. Now all images in that file are handled by this + * component and are web-ready if not explicitly sized in the style property. + */ +export function AutoImage(props: ImageProps) { + const [imageSize, setImageSize] = useState({ width: 0, height: 0 }) + + useLayoutEffect(() => { + if (props.source?.uri) { + RNImage.getSize(props.source.uri as any, (width, height) => { + setImageSize({ width, height }) + }) + } else if (Platform.OS === "web") { + // web requires a different method to get it's size + RNImage.getSize(props.source as any, (width, height) => { + setImageSize({ width, height }) + }) + } else { + const { width, height } = RNImage.resolveAssetSource(props.source) + setImageSize({ width, height }) + } + }, []) + + return <RNImage {...props} style={[imageSize, props.style]} /> +} diff --git a/app/components/bullet-item/bullet-item.tsx b/app/components/bullet-item/bullet-item.tsx new file mode 100644 index 0000000..d999e10 --- /dev/null +++ b/app/components/bullet-item/bullet-item.tsx @@ -0,0 +1,41 @@ +import * as React from "react" +import { View, ViewStyle, ImageStyle, TextStyle } from "react-native" +import { Text } from "../text/text" +import { Icon } from "../icon/icon" +import { spacing, typography } from "../../theme" + +const BULLET_ITEM: ViewStyle = { + flexDirection: "row", + marginTop: spacing[4], + paddingBottom: spacing[4], + borderBottomWidth: 1, + borderBottomColor: "#3A3048", +} +const BULLET_CONTAINER: ViewStyle = { + marginRight: spacing[4] - 1, + marginTop: spacing[2], +} +const BULLET: ImageStyle = { + width: 8, + height: 8, +} +const BULLET_TEXT: TextStyle = { + flex: 1, + fontFamily: typography.primary, + color: "#BAB6C8", + fontSize: 15, + lineHeight: 22, +} + +export interface BulletItemProps { + text: string +} + +export function BulletItem(props: BulletItemProps) { + return ( + <View style={BULLET_ITEM}> + <Icon icon="bullet" containerStyle={BULLET_CONTAINER} style={BULLET} /> + <Text style={BULLET_TEXT} text={props.text} /> + </View> + ) +} diff --git a/app/components/button/button.presets.ts b/app/components/button/button.presets.ts new file mode 100644 index 0000000..b140fd2 --- /dev/null +++ b/app/components/button/button.presets.ts @@ -0,0 +1,54 @@ +import { ViewStyle, TextStyle } from "react-native" +import { color, spacing } from "../../theme" + +/** + * All text will start off looking like this. + */ +const BASE_VIEW: ViewStyle = { + paddingVertical: spacing[2], + paddingHorizontal: spacing[2], + borderRadius: 4, + justifyContent: "center", + alignItems: "center", +} + +const BASE_TEXT: TextStyle = { + paddingHorizontal: spacing[3], +} + +/** + * All the variations of text styling within the app. + * + * You want to customize these to whatever you need in your app. + */ +export const viewPresets: Record<string, ViewStyle> = { + /** + * A smaller piece of secondard information. + */ + primary: { ...BASE_VIEW, backgroundColor: color.palette.orange } as ViewStyle, + + /** + * A button without extras. + */ + link: { + ...BASE_VIEW, + paddingHorizontal: 0, + paddingVertical: 0, + alignItems: "flex-start", + } as ViewStyle, +} + +export const textPresets: Record<ButtonPresetNames, TextStyle> = { + primary: { ...BASE_TEXT, fontSize: 9, color: color.palette.white } as TextStyle, + link: { + ...BASE_TEXT, + color: color.text, + paddingHorizontal: 0, + paddingVertical: 0, + } as TextStyle, +} + +/** + * A list of preset names. + */ +export type ButtonPresetNames = keyof typeof viewPresets diff --git a/app/components/button/button.props.ts b/app/components/button/button.props.ts new file mode 100644 index 0000000..1377a7e --- /dev/null +++ b/app/components/button/button.props.ts @@ -0,0 +1,35 @@ +import { StyleProp, TextStyle, TouchableOpacityProps, ViewStyle } from "react-native" +import { ButtonPresetNames } from "./button.presets" +import { TxKeyPath } from "../../i18n" + +export interface ButtonProps extends TouchableOpacityProps { + /** + * Text which is looked up via i18n. + */ + tx?: TxKeyPath + + /** + * The text to display if not using `tx` or nested components. + */ + text?: string + + /** + * An optional style override useful for padding & margin. + */ + style?: StyleProp<ViewStyle> + + /** + * An optional style override useful for the button text. + */ + textStyle?: StyleProp<TextStyle> + + /** + * One of the different types of text presets. + */ + preset?: ButtonPresetNames + + /** + * One of the different types of text presets. + */ + children?: React.ReactNode +} diff --git a/app/components/button/button.story.tsx b/app/components/button/button.story.tsx new file mode 100644 index 0000000..4861772 --- /dev/null +++ b/app/components/button/button.story.tsx @@ -0,0 +1,33 @@ +import * as React from "react" +import { ViewStyle, TextStyle, Alert } from "react-native" +import { storiesOf } from "@storybook/react-native" +import { StoryScreen, Story, UseCase } from "../../../storybook/views" +import { Button } from "./button" + +declare let module + +const buttonStyleArray: ViewStyle[] = [{ paddingVertical: 100 }, { borderRadius: 0 }] + +const buttonTextStyleArray: TextStyle[] = [{ fontSize: 20 }, { color: "#a511dc" }] + +storiesOf("Button", module) + .addDecorator((fn) => <StoryScreen>{fn()}</StoryScreen>) + .add("Style Presets", () => ( + <Story> + <UseCase text="Primary" usage="The primary button."> + <Button text="Click It" preset="primary" onPress={() => Alert.alert("pressed")} /> + </UseCase> + <UseCase text="Disabled" usage="The disabled behaviour of the primary button."> + <Button text="Click It" preset="primary" onPress={() => Alert.alert("pressed")} disabled /> + </UseCase> + <UseCase text="Array Style" usage="Button with array style"> + <Button + text="Click It" + preset="primary" + onPress={() => Alert.alert("pressed")} + style={buttonStyleArray} + textStyle={buttonTextStyleArray} + /> + </UseCase> + </Story> + )) diff --git a/app/components/button/button.tsx b/app/components/button/button.tsx new file mode 100644 index 0000000..a5662ff --- /dev/null +++ b/app/components/button/button.tsx @@ -0,0 +1,36 @@ +import * as React from "react" +import { TouchableOpacity } from "react-native" +import { Text } from "../text/text" +import { viewPresets, textPresets } from "./button.presets" +import { ButtonProps } from "./button.props" + +/** + * For your text displaying needs. + * + * This component is a HOC over the built-in React Native one. + */ +export function Button(props: ButtonProps) { + // grab the props + const { + preset = "primary", + tx, + text, + style: styleOverride, + textStyle: textStyleOverride, + children, + ...rest + } = props + + const viewStyle = viewPresets[preset] || viewPresets.primary + const viewStyles = [viewStyle, styleOverride] + const textStyle = textPresets[preset] || textPresets.primary + const textStyles = [textStyle, textStyleOverride] + + const content = children || <Text tx={tx} text={text} style={textStyles} /> + + return ( + <TouchableOpacity style={viewStyles} {...rest}> + {content} + </TouchableOpacity> + ) +} diff --git a/app/components/checkbox/checkbox.props.ts b/app/components/checkbox/checkbox.props.ts new file mode 100644 index 0000000..01a13d0 --- /dev/null +++ b/app/components/checkbox/checkbox.props.ts @@ -0,0 +1,44 @@ +import { StyleProp, ViewStyle } from "react-native" +import { TxKeyPath } from "../../i18n" + +export interface CheckboxProps { + /** + * Additional container style. Useful for margins. + */ + style?: StyleProp<ViewStyle> + + /** + * Additional outline style. + */ + outlineStyle?: StyleProp<ViewStyle> + + /** + * Additional fill style. Only visible when checked. + */ + fillStyle?: StyleProp<ViewStyle> + + /** + * Is the checkbox checked? + */ + value?: boolean + + /** + * The text to display if there isn't a tx. + */ + text?: string + + /** + * The i18n lookup key. + */ + tx?: TxKeyPath + + /** + * Multiline or clipped single line? + */ + multiline?: boolean + + /** + * Fires when the user tabs to change the value. + */ + onToggle?: (newValue: boolean) => void +} diff --git a/app/components/checkbox/checkbox.story.tsx b/app/components/checkbox/checkbox.story.tsx new file mode 100644 index 0000000..f8d80d8 --- /dev/null +++ b/app/components/checkbox/checkbox.story.tsx @@ -0,0 +1,121 @@ +/* eslint-disable react-native/no-inline-styles */ +/* eslint-disable react-native/no-color-literals */ + +import * as React from "react" +import { View, ViewStyle } from "react-native" +import { storiesOf } from "@storybook/react-native" +import { StoryScreen, Story, UseCase } from "../../../storybook/views" +import { Checkbox } from "./checkbox" +import { Toggle } from "react-powerplug" + +declare let module + +const arrayStyle: ViewStyle[] = [{ paddingVertical: 40 }, { alignSelf: "flex-end" }] +const arrayOutlineStyle: ViewStyle[] = [{ borderColor: "#b443c9" }, { borderWidth: 25 }] +const arrayFillStyle: ViewStyle[] = [{ backgroundColor: "#55e0ff" }] + +storiesOf("Checkbox", module) + .addDecorator((fn) => <StoryScreen>{fn()}</StoryScreen>) + .add("Behaviour", () => ( + <Story> + <UseCase text="The Checkbox" usage="Use the checkbox to represent on/off states."> + <Toggle initial={false}> + {({ on, toggle }) => <Checkbox value={on} onToggle={toggle} text="Toggle me" />} + </Toggle> + </UseCase> + <UseCase text="value = true" usage="This is permanently on."> + <Checkbox value={true} text="Always on" /> + </UseCase> + <UseCase text="value = false" usage="This is permanantly off."> + <Checkbox value={false} text="Always off" /> + </UseCase> + </Story> + )) + .add("Styling", () => ( + <Story> + <UseCase text="multiline = true" usage="For really really long text"> + <Toggle initial={false}> + {({ on, toggle }) => ( + <View> + <Checkbox + text="We’re an App Design & Development Team. Experts in mobile & web technologies. We create beautiful, functional mobile apps and websites." + value={on} + multiline + onToggle={toggle} + /> + </View> + )} + </Toggle> + </UseCase> + <UseCase text=".style" usage="Override the container style"> + <Toggle initial={false}> + {({ on, toggle }) => ( + <View> + <Checkbox + text="Hello there!" + value={on} + style={{ + backgroundColor: "purple", + marginLeft: 40, + paddingVertical: 30, + paddingLeft: 60, + }} + onToggle={toggle} + /> + </View> + )} + </Toggle> + </UseCase> + <UseCase text=".outlineStyle" usage="Override the outline style"> + <Toggle initial={false}> + {({ on, toggle }) => ( + <View> + <Checkbox + text="Outline is the box frame" + value={on} + outlineStyle={{ + borderColor: "green", + borderRadius: 10, + borderWidth: 4, + width: 60, + height: 20, + }} + onToggle={toggle} + /> + </View> + )} + </Toggle> + </UseCase> + <UseCase text=".fillStyle" usage="Override the fill style"> + <Toggle initial={false}> + {({ on, toggle }) => ( + <View> + <Checkbox + text="Fill er up" + value={on} + fillStyle={{ backgroundColor: "red", borderRadius: 8 }} + onToggle={toggle} + /> + </View> + )} + </Toggle> + </UseCase> + + <UseCase text="Array style" usage="Use array styles"> + <Toggle initial={false}> + {({ on, toggle }) => ( + <View> + <Checkbox + text="Check it twice" + value={on} + onToggle={toggle} + style={arrayStyle} + outlineStyle={arrayOutlineStyle} + fillStyle={arrayFillStyle} + /> + </View> + )} + </Toggle> + </UseCase> + </Story> + )) diff --git a/app/components/checkbox/checkbox.tsx b/app/components/checkbox/checkbox.tsx new file mode 100644 index 0000000..fc3992a --- /dev/null +++ b/app/components/checkbox/checkbox.tsx @@ -0,0 +1,53 @@ +import * as React from "react" +import { TextStyle, TouchableOpacity, View, ViewStyle } from "react-native" +import { Text } from "../text/text" +import { color, spacing } from "../../theme" +import { CheckboxProps } from "./checkbox.props" + +const ROOT: ViewStyle = { + flexDirection: "row", + paddingVertical: spacing[1], + alignSelf: "flex-start", +} + +const DIMENSIONS = { width: 16, height: 16 } + +const OUTLINE: ViewStyle = { + ...DIMENSIONS, + marginTop: 2, // finicky and will depend on font/line-height/baseline/weather + justifyContent: "center", + alignItems: "center", + borderWidth: 1, + borderColor: color.primaryDarker, + borderRadius: 1, +} + +const FILL: ViewStyle = { + width: DIMENSIONS.width - 4, + height: DIMENSIONS.height - 4, + backgroundColor: color.primary, +} + +const LABEL: TextStyle = { paddingLeft: spacing[2] } + +export function Checkbox(props: CheckboxProps) { + const numberOfLines = props.multiline ? 0 : 1 + + const rootStyle = [ROOT, props.style] + const outlineStyle = [OUTLINE, props.outlineStyle] + const fillStyle = [FILL, props.fillStyle] + + const onPress = props.onToggle ? () => props.onToggle && props.onToggle(!props.value) : null + + return ( + <TouchableOpacity + activeOpacity={1} + disabled={!props.onToggle} + onPress={onPress} + style={rootStyle} + > + <View style={outlineStyle}>{props.value && <View style={fillStyle} />}</View> + <Text text={props.text} tx={props.tx} numberOfLines={numberOfLines} style={LABEL} /> + </TouchableOpacity> + ) +} diff --git a/app/components/form-row/form-row.presets.ts b/app/components/form-row/form-row.presets.ts new file mode 100644 index 0000000..7c10294 --- /dev/null +++ b/app/components/form-row/form-row.presets.ts @@ -0,0 +1,71 @@ +import { ViewStyle } from "react-native" +import { color, spacing } from "../../theme" + +/** + * The size of the border radius. + */ +const RADIUS = 8 + +/** + * The default style of the container. + */ +const ROOT: ViewStyle = { + borderWidth: 1, + borderColor: color.line, + padding: spacing[2], +} + +/** + * What each of the presets look like. + */ +export const PRESETS = { + /** + * Rounded borders on the the top only. + */ + top: { + ...ROOT, + borderTopLeftRadius: RADIUS, + borderTopRightRadius: RADIUS, + borderBottomWidth: 0, + }, + /** + * No rounded borders. + */ + middle: { + ...ROOT, + borderBottomWidth: 0, + }, + /** + * Rounded borders on the bottom. + */ + bottom: { + ...ROOT, + borderBottomLeftRadius: RADIUS, + borderBottomRightRadius: RADIUS, + }, + /** + * Rounded borders everywhere. + */ + soloRound: { + ...ROOT, + borderRadius: RADIUS, + }, + /** + * Straight borders everywhere. + */ + soloStraight: { + ...ROOT, + }, + /** + * Transparent borders useful to keep things lined up. + */ + clear: { + ...ROOT, + borderColor: color.transparent, + }, +} + +/** + * The names of the presets supported by FormRow. + */ +export type FormRowPresets = keyof typeof PRESETS diff --git a/app/components/form-row/form-row.props.tsx b/app/components/form-row/form-row.props.tsx new file mode 100644 index 0000000..f010206 --- /dev/null +++ b/app/components/form-row/form-row.props.tsx @@ -0,0 +1,23 @@ +import * as React from "react" +import { StyleProp, ViewStyle } from "react-native" +import { FormRowPresets } from "./form-row.presets" + +/** + * The properties you can pass to FormRow. + */ +export interface FormRowProps { + /** + * Children components. + */ + children?: React.ReactNode + + /** + * Override the container style... useful for margins and padding. + */ + style?: StyleProp<ViewStyle> + + /** + * The type of border. + */ + preset: FormRowPresets +} diff --git a/app/components/form-row/form-row.story.tsx b/app/components/form-row/form-row.story.tsx new file mode 100644 index 0000000..509afbb --- /dev/null +++ b/app/components/form-row/form-row.story.tsx @@ -0,0 +1,107 @@ +/* eslint-disable react-native/no-inline-styles */ +/* eslint-disable react-native/no-color-literals */ + +import * as React from "react" +import { storiesOf } from "@storybook/react-native" +import { StoryScreen, Story, UseCase } from "../../../storybook/views" +import { Text, FormRow } from "../" +import { color } from "../../theme/color" +import { ViewStyle } from "react-native" + +declare let module + +const TEXT_STYLE_OVERRIDE = { + color: color.storybookTextColor, +} +const arrayStyle: ViewStyle[] = [{ borderWidth: 5 }, { borderColor: "#32cd32" }] + +storiesOf("FormRow", module) + .addDecorator((fn) => <StoryScreen>{fn()}</StoryScreen>) + .add("Assembled", () => ( + <Story> + <UseCase + text="Fully Assembled" + usage="FormRow has many parts designed to fit together. Here is what it looks like all assembled." + > + <FormRow preset="top"> + <Text preset="fieldLabel" style={TEXT_STYLE_OVERRIDE}> + Hello! I am at the top + </Text> + </FormRow> + <FormRow preset="middle"> + <Text style={TEXT_STYLE_OVERRIDE}> + Lorem ipsum dolor sit amet, consectetur adipisicing elit. Commodi officia quo rerum + impedit asperiores hic ex quae, quam dolores vel odit doloribus, tempore atque deserunt + possimus incidunt, obcaecati numquam officiis. + </Text> + </FormRow> + <FormRow preset="middle"> + <Text preset="secondary" style={TEXT_STYLE_OVERRIDE}> + ...one more thing + </Text> + </FormRow> + <FormRow preset="bottom"> + <Text style={TEXT_STYLE_OVERRIDE}>🎉 Footers!</Text> + </FormRow> + </UseCase> + <UseCase text="Alternatives" usage="Less commonly used presets."> + <FormRow preset="clear"> + <Text style={TEXT_STYLE_OVERRIDE}> + My borders are still there, but they are clear. This causes the text to still align + properly due to the box model of flexbox. + </Text> + </FormRow> + <FormRow preset="soloRound"> + <Text style={TEXT_STYLE_OVERRIDE}>I'm round</Text> + </FormRow> + <FormRow preset="soloStraight" style={{ marginTop: 10, backgroundColor: "#ffe" }}> + <Text style={TEXT_STYLE_OVERRIDE}>I'm square and have a custom style.</Text> + </FormRow> + </UseCase> + </Story> + )) + .add("Presets", () => ( + <Story> + <UseCase text="top" usage="The top of a form."> + <FormRow preset="top"> + <Text style={TEXT_STYLE_OVERRIDE}>Curved borders at the top.</Text> + <Text style={TEXT_STYLE_OVERRIDE}>Nothing below</Text> + </FormRow> + </UseCase> + <UseCase text="middle" usage="A row in the middle of a form."> + <FormRow preset="middle"> + <Text style={TEXT_STYLE_OVERRIDE}>No curves and empty at the bottom.</Text> + </FormRow> + </UseCase> + <UseCase text="bottom" usage="The bottom of a form."> + <FormRow preset="bottom"> + <Text style={TEXT_STYLE_OVERRIDE}>Curved at the bottom</Text> + <Text style={TEXT_STYLE_OVERRIDE}>Line at the top.</Text> + </FormRow> + </UseCase> + <UseCase text="soloRound" usage="A standalone curved form row."> + <FormRow preset="soloRound"> + <Text style={TEXT_STYLE_OVERRIDE}>Curves all around.</Text> + </FormRow> + </UseCase> + <UseCase text="soloStraight" usage="A standalone straight form row."> + <FormRow preset="soloStraight"> + <Text style={TEXT_STYLE_OVERRIDE}>Curves nowhere.</Text> + </FormRow> + </UseCase> + <UseCase text="clear" usage="Identical dimensions but transparent edges."> + <FormRow preset="clear"> + <Text style={TEXT_STYLE_OVERRIDE}>Curves nowhere.</Text> + </FormRow> + </UseCase> + </Story> + )) + .add("Styling", () => ( + <Story> + <UseCase text="Style array" usage="Form row with an array of styles"> + <FormRow preset="soloStraight" style={arrayStyle}> + <Text style={TEXT_STYLE_OVERRIDE}>Array style.</Text> + </FormRow> + </UseCase> + </Story> + )) diff --git a/app/components/form-row/form-row.tsx b/app/components/form-row/form-row.tsx new file mode 100644 index 0000000..2045336 --- /dev/null +++ b/app/components/form-row/form-row.tsx @@ -0,0 +1,13 @@ +import * as React from "react" +import { View } from "react-native" +import { PRESETS } from "./form-row.presets" +import { FormRowProps } from "./form-row.props" + +/** + * A horizontal container component used to hold a row of a form. + */ +export function FormRow(props: FormRowProps) { + const viewStyle = [PRESETS[props.preset], props.style] + + return <View style={viewStyle}>{props.children}</View> +} diff --git a/app/components/header/header.props.ts b/app/components/header/header.props.ts new file mode 100644 index 0000000..07f2c4d --- /dev/null +++ b/app/components/header/header.props.ts @@ -0,0 +1,45 @@ +import { StyleProp, TextStyle, ViewStyle } from "react-native" +import { IconTypes } from "../icon/icons" +import { TxKeyPath } from "../../i18n" + +export interface HeaderProps { + /** + * Main header, e.g. POWERED BY IGNITE + */ + headerTx?: TxKeyPath + + /** + * header non-i18n + */ + headerText?: string + + /** + * Icon that should appear on the left + */ + leftIcon?: IconTypes + + /** + * What happens when you press the left icon + */ + onLeftPress?(): void + + /** + * Icon that should appear on the right + */ + rightIcon?: IconTypes + + /** + * What happens when you press the right icon + */ + onRightPress?(): void + + /** + * Container style overrides. + */ + style?: StyleProp<ViewStyle> + + /** + * Title style overrides. + */ + titleStyle?: StyleProp<TextStyle> +} diff --git a/app/components/header/header.story.tsx b/app/components/header/header.story.tsx new file mode 100644 index 0000000..05093b3 --- /dev/null +++ b/app/components/header/header.story.tsx @@ -0,0 +1,43 @@ +import * as React from "react" +import { View, Alert } from "react-native" +import { storiesOf } from "@storybook/react-native" +import { StoryScreen, Story, UseCase } from "../../../storybook/views" +import { Header } from "./header" +import { color } from "../../theme" + +declare let module + +const VIEWSTYLE = { + flex: 1, + backgroundColor: color.storybookDarkBg, +} + +storiesOf("Header", module) + .addDecorator((fn) => <StoryScreen>{fn()}</StoryScreen>) + .add("Behavior", () => ( + <Story> + <UseCase noPad text="default" usage="The default usage"> + <View style={VIEWSTYLE}> + <Header headerTx="demoScreen.howTo" /> + </View> + </UseCase> + <UseCase noPad text="leftIcon" usage="A left nav icon"> + <View style={VIEWSTYLE}> + <Header + headerTx="demoScreen.howTo" + leftIcon="back" + onLeftPress={() => Alert.alert("left nav")} + /> + </View> + </UseCase> + <UseCase noPad text="rightIcon" usage="A right nav icon"> + <View style={VIEWSTYLE}> + <Header + headerTx="demoScreen.howTo" + rightIcon="bullet" + onRightPress={() => Alert.alert("right nav")} + /> + </View> + </UseCase> + </Story> + )) diff --git a/app/components/header/header.tsx b/app/components/header/header.tsx new file mode 100644 index 0000000..b4a6634 --- /dev/null +++ b/app/components/header/header.tsx @@ -0,0 +1,61 @@ +import React from "react" +import { View, ViewStyle, TextStyle } from "react-native" +import { HeaderProps } from "./header.props" +import { Button } from "../button/button" +import { Text } from "../text/text" +import { Icon } from "../icon/icon" +import { spacing } from "../../theme" +import { translate } from "../../i18n/" + +// static styles +const ROOT: ViewStyle = { + flexDirection: "row", + paddingHorizontal: spacing[4], + alignItems: "center", + paddingTop: spacing[5], + paddingBottom: spacing[5], + justifyContent: "flex-start", +} +const TITLE: TextStyle = { textAlign: "center" } +const TITLE_MIDDLE: ViewStyle = { flex: 1, justifyContent: "center" } +const LEFT: ViewStyle = { width: 32 } +const RIGHT: ViewStyle = { width: 32 } + +/** + * Header that appears on many screens. Will hold navigation buttons and screen title. + */ +export function Header(props: HeaderProps) { + const { + onLeftPress, + onRightPress, + rightIcon, + leftIcon, + headerText, + headerTx, + style, + titleStyle, + } = props + const header = headerText || (headerTx && translate(headerTx)) || "" + + return ( + <View style={[ROOT, style]}> + {leftIcon ? ( + <Button preset="link" onPress={onLeftPress}> + <Icon icon={leftIcon} /> + </Button> + ) : ( + <View style={LEFT} /> + )} + <View style={TITLE_MIDDLE}> + <Text style={[TITLE, titleStyle]} text={header} /> + </View> + {rightIcon ? ( + <Button preset="link" onPress={onRightPress}> + <Icon icon={rightIcon} /> + </Button> + ) : ( + <View style={RIGHT} /> + )} + </View> + ) +} diff --git a/app/components/icon/icon.props.ts b/app/components/icon/icon.props.ts new file mode 100644 index 0000000..71ce0b7 --- /dev/null +++ b/app/components/icon/icon.props.ts @@ -0,0 +1,21 @@ +import { ImageStyle, StyleProp, ViewStyle } from "react-native" +import { IconTypes } from "./icons" + +export interface IconProps { + /** + * Style overrides for the icon image + */ + style?: StyleProp<ImageStyle> + + /** + * Style overrides for the icon container + */ + + containerStyle?: StyleProp<ViewStyle> + + /** + * The name of the icon + */ + + icon?: IconTypes +} diff --git a/app/components/icon/icon.story.tsx b/app/components/icon/icon.story.tsx new file mode 100644 index 0000000..d119ed4 --- /dev/null +++ b/app/components/icon/icon.story.tsx @@ -0,0 +1,19 @@ +import * as React from "react" +import { storiesOf } from "@storybook/react-native" +import { StoryScreen, Story, UseCase } from "../../../storybook/views" +import { Icon } from "./icon" + +declare let module + +storiesOf("Icon", module) + .addDecorator((fn) => <StoryScreen>{fn()}</StoryScreen>) + .add("Names", () => ( + <Story> + <UseCase text="back" usage="The icon for going back"> + <Icon icon="back" /> + </UseCase> + <UseCase text="bullet" usage="The icon for a bullet point"> + <Icon icon="bullet" /> + </UseCase> + </Story> + )) diff --git a/app/components/icon/icon.tsx b/app/components/icon/icon.tsx new file mode 100644 index 0000000..4735e13 --- /dev/null +++ b/app/components/icon/icon.tsx @@ -0,0 +1,19 @@ +import * as React from "react" +import { View, ImageStyle } from "react-native" +import { AutoImage as Image } from "../auto-image/auto-image" +import { IconProps } from "./icon.props" +import { icons } from "./icons" + +const ROOT: ImageStyle = { + resizeMode: "contain", +} + +export function Icon(props: IconProps) { + const { style: styleOverride, icon, containerStyle } = props + + return ( + <View style={containerStyle}> + <Image style={[ROOT, styleOverride]} source={icons[icon]} /> + </View> + ) +} diff --git a/app/components/icon/icons/arrow-left.png b/app/components/icon/icons/arrow-left.png Binary files differnew file mode 100644 index 0000000..9d607d7 --- /dev/null +++ b/app/components/icon/icons/arrow-left.png diff --git a/app/components/icon/icons/[email protected] b/app/components/icon/icons/[email protected] Binary files differnew file mode 100644 index 0000000..9d607d7 --- /dev/null +++ b/app/components/icon/icons/[email protected] diff --git a/app/components/icon/icons/bullet.png b/app/components/icon/icons/bullet.png Binary files differnew file mode 100644 index 0000000..8fc256f --- /dev/null +++ b/app/components/icon/icons/bullet.png diff --git a/app/components/icon/icons/[email protected] b/app/components/icon/icons/[email protected] Binary files differnew file mode 100644 index 0000000..8fc256f --- /dev/null +++ b/app/components/icon/icons/[email protected] diff --git a/app/components/icon/icons/index.ts b/app/components/icon/icons/index.ts new file mode 100644 index 0000000..00e8a59 --- /dev/null +++ b/app/components/icon/icons/index.ts @@ -0,0 +1,6 @@ +export const icons = { + back: require("./arrow-left.png"), + bullet: require("./bullet.png"), +} + +export type IconTypes = keyof typeof icons diff --git a/app/components/index.ts b/app/components/index.ts new file mode 100644 index 0000000..5ed4a28 --- /dev/null +++ b/app/components/index.ts @@ -0,0 +1,12 @@ +export * from "./bullet-item/bullet-item" +export * from "./button/button" +export * from "./checkbox/checkbox" +export * from "./form-row/form-row" +export * from "./header/header" +export * from "./icon/icon" +export * from "./screen/screen" +export * from "./switch/switch" +export * from "./text/text" +export * from "./text-field/text-field" +export * from "./wallpaper/wallpaper" +export * from "./auto-image/auto-image" diff --git a/app/components/screen/screen.presets.ts b/app/components/screen/screen.presets.ts new file mode 100644 index 0000000..a016b77 --- /dev/null +++ b/app/components/screen/screen.presets.ts @@ -0,0 +1,66 @@ +import { ViewStyle } from "react-native" +import { color } from "../../theme" + +/** + * All screen keyboard offsets. + */ +export const offsets = { + none: 0, +} + +/** + * The variations of keyboard offsets. + */ +export type KeyboardOffsets = keyof typeof offsets + +/** + * All the variations of screens. + */ +export const presets = { + /** + * No scrolling. Suitable for full-screen carousels and components + * which have built-in scrolling like FlatList. + */ + fixed: { + outer: { + backgroundColor: color.background, + flex: 1, + height: "100%", + } as ViewStyle, + inner: { + justifyContent: "flex-start", + alignItems: "stretch", + height: "100%", + width: "100%", + } as ViewStyle, + }, + + /** + * Scrolls. Suitable for forms or other things requiring a keyboard. + * + * Pick this one if you don't know which one you want yet. + */ + scroll: { + outer: { + backgroundColor: color.background, + flex: 1, + height: "100%", + } as ViewStyle, + inner: { justifyContent: "flex-start", alignItems: "stretch" } as ViewStyle, + }, +} + +/** + * The variations of screens. + */ +export type ScreenPresets = keyof typeof presets + +/** + * Is this preset a non-scrolling one? + * + * @param preset The preset to check + */ +export function isNonScrolling(preset?: ScreenPresets) { + // any of these things will make you scroll + return !preset || !presets[preset] || preset === "fixed" +} diff --git a/app/components/screen/screen.props.ts b/app/components/screen/screen.props.ts new file mode 100644 index 0000000..0326fd7 --- /dev/null +++ b/app/components/screen/screen.props.ts @@ -0,0 +1,46 @@ +import React from "react" +import { StyleProp, ViewStyle } from "react-native" +import { KeyboardOffsets, ScreenPresets } from "./screen.presets" + +export interface ScreenProps { + /** + * Children components. + */ + children?: React.ReactNode + + /** + * An optional style override useful for padding & margin. + */ + style?: StyleProp<ViewStyle> + + /** + * One of the different types of presets. + */ + preset?: ScreenPresets + + /** + * An optional background color + */ + backgroundColor?: string + + /** + * An optional status bar setting. Defaults to light-content. + */ + statusBar?: "light-content" | "dark-content" + + /** + * Should we not wrap in SafeAreaView? Defaults to false. + */ + unsafe?: boolean + + /** + * By how much should we offset the keyboard? Defaults to none. + */ + keyboardOffset?: KeyboardOffsets + + /** + * Should keyboard persist on screen tap. Defaults to handled. + * Only applies to scroll preset. + */ + keyboardShouldPersistTaps?: "handled" | "always" | "never" +} diff --git a/app/components/screen/screen.tsx b/app/components/screen/screen.tsx new file mode 100644 index 0000000..ba84547 --- /dev/null +++ b/app/components/screen/screen.tsx @@ -0,0 +1,66 @@ +import * as React from "react" +import { KeyboardAvoidingView, Platform, ScrollView, StatusBar, View } from "react-native" +import { useSafeAreaInsets } from "react-native-safe-area-context" +import { ScreenProps } from "./screen.props" +import { isNonScrolling, offsets, presets } from "./screen.presets" + +const isIos = Platform.OS === "ios" + +function ScreenWithoutScrolling(props: ScreenProps) { + const insets = useSafeAreaInsets() + const preset = presets.fixed + const style = props.style || {} + const backgroundStyle = props.backgroundColor ? { backgroundColor: props.backgroundColor } : {} + const insetStyle = { paddingTop: props.unsafe ? 0 : insets.top } + + return ( + <KeyboardAvoidingView + style={[preset.outer, backgroundStyle]} + behavior={isIos ? "padding" : undefined} + keyboardVerticalOffset={offsets[props.keyboardOffset || "none"]} + > + <StatusBar barStyle={props.statusBar || "light-content"} /> + <View style={[preset.inner, style, insetStyle]}>{props.children}</View> + </KeyboardAvoidingView> + ) +} + +function ScreenWithScrolling(props: ScreenProps) { + const insets = useSafeAreaInsets() + const preset = presets.scroll + const style = props.style || {} + const backgroundStyle = props.backgroundColor ? { backgroundColor: props.backgroundColor } : {} + const insetStyle = { paddingTop: props.unsafe ? 0 : insets.top } + + return ( + <KeyboardAvoidingView + style={[preset.outer, backgroundStyle]} + behavior={isIos ? "padding" : undefined} + keyboardVerticalOffset={offsets[props.keyboardOffset || "none"]} + > + <StatusBar barStyle={props.statusBar || "light-content"} /> + <View style={[preset.outer, backgroundStyle, insetStyle]}> + <ScrollView + style={[preset.outer, backgroundStyle]} + contentContainerStyle={[preset.inner, style]} + keyboardShouldPersistTaps={props.keyboardShouldPersistTaps || "handled"} + > + {props.children} + </ScrollView> + </View> + </KeyboardAvoidingView> + ) +} + +/** + * The starting component on every screen in the app. + * + * @param props The screen props + */ +export function Screen(props: ScreenProps) { + if (isNonScrolling(props.preset)) { + return <ScreenWithoutScrolling {...props} /> + } else { + return <ScreenWithScrolling {...props} /> + } +} diff --git a/app/components/switch/switch.props.ts b/app/components/switch/switch.props.ts new file mode 100644 index 0000000..8235457 --- /dev/null +++ b/app/components/switch/switch.props.ts @@ -0,0 +1,39 @@ +import { StyleProp, ViewStyle } from "react-native" + +export interface SwitchProps { + /** + * On or off. + */ + value?: boolean + /** + * Fires when the on/off switch triggers. + * + * @param newValue The new value we're switching to. + */ + onToggle?: (newValue: boolean) => void + + /** + * A style override to apply to the container. Useful for margins and paddings. + */ + style?: StyleProp<ViewStyle> + + /** + * Additional track styling when on. + */ + trackOnStyle?: StyleProp<ViewStyle> + + /** + * Additional track styling when off. + */ + trackOffStyle?: StyleProp<ViewStyle> + + /** + * Additional thumb styling when on. + */ + thumbOnStyle?: StyleProp<ViewStyle> + + /** + * Additional thumb styling when off. + */ + thumbOffStyle?: StyleProp<ViewStyle> +} diff --git a/app/components/switch/switch.story.tsx b/app/components/switch/switch.story.tsx new file mode 100644 index 0000000..998d1df --- /dev/null +++ b/app/components/switch/switch.story.tsx @@ -0,0 +1,110 @@ +/* eslint-disable react-native/no-inline-styles */ +/* eslint-disable react-native/no-color-literals */ + +import * as React from "react" +import { View, ViewStyle } from "react-native" +import { storiesOf } from "@storybook/react-native" +import { StoryScreen, Story, UseCase } from "../../../storybook/views" +import { Toggle } from "react-powerplug" +import { Switch } from "./switch" + +declare let module + +const styleArray: ViewStyle[] = [{ borderColor: "#686868" }] + +const trackOffStyle: ViewStyle[] = [ + { backgroundColor: "#686868" }, + { + height: 80, + borderRadius: 0, + }, +] +const trackOnStyle: ViewStyle[] = [ + { + backgroundColor: "#b1008e", + borderColor: "#686868", + }, + { + height: 80, + borderRadius: 0, + }, +] +const thumbOffStyle: ViewStyle[] = [ + { + backgroundColor: "#b1008e", + borderColor: "#686868", + }, + { + height: 80, + borderRadius: 0, + }, +] +const thumbOnStyle: ViewStyle[] = [ + { backgroundColor: "#f0c" }, + { + height: 80, + borderRadius: 0, + borderColor: "#686868", + }, +] + +storiesOf("Switch", module) + .addDecorator((fn) => <StoryScreen>{fn()}</StoryScreen>) + .add("Behaviour", () => ( + <Story> + <UseCase text="The Toggle Switch" usage="Use the switch to represent on/off states."> + <Toggle initial={false}> + {({ on, toggle }) => <Switch value={on} onToggle={toggle} />} + </Toggle> + </UseCase> + <UseCase text="value = true" usage="This is permanently on."> + <Switch value={true} /> + </UseCase> + <UseCase text="value = false" usage="This is permanantly off."> + <Switch value={false} /> + </UseCase> + </Story> + )) + .add("Styling", () => ( + <Story> + <UseCase text="Custom Styling" usage="Promise me this won't happen."> + <Toggle initial={false}> + {({ on, toggle }) => ( + <View> + <Switch + trackOnStyle={{ backgroundColor: "green", borderColor: "black" }} + trackOffStyle={{ backgroundColor: "red", borderColor: "maroon" }} + thumbOnStyle={{ backgroundColor: "cyan" }} + thumbOffStyle={{ backgroundColor: "pink" }} + value={on} + onToggle={toggle} + /> + </View> + )} + </Toggle> + </UseCase> + + <UseCase text="Style array" usage="This either."> + <Toggle initial={false}> + {({ on, toggle }) => ( + <View> + <Switch + style={styleArray} + trackOffStyle={trackOffStyle} + trackOnStyle={trackOnStyle} + thumbOffStyle={thumbOffStyle} + thumbOnStyle={thumbOnStyle} + // trackOnStyle={{ backgroundColor: "green", borderColor: "black" }} + // trackOffStyle={{ backgroundColor: "red", borderColor: "maroon" }} + // thumbOnStyle={{ backgroundColor: "cyan" }} + // thumbOffStyle={{ backgroundColor: "pink" }} + + value={on} + onToggle={toggle} + /> + </View> + )} + </Toggle> + </UseCase> + </Story> + )) diff --git a/app/components/switch/switch.tsx b/app/components/switch/switch.tsx new file mode 100644 index 0000000..0813747 --- /dev/null +++ b/app/components/switch/switch.tsx @@ -0,0 +1,114 @@ +import React from "react" +import { ViewStyle, Animated, Easing, TouchableWithoutFeedback } from "react-native" +import { color } from "../../theme" +import { SwitchProps } from "./switch.props" + +// dimensions +const THUMB_SIZE = 30 +const WIDTH = 56 +const MARGIN = 2 +const OFF_POSITION = -0.5 +const ON_POSITION = WIDTH - THUMB_SIZE - MARGIN +const BORDER_RADIUS = (THUMB_SIZE * 3) / 4 + +// colors +const ON_COLOR = color.primary +const OFF_COLOR = color.palette.offWhite +const BORDER_ON_COLOR = ON_COLOR +const BORDER_OFF_COLOR = "rgba(0, 0, 0, 0.1)" + +// animation +const DURATION = 250 + +// the track always has these props +const TRACK = { + height: THUMB_SIZE + MARGIN, + width: WIDTH, + borderRadius: BORDER_RADIUS, + borderWidth: MARGIN / 2, + backgroundColor: color.background, +} + +// the thumb always has these props +const THUMB: ViewStyle = { + position: "absolute", + width: THUMB_SIZE, + height: THUMB_SIZE, + borderColor: BORDER_OFF_COLOR, + borderRadius: THUMB_SIZE / 2, + borderWidth: MARGIN / 2, + backgroundColor: color.background, + shadowColor: BORDER_OFF_COLOR, + shadowOffset: { width: 1, height: 2 }, + shadowOpacity: 1, + shadowRadius: 2, + elevation: 2, +} + +const makeAnimatedValue = (switchOn) => new Animated.Value(switchOn ? 1 : 0) + +export function Switch(props: SwitchProps) { + const [timer] = React.useState<Animated.Value>(makeAnimatedValue(props.value)) + const startAnimation = React.useMemo( + () => (newValue: boolean) => { + const toValue = newValue ? 1 : 0 + const easing = Easing.out(Easing.circle) + Animated.timing(timer, { + toValue, + duration: DURATION, + easing, + useNativeDriver: true, + }).start() + }, + [timer], + ) + + const [previousValue, setPreviousValue] = React.useState<boolean>(props.value) + React.useEffect(() => { + if (props.value !== previousValue) { + startAnimation(props.value) + setPreviousValue(props.value) + } + }, [props.value]) + + const handlePress = React.useMemo(() => () => props.onToggle && props.onToggle(!props.value), [ + props.onToggle, + props.value, + ]) + + if (!timer) { + return null + } + + const translateX = timer.interpolate({ + inputRange: [0, 1], + outputRange: [OFF_POSITION, ON_POSITION], + }) + + const style = props.style + + const trackStyle = [ + TRACK, + { + backgroundColor: props.value ? ON_COLOR : OFF_COLOR, + borderColor: props.value ? BORDER_ON_COLOR : BORDER_OFF_COLOR, + }, + props.value ? props.trackOnStyle : props.trackOffStyle, + ] + + const thumbStyle = [ + THUMB, + { + transform: [{ translateX }], + }, + props.value ? props.thumbOnStyle : props.thumbOffStyle, + ] + + return ( + <TouchableWithoutFeedback onPress={handlePress} style={style}> + <Animated.View style={trackStyle}> + <Animated.View style={thumbStyle} /> + </Animated.View> + </TouchableWithoutFeedback> + ) +} diff --git a/app/components/text-field/text-field.story.tsx b/app/components/text-field/text-field.story.tsx new file mode 100644 index 0000000..74a4da0 --- /dev/null +++ b/app/components/text-field/text-field.story.tsx @@ -0,0 +1,159 @@ +/* eslint-disable react-native/no-inline-styles */ +/* eslint-disable react-native/no-color-literals */ + +import * as React from "react" +import { storiesOf } from "@storybook/react-native" +import { StoryScreen, Story, UseCase } from "../../../storybook/views" +import { Text, TextField } from "../" +import { State } from "react-powerplug" +import { ViewStyle, TextStyle, Alert } from "react-native" + +declare let module + +const styleArray: ViewStyle[] = [{ paddingHorizontal: 30 }, { borderWidth: 30 }] + +const inputStyleArray: TextStyle[] = [ + { + backgroundColor: "rebeccapurple", + color: "white", + padding: 40, + }, + { + borderWidth: 10, + borderRadius: 4, + borderColor: "#7fff00", + }, +] +let alertWhenFocused = true + +storiesOf("TextField", module) + .addDecorator((fn) => <StoryScreen>{fn()}</StoryScreen>) + .add("Labelling", () => ( + <Story> + <UseCase text="Normal text" usage="Use placeholder and label to set the text."> + <State initial={{ value: "" }}> + {({ state, setState }) => ( + <TextField + onChangeText={(value) => setState({ value })} + value={state.value} + label="Name" + placeholder="omg your name" + /> + )} + </State> + </UseCase> + + <UseCase text="i18n text" usage="Use placeholderTx and labelTx for i18n lookups"> + <State initial={{ value: "" }}> + {({ state, setState }) => ( + <TextField + onChangeText={(value) => setState({ value })} + value={state.value} + placeholderTx="storybook.placeholder" + labelTx="storybook.field" + /> + )} + </State> + </UseCase> + </Story> + )) + .add("Style Overrides", () => ( + <Story> + <UseCase + noPad + text="Container Styles" + usage="Useful for applying margins when laying out a form to remove padding if the form brings its own." + > + <State initial={{ value: "Inigo" }}> + {({ state, setState }) => ( + <TextField + onChangeText={(value) => setState({ value })} + value={state.value} + label="First Name" + style={{ paddingTop: 0, paddingHorizontal: 40 }} + /> + )} + </State> + <State initial={{ value: "Montoya" }}> + {({ state, setState }) => ( + <TextField + onChangeText={(value) => setState({ value })} + value={state.value} + label="Last Name" + style={{ paddingBottom: 0 }} + /> + )} + </State> + </UseCase> + <UseCase + text="Input Styles" + usage="Useful for 1-off exceptions. Try to steer towards presets for this kind of thing." + > + <State initial={{ value: "fancy colour" }}> + {({ state, setState }) => ( + <TextField + onChangeText={(value) => setState({ value })} + value={state.value} + label="Name" + inputStyle={{ + backgroundColor: "rebeccapurple", + color: "white", + padding: 40, + borderWidth: 10, + borderRadius: 4, + borderColor: "hotpink", + }} + /> + )} + </State> + <Text text="* attention designers: i am so sorry" preset="secondary" /> + </UseCase> + + <UseCase text="Style array" usage="Useful for 1-off exceptions, but using style arrays."> + <State initial={{ value: "fancy colour" }}> + {({ state, setState }) => ( + <TextField + onChangeText={(value) => setState({ value })} + value={state.value} + label="Name" + style={styleArray} + inputStyle={inputStyleArray} + /> + )} + </State> + <Text text="* attention designers: i am so sorry" preset="secondary" /> + </UseCase> + </Story> + )) + .add("Ref Forwarding", () => ( + <Story> + <UseCase text="Ref Forwarding" usage=""> + <State initial={{ value: "fancy colour" }}> + {({ state, setState }) => ( + <TextField + onChangeText={(value) => setState({ value })} + value={state.value} + label="Name" + inputStyle={{ + backgroundColor: "rebeccapurple", + color: "white", + padding: 40, + borderWidth: 10, + borderRadius: 4, + borderColor: "hotpink", + }} + forwardedRef={(ref) => ref} + onFocus={() => { + if (alertWhenFocused) { + // Prevent text field focus from being repeatedly triggering alert + alertWhenFocused = false + Alert.alert("Text field focuesed with forwarded ref!") + } + }} + /> + )} + </State> + <Text text="* attention designers: i am so sorry" preset="secondary" /> + </UseCase> + </Story> + )) diff --git a/app/components/text-field/text-field.tsx b/app/components/text-field/text-field.tsx new file mode 100644 index 0000000..eea1a70 --- /dev/null +++ b/app/components/text-field/text-field.tsx @@ -0,0 +1,98 @@ +import React from "react" +import { StyleProp, TextInput, TextInputProps, TextStyle, View, ViewStyle } from "react-native" +import { color, spacing, typography } from "../../theme" +import { translate, TxKeyPath } from "../../i18n" +import { Text } from "../text/text" + +// the base styling for the container +const CONTAINER: ViewStyle = { + paddingVertical: spacing[3], +} + +// the base styling for the TextInput +const INPUT: TextStyle = { + fontFamily: typography.primary, + color: color.text, + minHeight: 44, + fontSize: 18, + backgroundColor: color.palette.white, +} + +// currently we have no presets, but that changes quickly when you build your app. +const PRESETS: { [name: string]: ViewStyle } = { + default: {}, +} + +export interface TextFieldProps extends TextInputProps { + /** + * The placeholder i18n key. + */ + placeholderTx?: TxKeyPath + + /** + * The Placeholder text if no placeholderTx is provided. + */ + placeholder?: string + + /** + * The label i18n key. + */ + labelTx?: TxKeyPath + + /** + * The label text if no labelTx is provided. + */ + label?: string + + /** + * Optional container style overrides useful for margins & padding. + */ + style?: StyleProp<ViewStyle> + + /** + * Optional style overrides for the input. + */ + inputStyle?: StyleProp<TextStyle> + + /** + * Various look & feels. + */ + preset?: keyof typeof PRESETS + + forwardedRef?: any +} + +/** + * A component which has a label and an input together. + */ +export function TextField(props: TextFieldProps) { + const { + placeholderTx, + placeholder, + labelTx, + label, + preset = "default", + style: styleOverride, + inputStyle: inputStyleOverride, + forwardedRef, + ...rest + } = props + + const containerStyles = [CONTAINER, PRESETS[preset], styleOverride] + const inputStyles = [INPUT, inputStyleOverride] + const actualPlaceholder = placeholderTx ? translate(placeholderTx) : placeholder + + return ( + <View style={containerStyles}> + <Text preset="fieldLabel" tx={labelTx} text={label} /> + <TextInput + placeholder={actualPlaceholder} + placeholderTextColor={color.palette.lighterGrey} + underlineColorAndroid={color.transparent} + {...rest} + style={inputStyles} + ref={forwardedRef} + /> + </View> + ) +} diff --git a/app/components/text/text.presets.ts b/app/components/text/text.presets.ts new file mode 100644 index 0000000..9622268 --- /dev/null +++ b/app/components/text/text.presets.ts @@ -0,0 +1,48 @@ +import { TextStyle } from "react-native" +import { color, typography } from "../../theme" + +/** + * All text will start off looking like this. + */ +const BASE: TextStyle = { + fontFamily: typography.primary, + color: color.text, + fontSize: 15, +} + +/** + * All the variations of text styling within the app. + * + * You want to customize these to whatever you need in your app. + */ +export const presets = { + /** + * The default text styles. + */ + default: BASE, + + /** + * A bold version of the default text. + */ + bold: { ...BASE, fontWeight: "bold" } as TextStyle, + + /** + * Large headers. + */ + header: { ...BASE, fontSize: 24, fontWeight: "bold" } as TextStyle, + + /** + * Field labels that appear on forms above the inputs. + */ + fieldLabel: { ...BASE, fontSize: 13, color: color.dim } as TextStyle, + + /** + * A smaller piece of secondard information. + */ + secondary: { ...BASE, fontSize: 9, color: color.dim } as TextStyle, +} + +/** + * A list of preset names. + */ +export type TextPresets = keyof typeof presets diff --git a/app/components/text/text.props.ts b/app/components/text/text.props.ts new file mode 100644 index 0000000..d2c55dc --- /dev/null +++ b/app/components/text/text.props.ts @@ -0,0 +1,37 @@ +import { StyleProp, TextProps as TextProperties, TextStyle } from "react-native" +import i18n from "i18n-js" +import { TextPresets } from "./text.presets" +import { TxKeyPath } from "../../i18n" + +export interface TextProps extends TextProperties { + /** + * Children components. + */ + children?: React.ReactNode + + /** + * Text which is looked up via i18n. + */ + tx?: TxKeyPath + + /** + * Optional options to pass to i18n. Useful for interpolation + * as well as explicitly setting locale or translation fallbacks. + */ + txOptions?: i18n.TranslateOptions + + /** + * The text to display if not using `tx` or nested components. + */ + text?: string + + /** + * An optional style override useful for padding & margin. + */ + style?: StyleProp<TextStyle> + + /** + * One of the different types of text presets. + */ + preset?: TextPresets +} diff --git a/app/components/text/text.story.tsx b/app/components/text/text.story.tsx new file mode 100644 index 0000000..5582c1b --- /dev/null +++ b/app/components/text/text.story.tsx @@ -0,0 +1,92 @@ +/* eslint-disable react-native/no-inline-styles */ +/* eslint-disable react-native/no-color-literals */ + +import * as React from "react" +import { View, ViewStyle } from "react-native" +import { storiesOf } from "@storybook/react-native" +import { StoryScreen, Story, UseCase } from "../../../storybook/views" +import { color } from "../../theme" +import { Text } from "./text" + +declare let module + +const VIEWSTYLE = { + flex: 1, + backgroundColor: color.storybookDarkBg, +} +const viewStyleArray: ViewStyle[] = [VIEWSTYLE, { backgroundColor: "#7fff00" }] + +storiesOf("Text", module) + .addDecorator((fn) => <StoryScreen>{fn()}</StoryScreen>) + .add("Style Presets", () => ( + <Story> + <UseCase text="default" usage="Used for normal body text."> + <View style={VIEWSTYLE}> + <Text>Hello!</Text> + <Text style={{ paddingTop: 10 }}> + Check out{"\n"} + my{"\n"} + line height + </Text> + <Text style={{ paddingTop: 10 }}>The quick brown fox jumped over the slow lazy dog.</Text> + <Text>$123,456,789.00</Text> + </View> + </UseCase> + <UseCase text="bold" usage="Used for bolded body text."> + <View style={VIEWSTYLE}> + <Text preset="bold">Osnap! I'm puffy.</Text> + </View> + </UseCase> + <UseCase text="header" usage="Used for major section headers."> + <View style={VIEWSTYLE}> + <Text preset="header">Behold!</Text> + </View> + </UseCase> + </Story> + )) + .add("Passing Content", () => ( + <Story> + <UseCase + text="text" + usage="Used when you want to pass a value but don't want to open a child." + > + <View style={VIEWSTYLE}> + <Text text="Heyo!" /> + </View> + </UseCase> + <UseCase text="tx" usage="Used for looking up i18n keys."> + <View style={VIEWSTYLE}> + <Text tx="common.ok" /> + <Text tx="common.cancel" /> + </View> + </UseCase> + <UseCase + text="children" + usage="Used like you would normally use a React Native <Text> component." + > + <View style={VIEWSTYLE}> + <Text>Passing strings as children.</Text> + </View> + </UseCase> + <UseCase text="nested children" usage="You can embed them and change styles too."> + <View style={VIEWSTYLE}> + <Text> + {" "} + Hello <Text preset="bold">bolded</Text> World. + </Text> + </View> + </UseCase> + </Story> + )) + .add("Styling", () => ( + <Story> + <UseCase text="Style array" usage="Text with style array"> + <View style={viewStyleArray}> + <Text> + {" "} + Hello <Text preset="bold">bolded</Text> World. + </Text> + </View> + </UseCase> + </Story> + )) diff --git a/app/components/text/text.tsx b/app/components/text/text.tsx new file mode 100644 index 0000000..3ea613b --- /dev/null +++ b/app/components/text/text.tsx @@ -0,0 +1,28 @@ +import * as React from "react" +import { Text as ReactNativeText } from "react-native" +import { presets } from "./text.presets" +import { TextProps } from "./text.props" +import { translate } from "../../i18n" + +/** + * For your text displaying needs. + * + * This component is a HOC over the built-in React Native one. + */ +export function Text(props: TextProps) { + // grab the props + const { preset = "default", tx, txOptions, text, children, style: styleOverride, ...rest } = props + + // figure out which content to use + const i18nText = tx && translate(tx, txOptions) + const content = i18nText || text || children + + const style = presets[preset] || presets.default + const styles = [style, styleOverride] + + return ( + <ReactNativeText {...rest} style={styles}> + {content} + </ReactNativeText> + ) +} diff --git a/app/components/wallpaper/bg.png b/app/components/wallpaper/bg.png Binary files differnew file mode 100644 index 0000000..641838e --- /dev/null +++ b/app/components/wallpaper/bg.png diff --git a/app/components/wallpaper/[email protected] b/app/components/wallpaper/[email protected] Binary files differnew file mode 100644 index 0000000..3ae8396 --- /dev/null +++ b/app/components/wallpaper/[email protected] diff --git a/app/components/wallpaper/wallpaper.presets.ts b/app/components/wallpaper/wallpaper.presets.ts new file mode 100644 index 0000000..3885b8f --- /dev/null +++ b/app/components/wallpaper/wallpaper.presets.ts @@ -0,0 +1,34 @@ +import { ImageStyle } from "react-native" + +/** + * All wallpaper will start off looking like this. + */ +const BASE: ImageStyle = { + position: "absolute", + top: 0, + left: 0, + bottom: 0, + right: 0, +} + +/** + * All the variations of wallpaper styling within the app. + * + * You want to customize these to whatever you need in your app. + */ +export const presets = { + /** + * The default wallpaper styles. + */ + stretch: { + ...BASE, + resizeMode: "stretch", + width: null, // Have to set these to null because android ¯\_(ツ)_/¯ + height: null, + } as ImageStyle, +} + +/** + * A list of preset names. + */ +export type WallpaperPresets = keyof typeof presets diff --git a/app/components/wallpaper/wallpaper.props.ts b/app/components/wallpaper/wallpaper.props.ts new file mode 100644 index 0000000..592bac9 --- /dev/null +++ b/app/components/wallpaper/wallpaper.props.ts @@ -0,0 +1,19 @@ +import { ImageStyle, StyleProp } from "react-native" +import { WallpaperPresets } from "./wallpaper.presets" + +export interface WallpaperProps { + /** + * An optional style override useful for padding & margin. + */ + style?: StyleProp<ImageStyle> + + /** + * An optional background image to override the default image. + */ + backgroundImage?: string + + /** + * One of the different types of wallpaper presets. + */ + preset?: WallpaperPresets +} diff --git a/app/components/wallpaper/wallpaper.story.tsx b/app/components/wallpaper/wallpaper.story.tsx new file mode 100644 index 0000000..8f5488a --- /dev/null +++ b/app/components/wallpaper/wallpaper.story.tsx @@ -0,0 +1,16 @@ +import * as React from "react" +import { storiesOf } from "@storybook/react-native" +import { StoryScreen, Story, UseCase } from "../../../storybook/views" +import { Wallpaper } from "./wallpaper" + +declare let module + +storiesOf("Wallpaper", module) + .addDecorator((fn) => <StoryScreen>{fn()}</StoryScreen>) + .add("Style Presets", () => ( + <Story> + <UseCase text="default/stretch" usage="Full screen wallpaper image."> + <Wallpaper /> + </UseCase> + </Story> + )) diff --git a/app/components/wallpaper/wallpaper.tsx b/app/components/wallpaper/wallpaper.tsx new file mode 100644 index 0000000..ebba75a --- /dev/null +++ b/app/components/wallpaper/wallpaper.tsx @@ -0,0 +1,25 @@ +import React from "react" +import { AutoImage as Image } from "../auto-image/auto-image" +import { presets } from "./wallpaper.presets" +import { WallpaperProps } from "./wallpaper.props" + +const defaultImage = require("./bg.png") + +/** + * For your text displaying needs. + * + * This component is a HOC over the built-in React Native one. + */ +export function Wallpaper(props: WallpaperProps) { + // grab the props + const { preset = "stretch", style: styleOverride, backgroundImage } = props + + // assemble the style + const presetToUse = presets[preset] || presets.stretch + const styles = [presetToUse, styleOverride] + + // figure out which image to use + const source = backgroundImage || defaultImage + + return <Image source={source} style={styles} /> +} diff --git a/app/config/env.js b/app/config/env.js new file mode 100644 index 0000000..c5d7ffc --- /dev/null +++ b/app/config/env.js @@ -0,0 +1 @@ +module.exports = __DEV__ ? require("./env.dev") : require("./env.prod") diff --git a/app/i18n/en.json b/app/i18n/en.json new file mode 100644 index 0000000..be5c35c --- /dev/null +++ b/app/i18n/en.json @@ -0,0 +1,34 @@ +{ + "common": { + "ok": "OK!", + "cancel": "Cancel", + "back": "Back" + }, + "errors": { + "invalidEmail": "Invalid email address." + }, + "welcomeScreen": { + "poweredBy": "POWERED BY IGNITE", + "readyForLaunch": "Ready for launch.", + "continue": "CONTINUE" + }, + "demoScreen": { + "howTo": "HOW TO", + "title": "What’s In This Stack?", + "tagLine": "Congratulations, you’ve got a very advanced React Native app template here. Take advantage of this boilerplate!", + "reactotron": "Demo Reactotron", + "demoList": "Demo List", + "androidReactotronHint": "If this doesn't work, ensure the Reactotron desktop app is running, run adb reverse tcp:9090 tcp:9090 from your terminal, and reload the app.", + "iosReactotronHint": "If this doesn't work, ensure the Reactotron desktop app is running and reload app.", + "macosReactotronHint": "If this doesn't work, ensure the Reactotron desktop app is running and reload app.", + "webReactotronHint": "If this doesn't work, ensure the Reactotron desktop app is running and reload app.", + "windowsReactotronHint": "If this doesn't work, ensure the Reactotron desktop app is running and reload app." + }, + "demoListScreen": { + "title": "Demo List" + }, + "storybook": { + "placeholder": "Placeholder", + "field": "Field" + } +} diff --git a/app/i18n/i18n.ts b/app/i18n/i18n.ts new file mode 100644 index 0000000..a563bbb --- /dev/null +++ b/app/i18n/i18n.ts @@ -0,0 +1,22 @@ +import * as Localization from "expo-localization" +import i18n from "i18n-js" +import en from "./en.json" +import ja from "./ja.json" + +i18n.fallbacks = true +i18n.translations = { en, ja } + +i18n.locale = Localization.locale || "en" + +/** + * Builds up valid keypaths for translations. + * Update to your default locale of choice if not English. + */ +type DefaultLocale = typeof en +export type TxKeyPath = RecursiveKeyOf<DefaultLocale> + +type RecursiveKeyOf<TObj extends Record<string, any>> = { + [TKey in keyof TObj & string]: TObj[TKey] extends Record<string, any> + ? `${TKey}` | `${TKey}.${RecursiveKeyOf<TObj[TKey]>}` + : `${TKey}` +}[keyof TObj & string] diff --git a/app/i18n/index.ts b/app/i18n/index.ts new file mode 100644 index 0000000..fbfba4e --- /dev/null +++ b/app/i18n/index.ts @@ -0,0 +1,3 @@ +import "./i18n" +export * from "./i18n" +export * from "./translate" diff --git a/app/i18n/ja.json b/app/i18n/ja.json new file mode 100644 index 0000000..7f53c8c --- /dev/null +++ b/app/i18n/ja.json @@ -0,0 +1,7 @@ +{ + "common": { + "ok": "OK 🇯🇵", + "cancel": "Cancel 🇯🇵", + "back": "Back 🇯🇵" + } +} diff --git a/app/i18n/translate.ts b/app/i18n/translate.ts new file mode 100644 index 0000000..ef1a019 --- /dev/null +++ b/app/i18n/translate.ts @@ -0,0 +1,11 @@ +import i18n from "i18n-js" +import { TxKeyPath } from "./i18n" + +/** + * Translates text. + * + * @param key The i18n key. + */ +export function translate(key: TxKeyPath, options?: i18n.TranslateOptions) { + return key ? i18n.t(key, options) : null +} diff --git a/app/models/character-store/character-store.test.ts b/app/models/character-store/character-store.test.ts new file mode 100644 index 0000000..6d82079 --- /dev/null +++ b/app/models/character-store/character-store.test.ts @@ -0,0 +1,7 @@ +import { CharacterStoreModel } from "./character-store" + +test("can be created", () => { + const instance = CharacterStoreModel.create({}) + + expect(instance).toBeTruthy() +}) diff --git a/app/models/character-store/character-store.ts b/app/models/character-store/character-store.ts new file mode 100644 index 0000000..bd92615 --- /dev/null +++ b/app/models/character-store/character-store.ts @@ -0,0 +1,37 @@ +import { Instance, SnapshotOut, types } from "mobx-state-tree" +import { CharacterModel, CharacterSnapshot } from "../character/character" +import { CharacterApi } from "../../services/api/character-api" +import { withEnvironment } from "../extensions/with-environment" + +/** + * Example store containing Rick and Morty characters + */ +export const CharacterStoreModel = types + .model("CharacterStore") + .props({ + characters: types.optional(types.array(CharacterModel), []), + }) + .extend(withEnvironment) + .actions((self) => ({ + saveCharacters: (characterSnapshots: CharacterSnapshot[]) => { + self.characters.replace(characterSnapshots) + }, + })) + .actions((self) => ({ + getCharacters: async () => { + const characterApi = new CharacterApi(self.environment.api) + const result = await characterApi.getCharacters() + + if (result.kind === "ok") { + self.saveCharacters(result.characters) + } else { + __DEV__ && console.tron.log(result.kind) + } + }, + })) + +type CharacterStoreType = Instance<typeof CharacterStoreModel> +export interface CharacterStore extends CharacterStoreType {} +type CharacterStoreSnapshotType = SnapshotOut<typeof CharacterStoreModel> +export interface CharacterStoreSnapshot extends CharacterStoreSnapshotType {} +export const createCharacterStoreDefaultModel = () => types.optional(CharacterStoreModel, {}) diff --git a/app/models/character/character.test.ts b/app/models/character/character.test.ts new file mode 100644 index 0000000..2f330b4 --- /dev/null +++ b/app/models/character/character.test.ts @@ -0,0 +1,10 @@ +import { CharacterModel } from "./character" + +test("can be created", () => { + const instance = CharacterModel.create({ + id: 1, + name: "Rick Sanchez", + }) + + expect(instance).toBeTruthy() +}) diff --git a/app/models/character/character.ts b/app/models/character/character.ts new file mode 100644 index 0000000..ca3cbc7 --- /dev/null +++ b/app/models/character/character.ts @@ -0,0 +1,17 @@ +import { Instance, SnapshotOut, types } from "mobx-state-tree" + +/** + * Rick and Morty character model. + */ +export const CharacterModel = types.model("Character").props({ + id: types.identifierNumber, + name: types.maybe(types.string), + status: types.maybe(types.string), + image: types.maybe(types.string), +}) + +type CharacterType = Instance<typeof CharacterModel> +export interface Character extends CharacterType {} +type CharacterSnapshotType = SnapshotOut<typeof CharacterModel> +export interface CharacterSnapshot extends CharacterSnapshotType {} +export const createCharacterDefaultModel = () => types.optional(CharacterModel, {}) diff --git a/app/models/environment.ts b/app/models/environment.ts new file mode 100644 index 0000000..e8569b1 --- /dev/null +++ b/app/models/environment.ts @@ -0,0 +1,40 @@ +import { Api } from "../services/api" + +let ReactotronDev +if (__DEV__) { + const { Reactotron } = require("../services/reactotron") + ReactotronDev = Reactotron +} + +/** + * The environment is a place where services and shared dependencies between + * models live. They are made available to every model via dependency injection. + */ +export class Environment { + constructor() { + // create each service + if (__DEV__) { + // dev-only services + this.reactotron = new ReactotronDev() + } + this.api = new Api() + } + + async setup() { + // allow each service to setup + if (__DEV__) { + await this.reactotron.setup() + } + await this.api.setup() + } + + /** + * Reactotron is only available in dev. + */ + reactotron: typeof ReactotronDev + + /** + * Our api. + */ + api: Api +} diff --git a/app/models/extensions/with-environment.ts b/app/models/extensions/with-environment.ts new file mode 100644 index 0000000..9fe5fd5 --- /dev/null +++ b/app/models/extensions/with-environment.ts @@ -0,0 +1,17 @@ +import { getEnv, IStateTreeNode } from "mobx-state-tree" +import { Environment } from "../environment" + +/** + * Adds a environment property to the node for accessing our + * Environment in strongly typed. + */ +export const withEnvironment = (self: IStateTreeNode) => ({ + views: { + /** + * The environment. + */ + get environment() { + return getEnv<Environment>(self) + }, + }, +}) diff --git a/app/models/extensions/with-root-store.ts b/app/models/extensions/with-root-store.ts new file mode 100644 index 0000000..eff769c --- /dev/null +++ b/app/models/extensions/with-root-store.ts @@ -0,0 +1,17 @@ +import { getRoot, IStateTreeNode } from "mobx-state-tree" +import { RootStoreModel } from "../root-store/root-store" + +/** + * Adds a rootStore property to the node for a convenient + * and strongly typed way for stores to access other stores. + */ +export const withRootStore = (self: IStateTreeNode) => ({ + views: { + /** + * The root store. + */ + get rootStore() { + return getRoot<typeof RootStoreModel>(self) + }, + }, +}) diff --git a/app/models/index.ts b/app/models/index.ts new file mode 100644 index 0000000..3538dbb --- /dev/null +++ b/app/models/index.ts @@ -0,0 +1,5 @@ +export * from "./extensions/with-environment" +export * from "./extensions/with-root-store" +export * from "./root-store/root-store" +export * from "./root-store/root-store-context" +export * from "./root-store/setup-root-store" diff --git a/app/models/root-store/root-store-context.ts b/app/models/root-store/root-store-context.ts new file mode 100644 index 0000000..537e51c --- /dev/null +++ b/app/models/root-store/root-store-context.ts @@ -0,0 +1,22 @@ +import { createContext, useContext } from "react" +import { RootStore } from "./root-store" + +/** + * Create a context we can use to + * - Provide access to our stores from our root component + * - Consume stores in our screens (or other components, though it's + * preferable to just connect screens) + */ +const RootStoreContext = createContext<RootStore>({} as RootStore) + +/** + * The provider our root component will use to expose the root store + */ +export const RootStoreProvider = RootStoreContext.Provider + +/** + * A hook that screens can use to gain access to our stores, with + * `const { someStore, someOtherStore } = useStores()`, + * or less likely: `const rootStore = useStores()` + */ +export const useStores = () => useContext(RootStoreContext) diff --git a/app/models/root-store/root-store.ts b/app/models/root-store/root-store.ts new file mode 100644 index 0000000..1131b48 --- /dev/null +++ b/app/models/root-store/root-store.ts @@ -0,0 +1,20 @@ +import { Instance, SnapshotOut, types } from "mobx-state-tree" +import { CharacterStoreModel } from "../character-store/character-store" + +/** + * A RootStore model. + */ +// prettier-ignore +export const RootStoreModel = types.model("RootStore").props({ + characterStore: types.optional(CharacterStoreModel, {} as any), +}) + +/** + * The RootStore instance. + */ +export interface RootStore extends Instance<typeof RootStoreModel> {} + +/** + * The data of a RootStore. + */ +export interface RootStoreSnapshot extends SnapshotOut<typeof RootStoreModel> {} diff --git a/app/models/root-store/setup-root-store.ts b/app/models/root-store/setup-root-store.ts new file mode 100644 index 0000000..4a6d0c5 --- /dev/null +++ b/app/models/root-store/setup-root-store.ts @@ -0,0 +1,55 @@ +import { onSnapshot } from "mobx-state-tree" +import { RootStoreModel, RootStore } from "./root-store" +import { Environment } from "../environment" +import * as storage from "../../utils/storage" + +/** + * The key we'll be saving our state as within async storage. + */ +const ROOT_STATE_STORAGE_KEY = "root" + +/** + * Setup the environment that all the models will be sharing. + * + * The environment includes other functions that will be picked from some + * of the models that get created later. This is how we loosly couple things + * like events between models. + */ +export async function createEnvironment() { + const env = new Environment() + await env.setup() + return env +} + +/** + * Setup the root state. + */ +export async function setupRootStore() { + let rootStore: RootStore + let data: any + + // prepare the environment that will be associated with the RootStore. + const env = await createEnvironment() + try { + // load data from storage + data = (await storage.load(ROOT_STATE_STORAGE_KEY)) || {} + rootStore = RootStoreModel.create(data, env) + } catch (e) { + // if there's any problems loading, then let's at least fallback to an empty state + // instead of crashing. + rootStore = RootStoreModel.create({}, env) + + // but please inform us what happened + __DEV__ && console.tron.error(e.message, null) + } + + // reactotron logging + if (__DEV__) { + env.reactotron.setRootStore(rootStore, data) + } + + // track changes & save to storage + onSnapshot(rootStore, (snapshot) => storage.save(ROOT_STATE_STORAGE_KEY, snapshot)) + + return rootStore +} diff --git a/app/navigators/index.ts b/app/navigators/index.ts new file mode 100644 index 0000000..b1b89a2 --- /dev/null +++ b/app/navigators/index.ts @@ -0,0 +1,4 @@ +export * from "./main-navigator" +export * from "./root-navigator" +export * from "./navigation-utilities" +// export other navigators from here diff --git a/app/navigators/main-navigator.tsx b/app/navigators/main-navigator.tsx new file mode 100644 index 0000000..9ad110a --- /dev/null +++ b/app/navigators/main-navigator.tsx @@ -0,0 +1,57 @@ +/** + * This is the navigator you will modify to display the logged-in screens of your app. + * You can use RootNavigator to also display an auth flow or other user flows. + * + * You'll likely spend most of your time in this file. + */ +import React from "react" +import { createStackNavigator } from "@react-navigation/stack" +import { WelcomeScreen, DemoScreen, DemoListScreen } from "../screens" + +/** + * This type allows TypeScript to know what routes are defined in this navigator + * as well as what properties (if any) they might take when navigating to them. + * + * If no params are allowed, pass through `undefined`. Generally speaking, we + * recommend using your MobX-State-Tree store(s) to keep application state + * rather than passing state through navigation params. + * + * For more information, see this documentation: + * https://reactnavigation.org/docs/params/ + * https://reactnavigation.org/docs/typescript#type-checking-the-navigator + */ +export type PrimaryParamList = { + welcome: undefined + demo: undefined + demoList: undefined +} + +// Documentation: https://reactnavigation.org/docs/stack-navigator/ +const Stack = createStackNavigator<PrimaryParamList>() + +export function MainNavigator() { + return ( + <Stack.Navigator + screenOptions={{ + cardStyle: { backgroundColor: "transparent" }, + headerShown: false, + }} + > + <Stack.Screen name="welcome" component={WelcomeScreen} /> + <Stack.Screen name="demo" component={DemoScreen} /> + <Stack.Screen name="demoList" component={DemoListScreen} /> + </Stack.Navigator> + ) +} + +/** + * A list of routes from which we're allowed to leave the app when + * the user presses the back button on Android. + * + * Anything not on this list will be a standard `back` action in + * react-navigation. + * + * `canExit` is used in ./app/app.tsx in the `useBackButtonHandler` hook. + */ +const exitRoutes = ["welcome"] +export const canExit = (routeName: string) => exitRoutes.includes(routeName) diff --git a/app/navigators/navigation-utilities.tsx b/app/navigators/navigation-utilities.tsx new file mode 100644 index 0000000..de1ea05 --- /dev/null +++ b/app/navigators/navigation-utilities.tsx @@ -0,0 +1,127 @@ +import React, { useState, useEffect, useRef } from "react" +import { BackHandler } from "react-native" +import { PartialState, NavigationState, NavigationContainerRef } from "@react-navigation/native" + +export const RootNavigation = { + navigate(name: string) { + name // eslint-disable-line no-unused-expressions + }, + goBack() {}, // eslint-disable-line @typescript-eslint/no-empty-function + resetRoot(state?: PartialState<NavigationState> | NavigationState) {}, // eslint-disable-line @typescript-eslint/no-unused-vars, @typescript-eslint/no-empty-function + getRootState(): NavigationState { + return {} as any + }, +} + +export const setRootNavigation = (ref: React.RefObject<NavigationContainerRef>) => { + for (const method in RootNavigation) { + RootNavigation[method] = (...args: any) => { + if (ref.current) { + return ref.current[method](...args) + } + } + } +} + +/** + * Gets the current screen from any navigation state. + */ +export function getActiveRouteName(state: NavigationState | PartialState<NavigationState>) { + const route = state.routes[state.index] + + // Found the active route -- return the name + if (!route.state) return route.name + + // Recursive call to deal with nested routers + return getActiveRouteName(route.state) +} + +/** + * Hook that handles Android back button presses and forwards those on to + * the navigation or allows exiting the app. + */ +export function useBackButtonHandler( + ref: React.RefObject<NavigationContainerRef>, + canExit: (routeName: string) => boolean, +) { + const canExitRef = useRef(canExit) + + useEffect(() => { + canExitRef.current = canExit + }, [canExit]) + + useEffect(() => { + // We'll fire this when the back button is pressed on Android. + const onBackPress = () => { + const navigation = ref.current + + if (navigation == null) { + return false + } + + // grab the current route + const routeName = getActiveRouteName(navigation.getRootState()) + + // are we allowed to exit? + if (canExitRef.current(routeName)) { + // let the system know we've not handled this event + return false + } + + // we can't exit, so let's turn this into a back action + if (navigation.canGoBack()) { + navigation.goBack() + + return true + } + + return false + } + + // Subscribe when we come to life + BackHandler.addEventListener("hardwareBackPress", onBackPress) + + // Unsubscribe when we're done + return () => BackHandler.removeEventListener("hardwareBackPress", onBackPress) + }, [ref]) +} + +/** + * Custom hook for persisting navigation state. + */ +export function useNavigationPersistence(storage: any, persistenceKey: string) { + const [initialNavigationState, setInitialNavigationState] = useState() + const [isRestoringNavigationState, setIsRestoringNavigationState] = useState(true) + + const routeNameRef = useRef() + const onNavigationStateChange = (state) => { + const previousRouteName = routeNameRef.current + const currentRouteName = getActiveRouteName(state) + + if (previousRouteName !== currentRouteName) { + // track screens. + __DEV__ && console.tron.log(currentRouteName) + } + + // Save the current route name for later comparision + routeNameRef.current = currentRouteName + + // Persist state to storage + storage.save(persistenceKey, state) + } + + const restoreState = async () => { + try { + const state = await storage.load(persistenceKey) + if (state) setInitialNavigationState(state) + } finally { + setIsRestoringNavigationState(false) + } + } + + useEffect(() => { + if (isRestoringNavigationState) restoreState() + }, [isRestoringNavigationState]) + + return { onNavigationStateChange, restoreState, initialNavigationState } +} diff --git a/app/navigators/root-navigator.tsx b/app/navigators/root-navigator.tsx new file mode 100644 index 0000000..2c04f29 --- /dev/null +++ b/app/navigators/root-navigator.tsx @@ -0,0 +1,59 @@ +/** + * The root navigator is used to switch between major navigation flows of your app. + * Generally speaking, it will contain an auth flow (registration, login, forgot password) + * and a "main" flow (which is contained in your MainNavigator) which the user + * will use once logged in. + */ +import React from "react" +import { NavigationContainer, NavigationContainerRef } from "@react-navigation/native" +import { createStackNavigator } from "@react-navigation/stack" +import { MainNavigator } from "./main-navigator" +import { color } from "../theme" + +/** + * This type allows TypeScript to know what routes are defined in this navigator + * as well as what properties (if any) they might take when navigating to them. + * + * We recommend using MobX-State-Tree store(s) to handle state rather than navigation params. + * + * For more information, see this documentation: + * https://reactnavigation.org/docs/params/ + * https://reactnavigation.org/docs/typescript#type-checking-the-navigator + */ +export type RootParamList = { + mainStack: undefined +} + +const Stack = createStackNavigator<RootParamList>() + +const RootStack = () => { + return ( + <Stack.Navigator + screenOptions={{ + cardStyle: { backgroundColor: color.palette.deepPurple }, + headerShown: false, + }} + > + <Stack.Screen + name="mainStack" + component={MainNavigator} + options={{ + headerShown: false, + }} + /> + </Stack.Navigator> + ) +} + +export const RootNavigator = React.forwardRef< + NavigationContainerRef, + Partial<React.ComponentProps<typeof NavigationContainer>> +>((props, ref) => { + return ( + <NavigationContainer {...props} ref={ref}> + <RootStack /> + </NavigationContainer> + ) +}) + +RootNavigator.displayName = "RootNavigator" diff --git a/app/screens/demo/demo-list-screen.tsx b/app/screens/demo/demo-list-screen.tsx new file mode 100644 index 0000000..ae66307 --- /dev/null +++ b/app/screens/demo/demo-list-screen.tsx @@ -0,0 +1,86 @@ +import React, { useEffect } from "react" +import { FlatList, TextStyle, View, ViewStyle, ImageStyle } from "react-native" +import { useNavigation } from "@react-navigation/native" +import { observer } from "mobx-react-lite" +import { Header, Screen, Text, Wallpaper, AutoImage as Image } from "../../components" +import { color, spacing } from "../../theme" +import { useStores } from "../../models" + +const FULL: ViewStyle = { + flex: 1, +} +const CONTAINER: ViewStyle = { + backgroundColor: color.transparent, +} +const HEADER: TextStyle = { + paddingBottom: spacing[5] - 1, + paddingHorizontal: spacing[4], + paddingTop: spacing[3], +} +const HEADER_TITLE: TextStyle = { + fontSize: 12, + fontWeight: "bold", + letterSpacing: 1.5, + lineHeight: 15, + textAlign: "center", +} +const LIST_CONTAINER: ViewStyle = { + alignItems: "center", + flexDirection: "row", + padding: 10, +} +const IMAGE: ImageStyle = { + borderRadius: 35, + height: 65, + width: 65, +} +const LIST_TEXT: TextStyle = { + marginLeft: 10, +} +const FLAT_LIST: ViewStyle = { + paddingHorizontal: spacing[4], +} + +export const DemoListScreen = observer(function DemoListScreen() { + const navigation = useNavigation() + const goBack = () => navigation.goBack() + + const { characterStore } = useStores() + const { characters } = characterStore + + useEffect(() => { + async function fetchData() { + await characterStore.getCharacters() + } + + fetchData() + }, []) + + return ( + <View testID="DemoListScreen" style={FULL}> + <Wallpaper /> + <Screen style={CONTAINER} preset="fixed" backgroundColor={color.transparent}> + <Header + headerTx="demoListScreen.title" + leftIcon="back" + onLeftPress={goBack} + style={HEADER} + titleStyle={HEADER_TITLE} + /> + <FlatList + contentContainerStyle={FLAT_LIST} + data={[...characters]} + keyExtractor={(item) => String(item.id)} + renderItem={({ item }) => ( + <View style={LIST_CONTAINER}> + <Image source={{ uri: item.image }} style={IMAGE} /> + <Text style={LIST_TEXT}> + {item.name} ({item.status}) + </Text> + </View> + )} + /> + </Screen> + </View> + ) +}) diff --git a/app/screens/demo/demo-screen.tsx b/app/screens/demo/demo-screen.tsx new file mode 100644 index 0000000..40e3830 --- /dev/null +++ b/app/screens/demo/demo-screen.tsx @@ -0,0 +1,181 @@ +import React from "react" +import { ImageStyle, Platform, TextStyle, View, ViewStyle } from "react-native" +import { useNavigation } from "@react-navigation/native" +import { observer } from "mobx-react-lite" +import { + BulletItem, + Button, + Header, + Text, + Screen, + Wallpaper, + AutoImage as Image, +} from "../../components" +import { color, spacing } from "../../theme" +import { Api } from "../../services/api" +import { save } from "../../utils/storage" +export const logoIgnite = require("./logo-ignite.png") +export const heart = require("./heart.png") + +const FULL: ViewStyle = { flex: 1 } +const CONTAINER: ViewStyle = { + backgroundColor: color.transparent, + paddingHorizontal: spacing[4], +} +const DEMO: ViewStyle = { + paddingVertical: spacing[4], + paddingHorizontal: spacing[4], + backgroundColor: color.palette.deepPurple, +} +const BOLD: TextStyle = { fontWeight: "bold" } +const DEMO_TEXT: TextStyle = { + ...BOLD, + fontSize: 13, + letterSpacing: 2, +} +const HEADER: TextStyle = { + paddingTop: spacing[3], + paddingBottom: spacing[5] - 1, + paddingHorizontal: 0, +} +const HEADER_TITLE: TextStyle = { + ...BOLD, + fontSize: 12, + lineHeight: 15, + textAlign: "center", + letterSpacing: 1.5, +} +const TITLE: TextStyle = { + ...BOLD, + fontSize: 28, + lineHeight: 38, + textAlign: "center", + marginBottom: spacing[5], +} +const TAGLINE: TextStyle = { + color: "#BAB6C8", + fontSize: 15, + lineHeight: 22, + marginBottom: spacing[4] + spacing[1], +} +const IGNITE: ImageStyle = { + marginVertical: spacing[6], + alignSelf: "center", + width: 180, + height: 100, +} +const LOVE_WRAPPER: ViewStyle = { + flexDirection: "row", + alignItems: "center", + alignSelf: "center", +} +const LOVE: TextStyle = { + color: "#BAB6C8", + fontSize: 15, + lineHeight: 22, +} +const HEART: ImageStyle = { + marginHorizontal: spacing[2], + width: 10, + height: 10, + resizeMode: "contain", +} +const HINT: TextStyle = { + color: "#BAB6C8", + fontSize: 12, + lineHeight: 15, + marginVertical: spacing[2], +} + +const platformCommand = Platform.select({ + ios: "Cmd + D", + android: "Cmd/Ctrl + M", +}) + +export const DemoScreen = observer(function DemoScreen() { + const navigation = useNavigation() + const goBack = () => navigation.goBack() + + const demoReactotron = React.useMemo( + () => async () => { + console.tron.log("Your Friendly tron log message") + console.tron.logImportant("I am important") + console.tron.display({ + name: "DISPLAY", + value: { + numbers: 1, + strings: "strings", + booleans: true, + arrays: [1, 2, 3], + objects: { + deeper: { + deeper: { + yay: "👾", + }, + }, + }, + functionNames: function hello() { + /* dummy function */ + }, + }, + preview: "More control with display()", + important: true, + image: { + uri: + "https://avatars2.githubusercontent.com/u/3902527?s=200&u=a0d16b13ed719f35d95ca0f4440f5d07c32c349a&v=4", + }, + }) + // make an API call for the demo + // Don't do API like this, use store's API + const demo = new Api() + demo.setup() + demo.getUser("1") + // Let's do some async storage stuff + await save("Cool Name", "Boaty McBoatface") + }, + [], + ) + + return ( + <View testID="DemoScreen" style={FULL}> + <Wallpaper /> + <Screen style={CONTAINER} preset="scroll" backgroundColor={color.transparent}> + <Header + headerTx="demoScreen.howTo" + leftIcon="back" + onLeftPress={goBack} + style={HEADER} + titleStyle={HEADER_TITLE} + /> + <Text style={TITLE} preset="header" tx="demoScreen.title" /> + <Text style={TAGLINE} tx="demoScreen.tagLine" /> + <BulletItem text="Integrated here, Navigation with State, TypeScript, Storybook, Solidarity, and i18n." /> + <BulletItem + text={`To run Storybook, press ${platformCommand} or shake the device to show the developer menu, then select "Toggle Storybook"`} + /> + <BulletItem text="Load up Reactotron! You can inspect your app, view the events, interact, and so much more!" /> + <View> + <Button + style={DEMO} + textStyle={DEMO_TEXT} + tx="demoScreen.reactotron" + onPress={demoReactotron} + /> + <Text style={HINT} tx={`demoScreen.${Platform.OS}ReactotronHint` as const} /> + </View> + <Button + style={DEMO} + textStyle={DEMO_TEXT} + tx="demoScreen.demoList" + onPress={() => navigation.navigate("demoList")} + /> + <Image source={logoIgnite} style={IGNITE} /> + <View style={LOVE_WRAPPER}> + <Text style={LOVE} text="Made with" /> + <Image source={heart} style={HEART} /> + <Text style={LOVE} text="by Infinite Red" /> + </View> + </Screen> + </View> + ) +}) diff --git a/app/screens/demo/heart.png b/app/screens/demo/heart.png Binary files differnew file mode 100644 index 0000000..9890998 --- /dev/null +++ b/app/screens/demo/heart.png diff --git a/app/screens/demo/[email protected] b/app/screens/demo/[email protected] Binary files differnew file mode 100644 index 0000000..e8b70d9 --- /dev/null +++ b/app/screens/demo/[email protected] diff --git a/app/screens/demo/logo-ignite.png b/app/screens/demo/logo-ignite.png Binary files differnew file mode 100644 index 0000000..36af16d --- /dev/null +++ b/app/screens/demo/logo-ignite.png diff --git a/app/screens/demo/[email protected] b/app/screens/demo/[email protected] Binary files differnew file mode 100644 index 0000000..fdbfad8 --- /dev/null +++ b/app/screens/demo/[email protected] diff --git a/app/screens/index.ts b/app/screens/index.ts new file mode 100644 index 0000000..17c8ade --- /dev/null +++ b/app/screens/index.ts @@ -0,0 +1,4 @@ +export * from "./welcome/welcome-screen" +export * from "./demo/demo-screen" +export * from "./demo/demo-list-screen" +// export other screens here diff --git a/app/screens/welcome/bowser.png b/app/screens/welcome/bowser.png Binary files differnew file mode 100644 index 0000000..2b0bdbc --- /dev/null +++ b/app/screens/welcome/bowser.png diff --git a/app/screens/welcome/[email protected] b/app/screens/welcome/[email protected] Binary files differnew file mode 100644 index 0000000..85cc693 --- /dev/null +++ b/app/screens/welcome/[email protected] diff --git a/app/screens/welcome/welcome-screen.tsx b/app/screens/welcome/welcome-screen.tsx new file mode 100644 index 0000000..47ed2ce --- /dev/null +++ b/app/screens/welcome/welcome-screen.tsx @@ -0,0 +1,118 @@ +import React from "react" +import { View, ViewStyle, TextStyle, ImageStyle, SafeAreaView } from "react-native" +import { useNavigation } from "@react-navigation/native" +import { observer } from "mobx-react-lite" +import { Button, Header, Screen, Text, Wallpaper, AutoImage as Image } from "../../components" +import { color, spacing, typography } from "../../theme" +const bowserLogo = require("./bowser.png") + +const FULL: ViewStyle = { flex: 1 } +const CONTAINER: ViewStyle = { + backgroundColor: color.transparent, + paddingHorizontal: spacing[4], +} +const TEXT: TextStyle = { + color: color.palette.white, + fontFamily: typography.primary, +} +const BOLD: TextStyle = { fontWeight: "bold" } +const HEADER: TextStyle = { + paddingTop: spacing[3], + paddingBottom: spacing[4] + spacing[1], + paddingHorizontal: 0, +} +const HEADER_TITLE: TextStyle = { + ...TEXT, + ...BOLD, + fontSize: 12, + lineHeight: 15, + textAlign: "center", + letterSpacing: 1.5, +} +const TITLE_WRAPPER: TextStyle = { + ...TEXT, + textAlign: "center", +} +const TITLE: TextStyle = { + ...TEXT, + ...BOLD, + fontSize: 28, + lineHeight: 38, + textAlign: "center", +} +const ALMOST: TextStyle = { + ...TEXT, + ...BOLD, + fontSize: 26, + fontStyle: "italic", +} +const BOWSER: ImageStyle = { + alignSelf: "center", + marginVertical: spacing[5], + maxWidth: "100%", + width: 343, + height: 230, +} +const CONTENT: TextStyle = { + ...TEXT, + color: "#BAB6C8", + fontSize: 15, + lineHeight: 22, + marginBottom: spacing[5], +} +const CONTINUE: ViewStyle = { + paddingVertical: spacing[4], + paddingHorizontal: spacing[4], + backgroundColor: color.palette.deepPurple, +} +const CONTINUE_TEXT: TextStyle = { + ...TEXT, + ...BOLD, + fontSize: 13, + letterSpacing: 2, +} +const FOOTER: ViewStyle = { backgroundColor: "#20162D" } +const FOOTER_CONTENT: ViewStyle = { + paddingVertical: spacing[4], + paddingHorizontal: spacing[4], +} + +export const WelcomeScreen = observer(function WelcomeScreen() { + const navigation = useNavigation() + const nextScreen = () => navigation.navigate("demo") + + return ( + <View testID="WelcomeScreen" style={FULL}> + <Wallpaper /> + <Screen style={CONTAINER} preset="scroll" backgroundColor={color.transparent}> + <Header headerTx="welcomeScreen.poweredBy" style={HEADER} titleStyle={HEADER_TITLE} /> + <Text style={TITLE_WRAPPER}> + <Text style={TITLE} text="Your new app, " /> + <Text style={ALMOST} text="almost" /> + <Text style={TITLE} text="!" /> + </Text> + <Text style={TITLE} preset="header" tx="welcomeScreen.readyForLaunch" /> + <Image source={bowserLogo} style={BOWSER} /> + <Text style={CONTENT}> + This probably isn't what your app is going to look like. Unless your designer handed you + this screen and, in that case, congrats! You're ready to ship. + </Text> + <Text style={CONTENT}> + For everyone else, this is where you'll see a live preview of your fully functioning app + using Ignite. + </Text> + </Screen> + <SafeAreaView style={FOOTER}> + <View style={FOOTER_CONTENT}> + <Button + testID="next-screen-button" + style={CONTINUE} + textStyle={CONTINUE_TEXT} + tx="welcomeScreen.continue" + onPress={nextScreen} + /> + </View> + </SafeAreaView> + </View> + ) +}) diff --git a/app/services/api/api-config.ts b/app/services/api/api-config.ts new file mode 100644 index 0000000..3061563 --- /dev/null +++ b/app/services/api/api-config.ts @@ -0,0 +1,27 @@ +// Use this import if you want to use "env.js" file +// const { API_URL } = require("../../config/env") +// Or just specify it directly like this: +const API_URL = "http://example.com" + +/** + * The options used to configure the API. + */ +export interface ApiConfig { + /** + * The URL of the api. + */ + url: string + + /** + * Milliseconds before we timeout the request. + */ + timeout: number +} + +/** + * The default configuration for the app. + */ +export const DEFAULT_API_CONFIG: ApiConfig = { + url: API_URL || "https://jsonplaceholder.typicode.com", + timeout: 10000, +} diff --git a/app/services/api/api-problem.test.ts b/app/services/api/api-problem.test.ts new file mode 100644 index 0000000..ccd35c9 --- /dev/null +++ b/app/services/api/api-problem.test.ts @@ -0,0 +1,72 @@ +import { getGeneralApiProblem } from "./api-problem" +import { ApiErrorResponse } from "apisauce" + +test("handles connection errors", () => { + expect(getGeneralApiProblem({ problem: "CONNECTION_ERROR" } as ApiErrorResponse<null>)).toEqual({ + kind: "cannot-connect", + temporary: true, + }) +}) + +test("handles network errors", () => { + expect(getGeneralApiProblem({ problem: "NETWORK_ERROR" } as ApiErrorResponse<null>)).toEqual({ + kind: "cannot-connect", + temporary: true, + }) +}) + +test("handles timeouts", () => { + expect(getGeneralApiProblem({ problem: "TIMEOUT_ERROR" } as ApiErrorResponse<null>)).toEqual({ + kind: "timeout", + temporary: true, + }) +}) + +test("handles server errors", () => { + expect(getGeneralApiProblem({ problem: "SERVER_ERROR" } as ApiErrorResponse<null>)).toEqual({ + kind: "server", + }) +}) + +test("handles unknown errors", () => { + expect(getGeneralApiProblem({ problem: "UNKNOWN_ERROR" } as ApiErrorResponse<null>)).toEqual({ + kind: "unknown", + temporary: true, + }) +}) + +test("handles unauthorized errors", () => { + expect( + getGeneralApiProblem({ problem: "CLIENT_ERROR", status: 401 } as ApiErrorResponse<null>), + ).toEqual({ + kind: "unauthorized", + }) +}) + +test("handles forbidden errors", () => { + expect( + getGeneralApiProblem({ problem: "CLIENT_ERROR", status: 403 } as ApiErrorResponse<null>), + ).toEqual({ + kind: "forbidden", + }) +}) + +test("handles not-found errors", () => { + expect( + getGeneralApiProblem({ problem: "CLIENT_ERROR", status: 404 } as ApiErrorResponse<null>), + ).toEqual({ + kind: "not-found", + }) +}) + +test("handles other client errors", () => { + expect( + getGeneralApiProblem({ problem: "CLIENT_ERROR", status: 418 } as ApiErrorResponse<null>), + ).toEqual({ + kind: "rejected", + }) +}) + +test("handles cancellation errors", () => { + expect(getGeneralApiProblem({ problem: "CANCEL_ERROR" } as ApiErrorResponse<null>)).toBeNull() +}) diff --git a/app/services/api/api-problem.ts b/app/services/api/api-problem.ts new file mode 100644 index 0000000..9c2aa49 --- /dev/null +++ b/app/services/api/api-problem.ts @@ -0,0 +1,74 @@ +import { ApiResponse } from "apisauce" + +export type GeneralApiProblem = + /** + * Times up. + */ + | { kind: "timeout"; temporary: true } + /** + * Cannot connect to the server for some reason. + */ + | { kind: "cannot-connect"; temporary: true } + /** + * The server experienced a problem. Any 5xx error. + */ + | { kind: "server" } + /** + * We're not allowed because we haven't identified ourself. This is 401. + */ + | { kind: "unauthorized" } + /** + * We don't have access to perform that request. This is 403. + */ + | { kind: "forbidden" } + /** + * Unable to find that resource. This is a 404. + */ + | { kind: "not-found" } + /** + * All other 4xx series errors. + */ + | { kind: "rejected" } + /** + * Something truly unexpected happened. Most likely can try again. This is a catch all. + */ + | { kind: "unknown"; temporary: true } + /** + * The data we received is not in the expected format. + */ + | { kind: "bad-data" } + +/** + * Attempts to get a common cause of problems from an api response. + * + * @param response The api response. + */ +export function getGeneralApiProblem(response: ApiResponse<any>): GeneralApiProblem | void { + switch (response.problem) { + case "CONNECTION_ERROR": + return { kind: "cannot-connect", temporary: true } + case "NETWORK_ERROR": + return { kind: "cannot-connect", temporary: true } + case "TIMEOUT_ERROR": + return { kind: "timeout", temporary: true } + case "SERVER_ERROR": + return { kind: "server" } + case "UNKNOWN_ERROR": + return { kind: "unknown", temporary: true } + case "CLIENT_ERROR": + switch (response.status) { + case 401: + return { kind: "unauthorized" } + case 403: + return { kind: "forbidden" } + case 404: + return { kind: "not-found" } + default: + return { kind: "rejected" } + } + case "CANCEL_ERROR": + return null + } + + return null +} diff --git a/app/services/api/api.ts b/app/services/api/api.ts new file mode 100644 index 0000000..15f6815 --- /dev/null +++ b/app/services/api/api.ts @@ -0,0 +1,102 @@ +import { ApisauceInstance, create, ApiResponse } from "apisauce" +import { getGeneralApiProblem } from "./api-problem" +import { ApiConfig, DEFAULT_API_CONFIG } from "./api-config" +import * as Types from "./api.types" + +/** + * Manages all requests to the API. + */ +export class Api { + /** + * The underlying apisauce instance which performs the requests. + */ + apisauce: ApisauceInstance + + /** + * Configurable options. + */ + config: ApiConfig + + /** + * Creates the api. + * + * @param config The configuration to use. + */ + constructor(config: ApiConfig = DEFAULT_API_CONFIG) { + this.config = config + } + + /** + * Sets up the API. This will be called during the bootup + * sequence and will happen before the first React component + * is mounted. + * + * Be as quick as possible in here. + */ + setup() { + // construct the apisauce instance + this.apisauce = create({ + baseURL: this.config.url, + timeout: this.config.timeout, + headers: { + Accept: "application/json", + }, + }) + } + + /** + * Gets a list of users. + */ + async getUsers(): Promise<Types.GetUsersResult> { + // make the api call + const response: ApiResponse<any> = await this.apisauce.get(`/users`) + + // the typical ways to die when calling an api + if (!response.ok) { + const problem = getGeneralApiProblem(response) + if (problem) return problem + } + + const convertUser = (raw) => { + return { + id: raw.id, + name: raw.name, + } + } + + // transform the data into the format we are expecting + try { + const rawUsers = response.data + const resultUsers: Types.User[] = rawUsers.map(convertUser) + return { kind: "ok", users: resultUsers } + } catch { + return { kind: "bad-data" } + } + } + + /** + * Gets a single user by ID + */ + + async getUser(id: string): Promise<Types.GetUserResult> { + // make the api call + const response: ApiResponse<any> = await this.apisauce.get(`/users/${id}`) + + // the typical ways to die when calling an api + if (!response.ok) { + const problem = getGeneralApiProblem(response) + if (problem) return problem + } + + // transform the data into the format we are expecting + try { + const resultUser: Types.User = { + id: response.data.id, + name: response.data.name, + } + return { kind: "ok", user: resultUser } + } catch { + return { kind: "bad-data" } + } + } +} diff --git a/app/services/api/api.types.ts b/app/services/api/api.types.ts new file mode 100644 index 0000000..dde58c0 --- /dev/null +++ b/app/services/api/api.types.ts @@ -0,0 +1,13 @@ +import { GeneralApiProblem } from "./api-problem" +import { Character } from "../../models/character/character" + +export interface User { + id: number + name: string +} + +export type GetUsersResult = { kind: "ok"; users: User[] } | GeneralApiProblem +export type GetUserResult = { kind: "ok"; user: User } | GeneralApiProblem + +export type GetCharactersResult = { kind: "ok"; characters: Character[] } | GeneralApiProblem +export type GetCharacterResult = { kind: "ok"; character: Character } | GeneralApiProblem diff --git a/app/services/api/character-api.ts b/app/services/api/character-api.ts new file mode 100644 index 0000000..7755006 --- /dev/null +++ b/app/services/api/character-api.ts @@ -0,0 +1,37 @@ +import { ApiResponse } from "apisauce" +import { Api } from "./api" +import { GetCharactersResult } from "./api.types" +import { getGeneralApiProblem } from "./api-problem" + +const API_PAGE_SIZE = 50 + +export class CharacterApi { + private api: Api + + constructor(api: Api) { + this.api = api + } + + async getCharacters(): Promise<GetCharactersResult> { + try { + // make the api call + const response: ApiResponse<any> = await this.api.apisauce.get( + "https://raw.githubusercontent.com/infinitered/ignite/master/data/rick-and-morty.json", + { amount: API_PAGE_SIZE }, + ) + + // the typical ways to die when calling an api + if (!response.ok) { + const problem = getGeneralApiProblem(response) + if (problem) return problem + } + + const characters = response.data.results + + return { kind: "ok", characters } + } catch (e) { + __DEV__ && console.tron.log(e.message) + return { kind: "bad-data" } + } + } +} diff --git a/app/services/api/index.ts b/app/services/api/index.ts new file mode 100644 index 0000000..a12bb55 --- /dev/null +++ b/app/services/api/index.ts @@ -0,0 +1,2 @@ +export * from "./api" +export * from "./api.types" diff --git a/app/services/reactotron/index.ts b/app/services/reactotron/index.ts new file mode 100644 index 0000000..60a8b40 --- /dev/null +++ b/app/services/reactotron/index.ts @@ -0,0 +1 @@ +export * from "./reactotron" diff --git a/app/services/reactotron/reactotron-config.ts b/app/services/reactotron/reactotron-config.ts new file mode 100644 index 0000000..dd87bd7 --- /dev/null +++ b/app/services/reactotron/reactotron-config.ts @@ -0,0 +1,30 @@ +export interface ReactotronConfig { + /** The name of the app. */ + name?: string + /** The host to connect to: default 'localhost'. */ + host?: string + /** Should we use async storage */ + useAsyncStorage?: boolean + /** Should we clear Reactotron when load? */ + clearOnLoad?: boolean + /** Root state logging. */ + state?: { + /** log the initial data that we put into the state on startup? */ + initial?: boolean + /** log snapshot changes. */ + snapshots?: boolean + } +} + +/** + * The default Reactotron configuration. + */ +export const DEFAULT_REACTOTRON_CONFIG: ReactotronConfig = { + clearOnLoad: true, + host: "localhost", + useAsyncStorage: true, + state: { + initial: true, + snapshots: false, + }, +} diff --git a/app/services/reactotron/reactotron.ts b/app/services/reactotron/reactotron.ts new file mode 100644 index 0000000..0ec12ad --- /dev/null +++ b/app/services/reactotron/reactotron.ts @@ -0,0 +1,181 @@ +import { Tron } from "./tron" +import AsyncStorage from "@react-native-async-storage/async-storage" +import { RootStore } from "../../models/root-store/root-store" +import { onSnapshot } from "mobx-state-tree" +import { ReactotronConfig, DEFAULT_REACTOTRON_CONFIG } from "./reactotron-config" +import { mst } from "reactotron-mst" +import { clear } from "../../utils/storage" +import { RootNavigation } from "../../navigators" +import { Platform } from "react-native" + +// Teach TypeScript about the bad things we want to do. +declare global { + interface Console { + /** + * Hey, it's Reactotron if we're in dev, and no-ops if we're in prod. + */ + tron: typeof Tron + } +} + +/** Do Nothing. */ +const noop = () => undefined + +// in dev, we attach Reactotron, in prod we attach a interface-compatible mock. +if (__DEV__) { + console.tron = Tron // attach reactotron to `console.tron` +} else { + // attach a mock so if things sneaky by our __DEV__ guards, we won't crash. + console.tron = { + benchmark: noop, + clear: noop, + close: noop, + configure: noop, + connect: noop, + display: noop, + error: noop, + image: noop, + log: noop, + logImportant: noop, + onCustomCommand: noop, + overlay: noop, + reportError: noop, + send: noop, + startTimer: noop, + storybookSwitcher: noop, + use: noop, + useReactNative: noop, + warn: noop, + } +} + +/** + * You'll probably never use the service like this since we hang the Reactotron + * instance off of `console.tron`. This is only to be consistent with the other + * services. + */ +export class Reactotron { + config: ReactotronConfig + + rootStore: any + + /** + * Create the Reactotron service. + * + * @param config the configuration + */ + constructor(config: ReactotronConfig = DEFAULT_REACTOTRON_CONFIG) { + // merge the passed in config with some defaults + this.config = { + host: "localhost", + useAsyncStorage: true, + ...config, + state: { + initial: false, + snapshots: false, + ...(config && config.state), + }, + } + } + + /** + * Hook into the root store for doing awesome state-related things. + * + * @param rootStore The root store + */ + setRootStore(rootStore: any, initialData: any) { + if (__DEV__) { + rootStore = rootStore as RootStore // typescript hack + this.rootStore = rootStore + + const { initial, snapshots } = this.config.state + const name = "ROOT STORE" + + // logging features + if (initial) { + console.tron.display({ name, value: initialData, preview: "Initial State" }) + } + // log state changes? + if (snapshots) { + onSnapshot(rootStore, (snapshot) => { + console.tron.display({ name, value: snapshot, preview: "New State" }) + }) + } + + console.tron.trackMstNode(rootStore) + } + } + + /** + * Configure reactotron based on the the config settings passed in, then connect if we need to. + */ + async setup() { + // only run this in dev... metro bundler will ignore this block: 🎉 + if (__DEV__) { + // configure reactotron + Tron.configure({ + name: this.config.name || require("../../../package.json").name, + host: this.config.host, + }) + + // hookup middleware + if (Platform.OS !== "web") { + if (this.config.useAsyncStorage) { + Tron.setAsyncStorageHandler(AsyncStorage) + } + Tron.useReactNative({ + asyncStorage: this.config.useAsyncStorage ? undefined : false, + }) + } + + // ignore some chatty `mobx-state-tree` actions + const RX = /postProcessSnapshot|@APPLY_SNAPSHOT/ + + // hookup mobx-state-tree middleware + Tron.use( + mst({ + filter: (event) => RX.test(event.name) === false, + }), + ) + + // connect to the app + Tron.connect() + + // Register Custom Commands + Tron.onCustomCommand({ + title: "Reset Root Store", + description: "Resets the MST store", + command: "resetStore", + handler: () => { + console.tron.log("resetting store") + clear() + }, + }) + + Tron.onCustomCommand({ + title: "Reset Navigation State", + description: "Resets the navigation state", + command: "resetNavigation", + handler: () => { + console.tron.log("resetting navigation state") + RootNavigation.resetRoot({ routes: [] }) + }, + }) + + Tron.onCustomCommand({ + title: "Go Back", + description: "Goes back", + command: "goBack", + handler: () => { + console.tron.log("Going back") + RootNavigation.goBack() + }, + }) + + // clear if we should + if (this.config.clearOnLoad) { + Tron.clear() + } + } + } +} diff --git a/app/services/reactotron/tron.ts b/app/services/reactotron/tron.ts new file mode 100644 index 0000000..f6c872d --- /dev/null +++ b/app/services/reactotron/tron.ts @@ -0,0 +1,2 @@ +import Reactotron from "reactotron-react-native" +export const Tron = Reactotron diff --git a/app/services/reactotron/tron.web.ts b/app/services/reactotron/tron.web.ts new file mode 100644 index 0000000..bcee53b --- /dev/null +++ b/app/services/reactotron/tron.web.ts @@ -0,0 +1,2 @@ +import Reactotron from "reactotron-react-js" +export const Tron = Reactotron diff --git a/app/theme/color.ts b/app/theme/color.ts new file mode 100644 index 0000000..7cbb7c7 --- /dev/null +++ b/app/theme/color.ts @@ -0,0 +1,64 @@ +import { palette } from "./palette" + +/** + * Roles for colors. Prefer using these over the palette. It makes it easier + * to change things. + * + * The only roles we need to place in here are the ones that span through the app. + * + * If you have a specific use-case, like a spinner color. It makes more sense to + * put that in the <Spinner /> component. + */ +export const color = { + /** + * The palette is available to use, but prefer using the name. + */ + palette, + /** + * A helper for making something see-thru. Use sparingly as many layers of transparency + * can cause older Android devices to slow down due to the excessive compositing required + * by their under-powered GPUs. + */ + transparent: "rgba(0, 0, 0, 0)", + /** + * The screen background. + */ + background: palette.white, + /** + * The main tinting color. + */ + primary: palette.orange, + /** + * The main tinting color, but darker. + */ + primaryDarker: palette.orangeDarker, + /** + * A subtle color used for borders and lines. + */ + line: palette.offWhite, + /** + * The default color of text in many components. + */ + text: palette.white, + /** + * Secondary information. + */ + dim: palette.lightGrey, + /** + * Error messages and icons. + */ + error: palette.angry, + + /** + * Storybook background for Text stories, or any stories where + * the text color is color.text, which is white by default, and does not show + * in Stories against the default white background + */ + storybookDarkBg: palette.black, + + /** + * Storybook text color for stories that display Text components against the + * white background + */ + storybookTextColor: palette.black, +} diff --git a/app/theme/fonts/index.ts b/app/theme/fonts/index.ts new file mode 100644 index 0000000..e55248d --- /dev/null +++ b/app/theme/fonts/index.ts @@ -0,0 +1,12 @@ +// import * as Font from "expo-font" + +export const initFonts = async () => { + // Refer to ./assets/fonts/custom-fonts.md for instructions. + // ... + // Welcome back! Just uncomment this and replace/append with your font file names! + // ⬇ + // await Font.loadAsync({ + // Montserrat: require("./Montserrat-Regular.ttf"), + // "Montserrat-Regular": require("./Montserrat-Regular.ttf"), + // }) +} diff --git a/app/theme/index.ts b/app/theme/index.ts new file mode 100644 index 0000000..46bf684 --- /dev/null +++ b/app/theme/index.ts @@ -0,0 +1,4 @@ +export * from "./color" +export * from "./spacing" +export * from "./typography" +export * from "./timing" diff --git a/app/theme/palette.ts b/app/theme/palette.ts new file mode 100644 index 0000000..ee16af4 --- /dev/null +++ b/app/theme/palette.ts @@ -0,0 +1,11 @@ +export const palette = { + black: "#1d1d1d", + white: "#ffffff", + offWhite: "#e6e6e6", + orange: "#FBA928", + orangeDarker: "#EB9918", + lightGrey: "#939AA4", + lighterGrey: "#CDD4DA", + angry: "#dd3333", + deepPurple: "#5D2555", +} diff --git a/app/theme/spacing.ts b/app/theme/spacing.ts new file mode 100644 index 0000000..b1b7935 --- /dev/null +++ b/app/theme/spacing.ts @@ -0,0 +1,41 @@ +/** + * NOTE TO DEVS: + * + * Spacing should be consistent and whitespace thought of as a first class technique up + * there with color and typefaces. + * + * Which type of scale you use is based on the design. + * + * If you've got simpler app, you may only need 6 items. Or maybe you want a spacing scale + * to be named: + * + * export const spacing = { + * tiny: 4, + * small: 8, + * medium: 12, + * large: 24, + * huge: 64 + * } + * + * Whatever you choose, try to stick with these, and not freestyle it everywhere. + * + * Feel free to delete this block. + */ + +/** + * The available spacing. + * + * Here's the rough guideline. Customize this for you usage. It's ok to put exceptions + * within the components themselves if they are truly exceptions. + * + * 0 = none - nothing. only here to bust out of a zero-based array. + * 1 = tiny - elements contextually close to each other + * 2 = smaller - for groups of closely related items or perhaps borders + * 3 = small - ? + * 4 = medium - ? + * 5 = medium+ - ? + * 6 = large - between groups of content that aren't related? + * 7 = huge - ? + * 8 = massive - an uncomfortable amount of whitespace + */ +export const spacing = [0, 4, 8, 12, 16, 24, 32, 48, 64] diff --git a/app/theme/timing.ts b/app/theme/timing.ts new file mode 100644 index 0000000..b8b7203 --- /dev/null +++ b/app/theme/timing.ts @@ -0,0 +1,6 @@ +export const timing = { + /** + * The duration (ms) for quick animations. + */ + quick: 300, +} diff --git a/app/theme/typography.ts b/app/theme/typography.ts new file mode 100644 index 0000000..5f7de85 --- /dev/null +++ b/app/theme/typography.ts @@ -0,0 +1,31 @@ +import { Platform } from "react-native" + +/** + * You can find a list of available fonts on both iOS and Android here: + * https://github.com/react-native-training/react-native-fonts + * + * If you're interested in adding a custom font to your project, + * check out the readme file in ./assets/fonts/ then come back here + * and enter your new font name. Remember the Android font name + * is probably different than iOS. + * More on that here: + * https://github.com/lendup/react-native-cross-platform-text + * + * The various styles of fonts are defined in the <Text /> component. + */ +export const typography = { + /** + * The primary font. Used in most places. + */ + primary: Platform.select({ ios: "Helvetica", android: "normal" }), + + /** + * An alternate font used for perhaps titles and stuff. + */ + secondary: Platform.select({ ios: "Arial", android: "sans-serif" }), + + /** + * Lets get fancy with a monospace font! + */ + code: Platform.select({ ios: "Courier", android: "monospace" }), +} diff --git a/app/utils/delay.ts b/app/utils/delay.ts new file mode 100644 index 0000000..6a2ef8d --- /dev/null +++ b/app/utils/delay.ts @@ -0,0 +1,6 @@ +/** + * A "modern" sleep statement. + * + * @param ms The number of milliseconds to wait. + */ +export const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)) diff --git a/app/utils/ignore-warnings.ts b/app/utils/ignore-warnings.ts new file mode 100644 index 0000000..18802fe --- /dev/null +++ b/app/utils/ignore-warnings.ts @@ -0,0 +1,10 @@ +/** + * Ignore some yellowbox warnings. Some of these are for deprecated functions + * that we haven't gotten around to replacing yet. + */ +import { LogBox } from "react-native" + +// prettier-ignore +LogBox.ignoreLogs([ + "Require cycle:", +]) diff --git a/app/utils/keychain.ts b/app/utils/keychain.ts new file mode 100644 index 0000000..34774bb --- /dev/null +++ b/app/utils/keychain.ts @@ -0,0 +1,63 @@ +import * as ReactNativeKeychain from "react-native-keychain" + +/** + * Saves some credentials securely. + * + * @param username The username + * @param password The password + * @param server The server these creds are for. + */ +export async function save(username: string, password: string, server?: string) { + if (server) { + await ReactNativeKeychain.setInternetCredentials(server, username, password) + return true + } else { + return ReactNativeKeychain.setGenericPassword(username, password) + } +} + +/** + * Loads credentials that were already saved. + * + * @param server The server that these creds are for + */ +export async function load(server?: string) { + if (server) { + const creds = await ReactNativeKeychain.getInternetCredentials(server) + return { + username: creds ? creds.username : null, + password: creds ? creds.password : null, + server, + } + } else { + const creds = await ReactNativeKeychain.getGenericPassword() + if (typeof creds === "object") { + return { + username: creds.username, + password: creds.password, + server: null, + } + } else { + return { + username: null, + password: null, + server: null, + } + } + } +} + +/** + * Resets any existing credentials for the given server. + * + * @param server The server which has these creds + */ +export async function reset(server?: string) { + if (server) { + await ReactNativeKeychain.resetInternetCredentials(server) + return true + } else { + const result = await ReactNativeKeychain.resetGenericPassword() + return result + } +} diff --git a/app/utils/storage/index.ts b/app/utils/storage/index.ts new file mode 100644 index 0000000..ff88148 --- /dev/null +++ b/app/utils/storage/index.ts @@ -0,0 +1 @@ +export * from "./storage" diff --git a/app/utils/storage/storage.test.ts b/app/utils/storage/storage.test.ts new file mode 100644 index 0000000..d5cd977 --- /dev/null +++ b/app/utils/storage/storage.test.ts @@ -0,0 +1,39 @@ +import AsyncStorage from "@react-native-async-storage/async-storage" +import { load, loadString, save, saveString, clear, remove } from "./storage" + +// fixtures +const VALUE_OBJECT = { x: 1 } +const VALUE_STRING = JSON.stringify(VALUE_OBJECT) + +beforeEach(() => (AsyncStorage.getItem as jest.Mock).mockReturnValue(Promise.resolve(VALUE_STRING))) +afterEach(() => jest.clearAllMocks()) + +test("load", async () => { + const value = await load("something") + expect(value).toEqual(JSON.parse(VALUE_STRING)) +}) + +test("loadString", async () => { + const value = await loadString("something") + expect(value).toEqual(VALUE_STRING) +}) + +test("save", async () => { + await save("something", VALUE_OBJECT) + expect(AsyncStorage.setItem).toHaveBeenCalledWith("something", VALUE_STRING) +}) + +test("saveString", async () => { + await saveString("something", VALUE_STRING) + expect(AsyncStorage.setItem).toHaveBeenCalledWith("something", VALUE_STRING) +}) + +test("remove", async () => { + await remove("something") + expect(AsyncStorage.removeItem).toHaveBeenCalledWith("something") +}) + +test("clear", async () => { + await clear() + expect(AsyncStorage.clear).toHaveBeenCalledWith() +}) diff --git a/app/utils/storage/storage.ts b/app/utils/storage/storage.ts new file mode 100644 index 0000000..05c098a --- /dev/null +++ b/app/utils/storage/storage.ts @@ -0,0 +1,79 @@ +import AsyncStorage from "@react-native-async-storage/async-storage" + +/** + * Loads a string from storage. + * + * @param key The key to fetch. + */ +export async function loadString(key: string): Promise<string | null> { + try { + return await AsyncStorage.getItem(key) + } catch { + // not sure why this would fail... even reading the RN docs I'm unclear + return null + } +} + +/** + * Saves a string to storage. + * + * @param key The key to fetch. + * @param value The value to store. + */ +export async function saveString(key: string, value: string): Promise<boolean> { + try { + await AsyncStorage.setItem(key, value) + return true + } catch { + return false + } +} + +/** + * Loads something from storage and runs it thru JSON.parse. + * + * @param key The key to fetch. + */ +export async function load(key: string): Promise<any | null> { + try { + const almostThere = await AsyncStorage.getItem(key) + return JSON.parse(almostThere) + } catch { + return null + } +} + +/** + * Saves an object to storage. + * + * @param key The key to fetch. + * @param value The value to store. + */ +export async function save(key: string, value: any): Promise<boolean> { + try { + await AsyncStorage.setItem(key, JSON.stringify(value)) + return true + } catch { + return false + } +} + +/** + * Removes something from storage. + * + * @param key The key to kill. + */ +export async function remove(key: string): Promise<void> { + try { + await AsyncStorage.removeItem(key) + } catch {} +} + +/** + * Burn it all to the ground. + */ +export async function clear(): Promise<void> { + try { + await AsyncStorage.clear() + } catch {} +} diff --git a/app/utils/validate.ts b/app/utils/validate.ts new file mode 100644 index 0000000..91db9b6 --- /dev/null +++ b/app/utils/validate.ts @@ -0,0 +1,77 @@ +const ValidateJS = require("validate.js") + +// HACK(steve): wierd typescript situation because of strange typings +const Validate: any = ValidateJS.default ? ValidateJS.default : ValidateJS + +/** + * Validates that 1 attribute doesn't appear in another's attributes content. + */ +Validate.validators.excludes = function custom(value, options, key, attributes) { + const list = attributes[options.attribute] || [] + if (value && list.includes(value)) { + return options.message || `${value} is in the list` + } +} + +/** + * Validates that another attribute isn't true. + */ +Validate.validators.tripped = function custom(value, options, key, attributes) { + if (value && attributes[options.attribute] === true) { + return options.message || `${options.attribute} is true` + } +} + +/** + * Defines the rules for validating. + * + * Example: + * ```ts + * const RULES = { + * favoriteBand: { + * inclusion: { ['Weezer', 'Other'], message: 'Pick wisely.' } + * }, + * name: { + * presence: { message: 'A developer has no name?' } + * } + * } + * validate(RULES, {}) + * ``` + * + * See https://validatejs.org/#validators for more examples. + * + */ +export interface ValidationRules { + [key: string]: Record<string, unknown> +} + +/** + * An object containing any errors found. + * + * Example: + * ```js + * { + * email: ['Invalid email address.'], + * password: [ + * 'Password must be 6 characters.', + * 'Password must have at least 1 digit.' + * ] + * } + * ``` + */ +export interface ValidationErrors { + [key: string]: string[] +} + +/** + * Runs the given rules against the data object. + * + * @param rules The rules to apply. + * @param data The object to validate. + */ +export function validate(rules: ValidationRules, data: Record<string, unknown>): ValidationErrors { + if (typeof data !== "object") { + return {} as ValidationErrors + } + return Validate(data, rules, { fullMessages: false }) || {} +} |