Make PhotoPanel bound

This commit is contained in:
John Lyon-Smith
2018-04-26 14:11:12 -07:00
parent 109e9f4d3d
commit 71cec6088a
10 changed files with 327 additions and 190 deletions

View File

@@ -137,7 +137,6 @@ class API extends EventEmitter {
this._apiURL = url this._apiURL = url
this._baseURL = parts[0] + "//" + parts[1] this._baseURL = parts[0] + "//" + parts[1]
this._secure = parts[0] === "https:"
if (parts.length === 3) { if (parts.length === 3) {
this._apiPath = "/" + parts[2] this._apiPath = "/" + parts[2]
@@ -155,8 +154,8 @@ class API extends EventEmitter {
return this._apiPath return this._apiPath
} }
get secure() { get apiURL() {
return this._secure return this._apiURL
} }
get backend() { get backend() {
@@ -178,21 +177,13 @@ class API extends EventEmitter {
} }
} }
makeImageUrl(id, size) { makeImageUrl(id) {
if (id) { return this._apiURL + "/assets/" + id + ".jpg?access_token=" + this.token
return this.apiPath + "/assets/" + id + "?access_token=" + this.token
} else if (size && size.width && size.height) {
return `${this.apiPath}/placeholders/${size.width}x${
size.height
}?access_token=${this.token}`
} else {
return null
}
} }
makeAssetUrl(id) { makeAssetUrl(id) {
return id return id
? this.apiPath + "/assets/" + id + "?access_token=" + this.token ? this._apiURL + "/assets/" + id + "?access_token=" + this.token
: null : null
} }
@@ -218,10 +209,17 @@ class API extends EventEmitter {
headers.set("Authorization", "Bearer " + this.token) headers.set("Authorization", "Bearer " + this.token)
} }
if (method === "POST" || method === "PUT") { if (method === "POST" || method === "PUT") {
if (requestOptions.binary) { if (requestOptions.raw) {
headers.set("Content-Type", "application/octet-stream") const isBase64 = requestOptions.raw.base64
headers.set("Content-Length", requestOptions.binary.length) headers.set(
headers.set("Range", "byte " + requestOptions.binary.offset) "Content-Type",
isBase64 ? "application/base64" : "application/octet-stream"
)
headers.set("Content-Length", requestOptions.raw.length)
headers.set(
"Content-Range",
(isBase64 ? "base64" : "byte") + " " + requestOptions.raw.offset
)
fetchOptions.body = requestBody fetchOptions.body = requestBody
} else { } else {
headers.set("Content-Type", "application/json") headers.set("Content-Type", "application/json")
@@ -238,7 +236,7 @@ class API extends EventEmitter {
.then((res) => { .then((res) => {
return Promise.all([ return Promise.all([
Promise.resolve(res), Promise.resolve(res),
requestOptions.binary && method === "GET" ? res.blob() : res.json(), requestOptions.raw && method === "GET" ? res.blob() : res.json(),
]) ])
}) })
.then((arr) => { .then((arr) => {
@@ -427,33 +425,29 @@ class API extends EventEmitter {
return promise return promise
} }
upload(file, progressCallback) { upload(data, progressCallback) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const chunkSize = 32 * 1024 const chunkSize = 32 * 1024
let reader = new FileReader() const uploadSize = data.length
const fileSize = file.size const numberOfChunks = Math.ceil(uploadSize / chunkSize)
const numberOfChunks = Math.ceil(fileSize / chunkSize)
let chunk = 0 let chunk = 0
let uploadId = null let uploadId = null
reader.onload = (e) => { const uploadNextChunk = () => {
const buffer = e.target.result const start = chunk * chunkSize
const bytesRead = buffer.byteLength const end = Math.min(uploadSize, start + chunkSize)
this.post("/assets/upload/" + uploadId, buffer, { this.post("/assets/upload/" + uploadId, data.slice(start, end), {
binary: { offset: chunk * chunkSize, length: bytesRead }, raw: { base64: true, length: chunkSize, offset: start },
}) })
.then((uploadData) => { .then((uploadData) => {
chunk++ chunk++
if (!progressCallback(uploadData)) { if (progressCallback && !progressCallback(uploadData)) {
return Promise.reject(new Error("Upload was canceled")) reject(new Error("Upload was canceled"))
} } else if (chunk >= numberOfChunks) {
if (chunk < numberOfChunks) {
let start = chunk * chunkSize
let end = Math.min(fileSize, start + chunkSize)
reader.readAsArrayBuffer(file.slice(start, end))
} else {
resolve(uploadData) resolve(uploadData)
} else {
uploadNextChunk()
} }
}) })
.catch((err) => { .catch((err) => {
@@ -462,14 +456,14 @@ class API extends EventEmitter {
} }
this.post("/assets/upload", { this.post("/assets/upload", {
fileName: file.name, uploadSize,
fileSize, contentType: "image/jpeg",
contentType: file.type, chunkContentType: "application/base64",
numberOfChunks, numberOfChunks,
}) })
.then((uploadData) => { .then((uploadData) => {
uploadId = uploadData.uploadId uploadId = uploadData.uploadId
reader.readAsArrayBuffer(file.slice(0, chunkSize)) uploadNextChunk()
}) })
.catch((err) => { .catch((err) => {
reject(err) reject(err)

View File

@@ -19,9 +19,9 @@ import {
BoundButton, BoundButton,
BoundOptionStrip, BoundOptionStrip,
BoundHeader, BoundHeader,
PhotoPanel, BoundPhotoPanel,
} from "../ui" } from "../ui"
import { MessageModal } from "../Modal" import { MessageModal, WaitModal } from "../Modal"
import autobind from "autobind-decorator" import autobind from "autobind-decorator"
import KeyboardSpacer from "react-native-keyboard-spacer" import KeyboardSpacer from "react-native-keyboard-spacer"
import { isIphoneX } from "react-native-iphone-x-helper" import { isIphoneX } from "react-native-iphone-x-helper"
@@ -70,6 +70,9 @@ export class Activity extends React.Component {
notes: { notes: {
isValid: (r, v) => v !== "", isValid: (r, v) => v !== "",
}, },
photos: {
isValid: (r, v) => v && v.length > 0,
},
status: { status: {
isValid: (r, v) => v !== "", isValid: (r, v) => v !== "",
alwaysGet: true, alwaysGet: true,
@@ -84,6 +87,7 @@ export class Activity extends React.Component {
super(props) super(props)
this.state = { this.state = {
binder: new FormBinder({}, Activity.bindings), binder: new FormBinder({}, Activity.bindings),
waitModal: null,
messageModal: null, messageModal: null,
} }
@@ -180,8 +184,18 @@ export class Activity extends React.Component {
} }
} }
@autobind
handleUploadStarted() {
this.setState({ waitModal: { message: "Uploading Photo..." } })
}
@autobind
handleUploadEnded() {
this.setState({ waitModal: null })
}
render() { render() {
const { binder, messageModal, region } = this.state const { binder, messageModal, waitModal, region } = this.state
return ( return (
<View style={{ width: "100%", height: "100%" }}> <View style={{ width: "100%", height: "100%" }}>
@@ -258,10 +272,19 @@ export class Activity extends React.Component {
</View> </View>
</View> </View>
<View style={styles.panel}> <View style={styles.panel}>
<PhotoPanel /> <BoundPhotoPanel
name="photos"
binder={binder}
onUploadStarted={this.handleUploadStarted}
onUploadEnded={this.handleUploadEnded}
/>
</View> </View>
{isIphoneX ? <View style={{ height: 30, width: "100%" }} /> : null} {isIphoneX ? <View style={{ height: 30, width: "100%" }} /> : null}
</ScrollView> </ScrollView>
<WaitModal
open={!!waitModal}
message={waitModal ? waitModal.message : ""}
/>
<MessageModal <MessageModal
open={!!messageModal} open={!!messageModal}
icon={messageModal ? messageModal.icon : ""} icon={messageModal ? messageModal.icon : ""}

View File

@@ -3,5 +3,5 @@ import { Route, Redirect } from "react-router-native"
export const DefaultRoute = () => { export const DefaultRoute = () => {
// NOTE: When working on the app, change this to the page you are working on // NOTE: When working on the app, change this to the page you are working on
return <Route render={() => <Redirect to={"/workItem"} />} /> return <Route render={() => <Redirect to={"/home"} />} />
} }

View File

@@ -52,6 +52,8 @@ export class Home extends React.Component {
workItemDistance: -1, workItemDistance: -1,
} }
this.watchId = null
ensurePermissions( ensurePermissions(
[ [
PermissionsAndroid.PERMISSIONS.ACCESS_FINE_LOCATION, PermissionsAndroid.PERMISSIONS.ACCESS_FINE_LOCATION,
@@ -173,8 +175,20 @@ export class Home extends React.Component {
} }
@autobind @autobind
handleItemSelect(item) { handleItemSelect(activity) {
this.props.history.push(`/activity?id=${item._id}`) this.props.history.push(`/activity?id=${activity._id}`)
}
@autobind
handleSectionSelect(workItem) {
const { latitude, longitude } = workItem.coordinate
const region = {
latitude,
longitude,
latitudeDelta: 0.01,
longitudeDelta: 0.01,
}
this.setState({ region })
} }
@autobind @autobind
@@ -276,6 +290,7 @@ export class Home extends React.Component {
showsTraffic={false} showsTraffic={false}
showsIndoors={false} showsIndoors={false}
zoomControlEnabled={false} zoomControlEnabled={false}
showsMyLocationButton={false}
region={region}> region={region}>
{sections.map((workItem, index) => ( {sections.map((workItem, index) => (
<Marker <Marker
@@ -348,32 +363,40 @@ export class Home extends React.Component {
sections={sections} sections={sections}
stickySectionHeadersEnabled={true} stickySectionHeadersEnabled={true}
renderSectionHeader={({ section: workItem }) => ( renderSectionHeader={({ section: workItem }) => (
<View <TouchableHighlight
key={workItem._id}
style={{ style={{
flexDirection: "row",
justifyContent: "flex-start",
alignItems: "center",
backgroundColor: "#F4F4F4",
paddingLeft: 8, paddingLeft: 8,
height: 45, height: 45,
}}> backgroundColor: "#F4F4F4",
<Icon }}
name={ underlayColor="#EEEEEE"
workItem.workItemType === "order" onPress={() => this.handleSectionSelect(workItem)}>
? "hardhat" <View
: workItem.workItemType === "complaint" key={workItem._id}
? "question" style={{
: "clipboard" height: "100%",
} width: "100%",
size={16} flexDirection: "row",
style={{ marginRight: 10 }} justifyContent: "flex-start",
/> alignItems: "center",
<Text style={{ fontSize: 16 }}> }}>
{workItemTypeText[workItem.workItemType].toUpperCase()}{" "} <Icon
{pad(workItem.ticketNumber, 4)} name={
</Text> workItem.workItemType === "order"
</View> ? "hardhat"
: workItem.workItemType === "complaint"
? "question"
: "clipboard"
}
size={16}
style={{ marginRight: 10 }}
/>
<Text style={{ fontSize: 16 }}>
{workItemTypeText[workItem.workItemType].toUpperCase()}{" "}
{pad(workItem.ticketNumber, 4)}
</Text>
</View>
</TouchableHighlight>
)} )}
keyExtractor={(item) => item._id} keyExtractor={(item) => item._id}
renderItem={({ item: activity, section }) => { renderItem={({ item: activity, section }) => {

View File

@@ -18,11 +18,10 @@ import {
BoundHeader, BoundHeader,
Icon, Icon,
Header, Header,
PhotoButton,
BoundOptionStrip, BoundOptionStrip,
PhotoPanel, BoundPhotoPanel,
} from "../ui" } from "../ui"
import { MessageModal } from "../Modal" import { MessageModal, WaitModal } from "../Modal"
import autobind from "autobind-decorator" import autobind from "autobind-decorator"
import { ifIphoneX, isIphoneX } from "react-native-iphone-x-helper" import { ifIphoneX, isIphoneX } from "react-native-iphone-x-helper"
import KeyboardSpacer from "react-native-keyboard-spacer" import KeyboardSpacer from "react-native-keyboard-spacer"
@@ -65,6 +64,9 @@ export class WorkItem extends React.Component {
isValid: true, isValid: true,
isReadOnly: true, isReadOnly: true,
}, },
photos: {
isValid: (r, v) => v && v.length > 0,
},
details: { details: {
isValid: (r, v) => v !== "", isValid: (r, v) => v !== "",
}, },
@@ -87,6 +89,7 @@ export class WorkItem extends React.Component {
this.state = { this.state = {
binder: new FormBinder({}, WorkItem.bindings), binder: new FormBinder({}, WorkItem.bindings),
messageModal: null, messageModal: null,
waitModal: null,
region, region,
} }
@@ -230,8 +233,18 @@ export class WorkItem extends React.Component {
}) })
} }
@autobind
handleUploadStarted() {
this.setState({ waitModal: { message: "Uploading Photo..." } })
}
@autobind
handleUploadEnded() {
this.setState({ waitModal: null })
}
render() { render() {
const { binder, messageModal, region } = this.state const { binder, messageModal, waitModal, region } = this.state
return ( return (
<View style={{ flex: 1 }}> <View style={{ flex: 1 }}>
@@ -304,10 +317,19 @@ export class WorkItem extends React.Component {
/> />
</View> </View>
<View style={styles.panel}> <View style={styles.panel}>
<PhotoPanel /> <BoundPhotoPanel
name="photos"
binder={binder}
onUploadStarted={this.handleUploadStarted}
onUploadEnded={this.handleUploadEnded}
/>
</View> </View>
{isIphoneX ? <View style={{ height: 30, width: "100%" }} /> : null} {isIphoneX ? <View style={{ height: 30, width: "100%" }} /> : null}
</ScrollView> </ScrollView>
<WaitModal
open={!!waitModal}
message={waitModal ? waitModal.message : ""}
/>
<MessageModal <MessageModal
open={!!messageModal} open={!!messageModal}
icon={messageModal ? messageModal.icon : ""} icon={messageModal ? messageModal.icon : ""}

View File

@@ -36,10 +36,7 @@ export class BoundInput extends React.Component {
handleChangeText(newText) { handleChangeText(newText) {
const { binder, name } = this.props const { binder, name } = this.props
// TODO: Sometimes this is undefined and causes a crash?!
if (binder) { if (binder) {
const state = binder.getFieldState(name)
this.setState(binder.updateFieldValue(name, newText)) this.setState(binder.updateFieldValue(name, newText))
} }
} }

View File

@@ -0,0 +1,169 @@
import React, { Component } from "react"
import PropTypes from "prop-types"
import {
StyleSheet,
Text,
View,
Image,
TouchableOpacity,
Dimensions,
ActivityIndicator,
} from "react-native"
import { Icon } from "."
import ImagePicker from "react-native-image-picker"
import autobind from "autobind-decorator"
import { api } from "../API"
const getScreenPortraitDimensions = () => {
const { width, height } = Dimensions.get("window")
return {
screenWidth: Math.min(width, height),
screenHeight: Math.max(width, height),
}
}
export class BoundPhotoPanel extends Component {
static propTypes = {
onUploadStarted: PropTypes.func,
onUploadEnded: PropTypes.func,
}
constructor(props) {
super(props)
const { name, binder } = this.props
this.state = binder.getFieldState(name)
}
componentWillReceiveProps(nextProps) {
if (nextProps.binder !== this.props.binder) {
this.setState(nextProps.binder.getFieldState(nextProps.name))
}
}
@autobind
handlePhotoPress(index) {
const { onUploadStarted, onUploadEnded } = this.props
ImagePicker.showImagePicker(
{
title: "Select Photo",
storageOptions: {
skipBackup: true,
path: "photos",
},
},
(response) => {
if (!response.didCancel && !response.error) {
if (onUploadStarted) {
onUploadStarted()
}
api
.upload(response.data)
.then((uploadData) => {
if (onUploadEnded) {
onUploadEnded(true, uploadData)
}
const { binder, name } = this.props
if (binder) {
const value = binder.getFieldValue(name)
let newValue = value.slice(0)
newValue[index] = uploadData.assetId
this.setState(binder.updateFieldValue(name, newValue))
}
})
.catch((err) => {
if (onUploadEnded) {
onUploadEnded(false)
}
})
}
}
)
}
render() {
const { screenWidth, screenHeight } = getScreenPortraitDimensions()
const photoWidth = screenHeight / 4
const photoHeight = screenWidth / 4
const rowPadding = 10
const { value: assetIds } = this.state
const renderPhoto = (index) => {
const assetId = assetIds[index]
if (assetId) {
console.log(api.makeImageUrl(assetId))
}
return (
<TouchableOpacity
key={assetId || "blank" + index.toString()}
style={{
width: photoWidth,
height: photoHeight,
borderWidth: 2,
borderColor: "gray",
borderRadius: 4,
justifyContent: "center",
}}
onPress={() => this.handlePhotoPress(index)}>
{!assetId && (
<Icon name="add" size={24} style={{ alignSelf: "center" }} />
)}
{assetId && (
<Image
source={{ uri: api.makeImageUrl(assetId) }}
style={{ width: "100%", height: "100%" }}
resizeMode="contain"
/>
)}
</TouchableOpacity>
)
}
const extraRowStyle = {
height: photoHeight + rowPadding,
paddingTop: rowPadding / 2,
paddingBottom: rowPadding / 2,
}
return (
<View
style={{
flexDirection: "column",
justifyContent: "space-between",
}}>
<Text
style={{
fontSize: 14,
marginBottom: 4,
}}>
Pictures:
</Text>
<View style={[styles.photoRow, extraRowStyle]}>
{renderPhoto(0)}
{renderPhoto(1)}
</View>
<View style={[styles.photoRow, extraRowStyle]}>
{renderPhoto(2)}
{renderPhoto(3)}
</View>
</View>
)
}
}
const styles = StyleSheet.create({
photoRow: {
flexDirection: "row",
justifyContent: "space-between",
width: "100%",
},
})

View File

@@ -1,104 +0,0 @@
import React, { Component } from "react"
import {
StyleSheet,
Text,
View,
Image,
TouchableOpacity,
Dimensions,
} from "react-native"
import { Icon } from "."
import ImagePicker from "react-native-image-picker"
import autobind from "autobind-decorator"
const getScreenPortraitDimensions = () => {
const { width, height } = Dimensions.get("window")
return {
screenWidth: Math.min(width, height),
screenHeight: Math.max(width, height),
}
}
export class PhotoPanel extends Component {
@autobind
handlePhotoPress() {
ImagePicker.showImagePicker(
{
title: "Select Photo",
storageOptions: {
skipBackup: true,
path: "photos",
},
},
(response) => {
console.log("Response = ", response)
if (response.didCancel) {
console.log("User cancelled image picker")
} else if (response.error) {
console.log("ImagePicker Error: ", response.error)
} else if (response.customButton) {
console.log("User tapped custom button: ", response.customButton)
} else {
// You can also display the image using data:
// let source = { uri: 'data:image/jpeg;base64,' + response.data };
console.log(response)
}
}
)
}
render() {
const { screenWidth, screenHeight } = getScreenPortraitDimensions()
const photoWidth = screenHeight / 4
const photoHeight = screenWidth / 4
const numRows = 2
const numCols = 2
const rowPadding = 10
return (
<View
style={{
flexDirection: "column",
justifyContent: "space-between",
}}>
<Text
style={{
fontSize: 14,
marginBottom: 4,
}}>
Pictures:
</Text>
{Array.from(new Array(numRows), (x, i) => (
<View
key={"r" + i.toString()}
style={{
flexDirection: "row",
justifyContent: "space-between",
width: "100%",
height: photoHeight + rowPadding,
paddingTop: rowPadding / 2,
paddingBottom: rowPadding / 2,
}}>
{Array.from(new Array(numCols), (y, j) => (
<TouchableOpacity
key={"r" + i.toString() + "c" + j.toString()}
style={{
width: photoWidth,
height: photoHeight,
borderWidth: 2,
borderColor: "gray",
borderRadius: 4,
justifyContent: "center",
}}
onPress={this.handlePhotoPress}>
<Icon name="add" size={24} style={{ alignSelf: "center" }} />
</TouchableOpacity>
))}
</View>
))}
</View>
)
}
}

View File

@@ -1,9 +1,9 @@
export { Icon } from "./Icon" export { Icon } from "./Icon"
export { Header } from "./Header" export { Header } from "./Header"
export { PhotoPanel } from "./PhotoPanel"
export { OptionStrip } from "./OptionStrip" export { OptionStrip } from "./OptionStrip"
export { BoundSwitch } from "./BoundSwitch" export { BoundSwitch } from "./BoundSwitch"
export { BoundInput } from "./BoundInput" export { BoundInput } from "./BoundInput"
export { BoundButton } from "./BoundButton" export { BoundButton } from "./BoundButton"
export { BoundOptionStrip } from "./BoundOptionStrip" export { BoundOptionStrip } from "./BoundOptionStrip"
export { BoundHeader } from "./BoundHeader" export { BoundHeader } from "./BoundHeader"
export { BoundPhotoPanel } from "./BoundPhotoPanel"

View File

@@ -65,7 +65,14 @@ export class AssetRoutes {
} }
async getAsset(req, res, next) { async getAsset(req, res, next) {
const assetId = req.params._id let assetId = req.params._id
const extIndex = assetId.indexOf(".")
if (extIndex !== -1) {
// TODO: Should really check the index against the requested extension...
assetId = assetId.slice(0, extIndex)
}
const file = await this.db.gridfs.findOneAsync({ _id: assetId }) const file = await this.db.gridfs.findOneAsync({ _id: assetId })
if (!file) { if (!file) {
@@ -112,13 +119,17 @@ export class AssetRoutes {
chunkContentType, chunkContentType,
} = req.body } = req.body
if (!fileName || !uploadSize || !numberOfChunks || !contentType) { if (!uploadSize || !numberOfChunks || !contentType) {
throw createError.BadRequest( throw createError.BadRequest(
"Must specify fileName, uploadSize, numberOfChunks, contentType" "Must specify uploadSize, numberOfChunks, contentType"
) )
} }
fileName = uploadId + "-" + path.basename(fileName) if (fileName) {
fileName = uploadId + "-" + path.basename(fileName)
} else {
fileName = uploadId
}
if (chunkContentType) { if (chunkContentType) {
if ( if (
@@ -159,9 +170,11 @@ export class AssetRoutes {
const contentRange = req.get("Content-Range") const contentRange = req.get("Content-Range")
const contentLength = req.get("Content-Length") const contentLength = req.get("Content-Length")
console.log(uploadData) if (!uploadData) {
throw createError.BadRequest(`Bad upload id ${uploadId}`)
}
if (contentType !== uploadData.chunkContentType) { if (!contentType.startsWith(uploadData.chunkContentType)) {
throw createError.BadRequest( throw createError.BadRequest(
`Content-Type ${contentType} does not match chunk type ${ `Content-Type ${contentType} does not match chunk type ${
uploadData.chunkContentType uploadData.chunkContentType
@@ -211,7 +224,7 @@ export class AssetRoutes {
} }
try { try {
const [uploadedChunks] = await Promise.all([ const [, uploadedChunks] = await Promise.all([
this.rs.setrangeAsync(uploadDataId, offset, req.body), this.rs.setrangeAsync(uploadDataId, offset, req.body),
this.rs.incrAsync(uploadCountId), this.rs.incrAsync(uploadCountId),
]) ])