From 71cec6088a8c7045688d0581b7b5dea17cdcd8f0 Mon Sep 17 00:00:00 2001 From: John Lyon-Smith Date: Thu, 26 Apr 2018 14:11:12 -0700 Subject: [PATCH] Make PhotoPanel bound --- mobile/src/API.js | 74 ++++++------ mobile/src/Activity/Activity.js | 31 ++++- mobile/src/Auth/DefaultRoute.js | 2 +- mobile/src/Home/Home.js | 73 ++++++++---- mobile/src/WorkItem/WorkItem.js | 32 ++++- mobile/src/ui/BoundInput.js | 3 - mobile/src/ui/BoundPhotoPanel.js | 169 +++++++++++++++++++++++++++ mobile/src/ui/PhotoPanel.js | 104 ----------------- mobile/src/ui/index.js | 2 +- server/src/api/routes/AssetRoutes.js | 27 +++-- 10 files changed, 327 insertions(+), 190 deletions(-) create mode 100644 mobile/src/ui/BoundPhotoPanel.js delete mode 100644 mobile/src/ui/PhotoPanel.js diff --git a/mobile/src/API.js b/mobile/src/API.js index 555f108..a3b06f0 100644 --- a/mobile/src/API.js +++ b/mobile/src/API.js @@ -137,7 +137,6 @@ class API extends EventEmitter { this._apiURL = url this._baseURL = parts[0] + "//" + parts[1] - this._secure = parts[0] === "https:" if (parts.length === 3) { this._apiPath = "/" + parts[2] @@ -155,8 +154,8 @@ class API extends EventEmitter { return this._apiPath } - get secure() { - return this._secure + get apiURL() { + return this._apiURL } get backend() { @@ -178,21 +177,13 @@ class API extends EventEmitter { } } - makeImageUrl(id, size) { - if (id) { - 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 - } + makeImageUrl(id) { + return this._apiURL + "/assets/" + id + ".jpg?access_token=" + this.token } makeAssetUrl(id) { return id - ? this.apiPath + "/assets/" + id + "?access_token=" + this.token + ? this._apiURL + "/assets/" + id + "?access_token=" + this.token : null } @@ -218,10 +209,17 @@ class API extends EventEmitter { headers.set("Authorization", "Bearer " + this.token) } if (method === "POST" || method === "PUT") { - if (requestOptions.binary) { - headers.set("Content-Type", "application/octet-stream") - headers.set("Content-Length", requestOptions.binary.length) - headers.set("Range", "byte " + requestOptions.binary.offset) + if (requestOptions.raw) { + const isBase64 = requestOptions.raw.base64 + headers.set( + "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 } else { headers.set("Content-Type", "application/json") @@ -238,7 +236,7 @@ class API extends EventEmitter { .then((res) => { return Promise.all([ Promise.resolve(res), - requestOptions.binary && method === "GET" ? res.blob() : res.json(), + requestOptions.raw && method === "GET" ? res.blob() : res.json(), ]) }) .then((arr) => { @@ -427,33 +425,29 @@ class API extends EventEmitter { return promise } - upload(file, progressCallback) { + upload(data, progressCallback) { return new Promise((resolve, reject) => { const chunkSize = 32 * 1024 - let reader = new FileReader() - const fileSize = file.size - const numberOfChunks = Math.ceil(fileSize / chunkSize) + const uploadSize = data.length + const numberOfChunks = Math.ceil(uploadSize / chunkSize) let chunk = 0 let uploadId = null - reader.onload = (e) => { - const buffer = e.target.result - const bytesRead = buffer.byteLength + const uploadNextChunk = () => { + const start = chunk * chunkSize + const end = Math.min(uploadSize, start + chunkSize) - this.post("/assets/upload/" + uploadId, buffer, { - binary: { offset: chunk * chunkSize, length: bytesRead }, + this.post("/assets/upload/" + uploadId, data.slice(start, end), { + raw: { base64: true, length: chunkSize, offset: start }, }) .then((uploadData) => { chunk++ - if (!progressCallback(uploadData)) { - return Promise.reject(new Error("Upload was canceled")) - } - if (chunk < numberOfChunks) { - let start = chunk * chunkSize - let end = Math.min(fileSize, start + chunkSize) - reader.readAsArrayBuffer(file.slice(start, end)) - } else { + if (progressCallback && !progressCallback(uploadData)) { + reject(new Error("Upload was canceled")) + } else if (chunk >= numberOfChunks) { resolve(uploadData) + } else { + uploadNextChunk() } }) .catch((err) => { @@ -462,14 +456,14 @@ class API extends EventEmitter { } this.post("/assets/upload", { - fileName: file.name, - fileSize, - contentType: file.type, + uploadSize, + contentType: "image/jpeg", + chunkContentType: "application/base64", numberOfChunks, }) .then((uploadData) => { uploadId = uploadData.uploadId - reader.readAsArrayBuffer(file.slice(0, chunkSize)) + uploadNextChunk() }) .catch((err) => { reject(err) diff --git a/mobile/src/Activity/Activity.js b/mobile/src/Activity/Activity.js index 7f209a5..ef27acd 100644 --- a/mobile/src/Activity/Activity.js +++ b/mobile/src/Activity/Activity.js @@ -19,9 +19,9 @@ import { BoundButton, BoundOptionStrip, BoundHeader, - PhotoPanel, + BoundPhotoPanel, } from "../ui" -import { MessageModal } from "../Modal" +import { MessageModal, WaitModal } from "../Modal" import autobind from "autobind-decorator" import KeyboardSpacer from "react-native-keyboard-spacer" import { isIphoneX } from "react-native-iphone-x-helper" @@ -70,6 +70,9 @@ export class Activity extends React.Component { notes: { isValid: (r, v) => v !== "", }, + photos: { + isValid: (r, v) => v && v.length > 0, + }, status: { isValid: (r, v) => v !== "", alwaysGet: true, @@ -84,6 +87,7 @@ export class Activity extends React.Component { super(props) this.state = { binder: new FormBinder({}, Activity.bindings), + waitModal: 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() { - const { binder, messageModal, region } = this.state + const { binder, messageModal, waitModal, region } = this.state return ( @@ -258,10 +272,19 @@ export class Activity extends React.Component { - + {isIphoneX ? : null} + { // NOTE: When working on the app, change this to the page you are working on - return } /> + return } /> } diff --git a/mobile/src/Home/Home.js b/mobile/src/Home/Home.js index ba1bd74..53f03c5 100644 --- a/mobile/src/Home/Home.js +++ b/mobile/src/Home/Home.js @@ -52,6 +52,8 @@ export class Home extends React.Component { workItemDistance: -1, } + this.watchId = null + ensurePermissions( [ PermissionsAndroid.PERMISSIONS.ACCESS_FINE_LOCATION, @@ -173,8 +175,20 @@ export class Home extends React.Component { } @autobind - handleItemSelect(item) { - this.props.history.push(`/activity?id=${item._id}`) + handleItemSelect(activity) { + 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 @@ -276,6 +290,7 @@ export class Home extends React.Component { showsTraffic={false} showsIndoors={false} zoomControlEnabled={false} + showsMyLocationButton={false} region={region}> {sections.map((workItem, index) => ( ( - - - - {workItemTypeText[workItem.workItemType].toUpperCase()}{" "} - {pad(workItem.ticketNumber, 4)} - - + backgroundColor: "#F4F4F4", + }} + underlayColor="#EEEEEE" + onPress={() => this.handleSectionSelect(workItem)}> + + + + {workItemTypeText[workItem.workItemType].toUpperCase()}{" "} + {pad(workItem.ticketNumber, 4)} + + + )} keyExtractor={(item) => item._id} renderItem={({ item: activity, section }) => { diff --git a/mobile/src/WorkItem/WorkItem.js b/mobile/src/WorkItem/WorkItem.js index c84f497..3a8da55 100644 --- a/mobile/src/WorkItem/WorkItem.js +++ b/mobile/src/WorkItem/WorkItem.js @@ -18,11 +18,10 @@ import { BoundHeader, Icon, Header, - PhotoButton, BoundOptionStrip, - PhotoPanel, + BoundPhotoPanel, } from "../ui" -import { MessageModal } from "../Modal" +import { MessageModal, WaitModal } from "../Modal" import autobind from "autobind-decorator" import { ifIphoneX, isIphoneX } from "react-native-iphone-x-helper" import KeyboardSpacer from "react-native-keyboard-spacer" @@ -65,6 +64,9 @@ export class WorkItem extends React.Component { isValid: true, isReadOnly: true, }, + photos: { + isValid: (r, v) => v && v.length > 0, + }, details: { isValid: (r, v) => v !== "", }, @@ -87,6 +89,7 @@ export class WorkItem extends React.Component { this.state = { binder: new FormBinder({}, WorkItem.bindings), messageModal: null, + waitModal: null, 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() { - const { binder, messageModal, region } = this.state + const { binder, messageModal, waitModal, region } = this.state return ( @@ -304,10 +317,19 @@ export class WorkItem extends React.Component { /> - + {isIphoneX ? : null} + { + 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 ( + this.handlePhotoPress(index)}> + {!assetId && ( + + )} + {assetId && ( + + )} + + ) + } + + const extraRowStyle = { + height: photoHeight + rowPadding, + paddingTop: rowPadding / 2, + paddingBottom: rowPadding / 2, + } + + return ( + + + Pictures: + + + {renderPhoto(0)} + {renderPhoto(1)} + + + {renderPhoto(2)} + {renderPhoto(3)} + + + ) + } +} + +const styles = StyleSheet.create({ + photoRow: { + flexDirection: "row", + justifyContent: "space-between", + width: "100%", + }, +}) diff --git a/mobile/src/ui/PhotoPanel.js b/mobile/src/ui/PhotoPanel.js deleted file mode 100644 index 3b47cf1..0000000 --- a/mobile/src/ui/PhotoPanel.js +++ /dev/null @@ -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 ( - - - Pictures: - - {Array.from(new Array(numRows), (x, i) => ( - - {Array.from(new Array(numCols), (y, j) => ( - - - - ))} - - ))} - - ) - } -} diff --git a/mobile/src/ui/index.js b/mobile/src/ui/index.js index b2865c5..7a2c495 100644 --- a/mobile/src/ui/index.js +++ b/mobile/src/ui/index.js @@ -1,9 +1,9 @@ export { Icon } from "./Icon" export { Header } from "./Header" -export { PhotoPanel } from "./PhotoPanel" export { OptionStrip } from "./OptionStrip" export { BoundSwitch } from "./BoundSwitch" export { BoundInput } from "./BoundInput" export { BoundButton } from "./BoundButton" export { BoundOptionStrip } from "./BoundOptionStrip" export { BoundHeader } from "./BoundHeader" +export { BoundPhotoPanel } from "./BoundPhotoPanel" diff --git a/server/src/api/routes/AssetRoutes.js b/server/src/api/routes/AssetRoutes.js index 1d03183..1b30792 100644 --- a/server/src/api/routes/AssetRoutes.js +++ b/server/src/api/routes/AssetRoutes.js @@ -65,7 +65,14 @@ export class AssetRoutes { } 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 }) if (!file) { @@ -112,13 +119,17 @@ export class AssetRoutes { chunkContentType, } = req.body - if (!fileName || !uploadSize || !numberOfChunks || !contentType) { + if (!uploadSize || !numberOfChunks || !contentType) { 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 ( @@ -159,9 +170,11 @@ export class AssetRoutes { const contentRange = req.get("Content-Range") 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( `Content-Type ${contentType} does not match chunk type ${ uploadData.chunkContentType @@ -211,7 +224,7 @@ export class AssetRoutes { } try { - const [uploadedChunks] = await Promise.all([ + const [, uploadedChunks] = await Promise.all([ this.rs.setrangeAsync(uploadDataId, offset, req.body), this.rs.incrAsync(uploadCountId), ])