summaryrefslogtreecommitdiff
path: root/app
diff options
context:
space:
mode:
authorThomas F. K. Jorna <[email protected]>2021-07-14 15:10:31 +0200
committerThomas F. K. Jorna <[email protected]>2021-07-14 15:10:31 +0200
commite5021187e96b78b53203bd95d08d6818aea47d17 (patch)
tree37ec45d00eb963db53cd4bb4f04a770414b351cc /app
New Ignite 7.0.6 app
Diffstat (limited to 'app')
-rw-r--r--app/app.tsx81
-rw-r--r--app/components/auto-image/auto-image.story.tsx31
-rw-r--r--app/components/auto-image/auto-image.tsx46
-rw-r--r--app/components/bullet-item/bullet-item.tsx41
-rw-r--r--app/components/button/button.presets.ts54
-rw-r--r--app/components/button/button.props.ts35
-rw-r--r--app/components/button/button.story.tsx33
-rw-r--r--app/components/button/button.tsx36
-rw-r--r--app/components/checkbox/checkbox.props.ts44
-rw-r--r--app/components/checkbox/checkbox.story.tsx121
-rw-r--r--app/components/checkbox/checkbox.tsx53
-rw-r--r--app/components/form-row/form-row.presets.ts71
-rw-r--r--app/components/form-row/form-row.props.tsx23
-rw-r--r--app/components/form-row/form-row.story.tsx107
-rw-r--r--app/components/form-row/form-row.tsx13
-rw-r--r--app/components/header/header.props.ts45
-rw-r--r--app/components/header/header.story.tsx43
-rw-r--r--app/components/header/header.tsx61
-rw-r--r--app/components/icon/icon.props.ts21
-rw-r--r--app/components/icon/icon.story.tsx19
-rw-r--r--app/components/icon/icon.tsx19
-rw-r--r--app/components/icon/icons/arrow-left.pngbin0 -> 329 bytes
-rw-r--r--app/components/icon/icons/[email protected]bin0 -> 329 bytes
-rw-r--r--app/components/icon/icons/bullet.pngbin0 -> 204 bytes
-rw-r--r--app/components/icon/icons/[email protected]bin0 -> 204 bytes
-rw-r--r--app/components/icon/icons/index.ts6
-rw-r--r--app/components/index.ts12
-rw-r--r--app/components/screen/screen.presets.ts66
-rw-r--r--app/components/screen/screen.props.ts46
-rw-r--r--app/components/screen/screen.tsx66
-rw-r--r--app/components/switch/switch.props.ts39
-rw-r--r--app/components/switch/switch.story.tsx110
-rw-r--r--app/components/switch/switch.tsx114
-rw-r--r--app/components/text-field/text-field.story.tsx159
-rw-r--r--app/components/text-field/text-field.tsx98
-rw-r--r--app/components/text/text.presets.ts48
-rw-r--r--app/components/text/text.props.ts37
-rw-r--r--app/components/text/text.story.tsx92
-rw-r--r--app/components/text/text.tsx28
-rw-r--r--app/components/wallpaper/bg.pngbin0 -> 56176 bytes
-rw-r--r--app/components/wallpaper/[email protected]bin0 -> 203224 bytes
-rw-r--r--app/components/wallpaper/wallpaper.presets.ts34
-rw-r--r--app/components/wallpaper/wallpaper.props.ts19
-rw-r--r--app/components/wallpaper/wallpaper.story.tsx16
-rw-r--r--app/components/wallpaper/wallpaper.tsx25
-rw-r--r--app/config/env.js1
-rw-r--r--app/i18n/en.json34
-rw-r--r--app/i18n/i18n.ts22
-rw-r--r--app/i18n/index.ts3
-rw-r--r--app/i18n/ja.json7
-rw-r--r--app/i18n/translate.ts11
-rw-r--r--app/models/character-store/character-store.test.ts7
-rw-r--r--app/models/character-store/character-store.ts37
-rw-r--r--app/models/character/character.test.ts10
-rw-r--r--app/models/character/character.ts17
-rw-r--r--app/models/environment.ts40
-rw-r--r--app/models/extensions/with-environment.ts17
-rw-r--r--app/models/extensions/with-root-store.ts17
-rw-r--r--app/models/index.ts5
-rw-r--r--app/models/root-store/root-store-context.ts22
-rw-r--r--app/models/root-store/root-store.ts20
-rw-r--r--app/models/root-store/setup-root-store.ts55
-rw-r--r--app/navigators/index.ts4
-rw-r--r--app/navigators/main-navigator.tsx57
-rw-r--r--app/navigators/navigation-utilities.tsx127
-rw-r--r--app/navigators/root-navigator.tsx59
-rw-r--r--app/screens/demo/demo-list-screen.tsx86
-rw-r--r--app/screens/demo/demo-screen.tsx181
-rw-r--r--app/screens/demo/heart.pngbin0 -> 204 bytes
-rw-r--r--app/screens/demo/[email protected]bin0 -> 377 bytes
-rw-r--r--app/screens/demo/logo-ignite.pngbin0 -> 9427 bytes
-rw-r--r--app/screens/demo/[email protected]bin0 -> 20283 bytes
-rw-r--r--app/screens/index.ts4
-rw-r--r--app/screens/welcome/bowser.pngbin0 -> 33502 bytes
-rw-r--r--app/screens/welcome/[email protected]bin0 -> 79259 bytes
-rw-r--r--app/screens/welcome/welcome-screen.tsx118
-rw-r--r--app/services/api/api-config.ts27
-rw-r--r--app/services/api/api-problem.test.ts72
-rw-r--r--app/services/api/api-problem.ts74
-rw-r--r--app/services/api/api.ts102
-rw-r--r--app/services/api/api.types.ts13
-rw-r--r--app/services/api/character-api.ts37
-rw-r--r--app/services/api/index.ts2
-rw-r--r--app/services/reactotron/index.ts1
-rw-r--r--app/services/reactotron/reactotron-config.ts30
-rw-r--r--app/services/reactotron/reactotron.ts181
-rw-r--r--app/services/reactotron/tron.ts2
-rw-r--r--app/services/reactotron/tron.web.ts2
-rw-r--r--app/theme/color.ts64
-rw-r--r--app/theme/fonts/index.ts12
-rw-r--r--app/theme/index.ts4
-rw-r--r--app/theme/palette.ts11
-rw-r--r--app/theme/spacing.ts41
-rw-r--r--app/theme/timing.ts6
-rw-r--r--app/theme/typography.ts31
-rw-r--r--app/utils/delay.ts6
-rw-r--r--app/utils/ignore-warnings.ts10
-rw-r--r--app/utils/keychain.ts63
-rw-r--r--app/utils/storage/index.ts1
-rw-r--r--app/utils/storage/storage.test.ts39
-rw-r--r--app/utils/storage/storage.ts79
-rw-r--r--app/utils/validate.ts77
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
new file mode 100644
index 0000000..9d607d7
--- /dev/null
+++ b/app/components/icon/icons/arrow-left.png
Binary files differ
diff --git a/app/components/icon/icons/[email protected] b/app/components/icon/icons/[email protected]
new file mode 100644
index 0000000..9d607d7
--- /dev/null
+++ b/app/components/icon/icons/[email protected]
Binary files differ
diff --git a/app/components/icon/icons/bullet.png b/app/components/icon/icons/bullet.png
new file mode 100644
index 0000000..8fc256f
--- /dev/null
+++ b/app/components/icon/icons/bullet.png
Binary files differ
diff --git a/app/components/icon/icons/[email protected] b/app/components/icon/icons/[email protected]
new file mode 100644
index 0000000..8fc256f
--- /dev/null
+++ b/app/components/icon/icons/[email protected]
Binary files differ
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
new file mode 100644
index 0000000..641838e
--- /dev/null
+++ b/app/components/wallpaper/bg.png
Binary files differ
diff --git a/app/components/wallpaper/[email protected] b/app/components/wallpaper/[email protected]
new file mode 100644
index 0000000..3ae8396
--- /dev/null
+++ b/app/components/wallpaper/[email protected]
Binary files differ
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
new file mode 100644
index 0000000..9890998
--- /dev/null
+++ b/app/screens/demo/heart.png
Binary files differ
diff --git a/app/screens/demo/[email protected] b/app/screens/demo/[email protected]
new file mode 100644
index 0000000..e8b70d9
--- /dev/null
+++ b/app/screens/demo/[email protected]
Binary files differ
diff --git a/app/screens/demo/logo-ignite.png b/app/screens/demo/logo-ignite.png
new file mode 100644
index 0000000..36af16d
--- /dev/null
+++ b/app/screens/demo/logo-ignite.png
Binary files differ
diff --git a/app/screens/demo/[email protected] b/app/screens/demo/[email protected]
new file mode 100644
index 0000000..fdbfad8
--- /dev/null
+++ b/app/screens/demo/[email protected]
Binary files differ
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
new file mode 100644
index 0000000..2b0bdbc
--- /dev/null
+++ b/app/screens/welcome/bowser.png
Binary files differ
diff --git a/app/screens/welcome/[email protected] b/app/screens/welcome/[email protected]
new file mode 100644
index 0000000..85cc693
--- /dev/null
+++ b/app/screens/welcome/[email protected]
Binary files differ
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 }) || {}
+}