first working version 🎉
This commit is contained in:
parent
150aa44885
commit
484be226ca
36 changed files with 2369 additions and 4796 deletions
245
flask/backend.py
245
flask/backend.py
|
|
@ -1,11 +1,15 @@
|
||||||
from os import environ
|
from os import environ
|
||||||
|
from io import BytesIO
|
||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
from flask import Flask, request, abort
|
from flask import Flask, request, abort, make_response, send_file
|
||||||
|
from flask_cors import CORS
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from deta import Deta
|
from deta import Deta
|
||||||
|
import filetype
|
||||||
|
|
||||||
load_dotenv()
|
load_dotenv()
|
||||||
app = Flask(__name__)
|
app = Flask(__name__)
|
||||||
|
CORS(app)
|
||||||
deta = Deta()
|
deta = Deta()
|
||||||
|
|
||||||
### connect to dbs and drive
|
### connect to dbs and drive
|
||||||
|
|
@ -17,45 +21,50 @@ drive = deta.Drive('beerbuddy')
|
||||||
|
|
||||||
# fetch requested store from store database based on request arguments
|
# fetch requested store from store database based on request arguments
|
||||||
# arguments are assumed checked beforehand
|
# arguments are assumed checked beforehand
|
||||||
def fetchStores():
|
def fetchStore(key = None):
|
||||||
|
if key is not None:
|
||||||
|
data = storesDB.get(key)
|
||||||
|
return data
|
||||||
|
else:
|
||||||
|
northing = float(request.args['northing'])
|
||||||
|
easting = float(request.args['easting'])
|
||||||
|
zone = int(request.args['zone'])
|
||||||
# fetch store from deta Base
|
# fetch store from deta Base
|
||||||
fetch = storesDB.fetch({
|
fetch = storesDB.fetch({
|
||||||
'easting': request.args['easting'],
|
'easting?r': [easting - 1, easting + 1],
|
||||||
'northing': request.args['northing'],
|
'northing?r': [northing - 1, northing + 1],
|
||||||
'zone': request.args['zone'],
|
'zone': zone,
|
||||||
})
|
})
|
||||||
data = fetch.items
|
data = fetch.items
|
||||||
# if we didn't find the record on the first go around, try again
|
# if we didn't find the record on the first go around, try again
|
||||||
if not data:
|
if not data:
|
||||||
### inb4 infinite loop
|
|
||||||
while (fetch.last is not None):
|
while (fetch.last is not None):
|
||||||
fetch = storesDB.fetch({
|
fetch = storesDB.fetch({
|
||||||
'easting': request.args['easting'],
|
'easting?r': [easting - 1, easting + 1],
|
||||||
'northing': request.args['northing'],
|
'northing?r': [northing - 1, northing + 1],
|
||||||
'zone': request.args['zone'],
|
'zone': zone,
|
||||||
last: fetch.last
|
last: fetch.last
|
||||||
})
|
})
|
||||||
# check with each loop iteration if we've found it
|
# check with each loop iteration if we've found it
|
||||||
if fetch.items:
|
if fetch.items:
|
||||||
data.append(fetch.items)
|
data = fetch.items
|
||||||
# if so, break loop
|
# if so, break loop
|
||||||
break
|
break
|
||||||
return data
|
return data
|
||||||
|
|
||||||
# fetch requested item from item database based on request arguments and passed store
|
# fetch requested item from item database based on request arguments and passed store
|
||||||
def fetchItems(store):
|
def fetchItem(store):
|
||||||
# fetch item from deta Base
|
# fetch item from deta Base
|
||||||
fetch = itemsDB.fetch({
|
fetch = itemsDB.fetch({
|
||||||
'name': request.args['itemName'],
|
'lowername': request.args['itemName'].lower(),
|
||||||
'store': store['key'],
|
'store': store['key'],
|
||||||
})
|
})
|
||||||
data = fetch.items
|
data = fetch.items
|
||||||
# if we didn't find the record on the first go around, try again
|
# if we didn't find the record on the first go around, try again
|
||||||
if not data:
|
if not data:
|
||||||
### inb4 infinite loop
|
|
||||||
while (fetch.last is not None):
|
while (fetch.last is not None):
|
||||||
fetch = itemsDB.fetch({
|
fetch = itemsDB.fetch({
|
||||||
'name': request.args['itemName'],
|
'lowername': request.args['itemName'].lower(),
|
||||||
'store': store['key'],
|
'store': store['key'],
|
||||||
last: fetch.last
|
last: fetch.last
|
||||||
})
|
})
|
||||||
|
|
@ -73,26 +82,25 @@ def fetchAllItems(store):
|
||||||
'store': store['key'],
|
'store': store['key'],
|
||||||
})
|
})
|
||||||
data = fetch.items
|
data = fetch.items
|
||||||
# if we didn't find the record on the first go around, try again
|
|
||||||
if not data:
|
|
||||||
### inb4 infinite loop
|
|
||||||
while (fetch.last is not None):
|
while (fetch.last is not None):
|
||||||
fetch = itemsDB.fetch({
|
fetch = itemsDB.fetch({
|
||||||
'store': store['key'],
|
'store': store['key'],
|
||||||
last: fetch.last
|
last: fetch.last
|
||||||
})
|
})
|
||||||
data.append(fetch.items)
|
data = data + fetch.items
|
||||||
return data
|
return data
|
||||||
|
|
||||||
# compares this item to other items from its store to determine if it is the cheapest then updates store accordingly
|
# compares this item to other items from its store to determine if it is the cheapest then updates store accordingly
|
||||||
def updateCheapest(item, store):
|
def updateCheapest(item, store):
|
||||||
cheapest = true
|
cheapest = True
|
||||||
storeItems = fetchAllItems(store)
|
storeItems = fetchAllItems(store)
|
||||||
|
itemPerFloz = item['perFloz']
|
||||||
for storeItem in storeItems:
|
for storeItem in storeItems:
|
||||||
if storeItem['perFloz'] < item['perFloz']: cheapest = false
|
storeItemPerFloz = storeItem['perFloz']
|
||||||
|
if storeItemPerFloz < itemPerFloz: cheapest = False
|
||||||
if cheapest:
|
if cheapest:
|
||||||
store['cheapestItem'] = item['name']
|
store['cheapestItem'] = item['name']
|
||||||
store['cheapestFloz'] = item['perFloz']
|
store['cheapestFloz'] = itemPerFloz
|
||||||
storesDB.put(store)
|
storesDB.put(store)
|
||||||
|
|
||||||
### routes
|
### routes
|
||||||
|
|
@ -104,150 +112,221 @@ def get():
|
||||||
# get all stores
|
# get all stores
|
||||||
fetch = storesDB.fetch({})
|
fetch = storesDB.fetch({})
|
||||||
data = fetch.items
|
data = fetch.items
|
||||||
### inb4 infinite loop
|
|
||||||
while (fetch.last is not None):
|
while (fetch.last is not None):
|
||||||
fetch = storesDB.fetch({
|
fetch = storesDB.fetch({
|
||||||
last: fetch.last
|
last: fetch.last
|
||||||
})
|
})
|
||||||
data.append(fetch.items)
|
data = data + fetch.items
|
||||||
else:
|
else:
|
||||||
# get store location details from URL arguments
|
# get store location details from URL arguments
|
||||||
if request.args['easting'] and request.args['northing'] and request.args['zone']:
|
if 'easting' in request.args and 'northing' in request.args and 'zone' in request.args:
|
||||||
stores = fetchStores()
|
stores = fetchStore()
|
||||||
if not stores: abort(404)
|
if not stores: abort(404)
|
||||||
store = stores[0]
|
store = stores[0]
|
||||||
|
items = fetchAllItems(store)
|
||||||
|
store['items'] = items
|
||||||
# if an item GET request
|
# if an item GET request
|
||||||
if request.args['itemName']:
|
if 'itemName' in request.args:
|
||||||
# passing through the store at position [0] in the fetched stores array
|
# passing through the store at position [0] in the fetched stores array
|
||||||
items = fetchItems(store)
|
items = fetchItem(store)
|
||||||
if not items: abort(404)
|
if not items: abort(404)
|
||||||
data = items[0]
|
data = items[0]
|
||||||
# otherwise is a store GET request
|
# otherwise is a store GET request
|
||||||
else: data = store
|
else: data = store
|
||||||
# if wrong arguments, malformed request
|
# else store key passed, get by key
|
||||||
|
elif 'storeKey' in request.args: data = fetchStore(request.args['storeKey'])
|
||||||
|
# otherwise is a malformed request
|
||||||
else: abort(400)
|
else: abort(400)
|
||||||
# otherwise return data
|
# otherwise return data
|
||||||
return data
|
return data
|
||||||
|
|
||||||
@app.route('/', methods=['POST'])
|
@app.route('/', methods=['POST'])
|
||||||
def post():
|
def post():
|
||||||
# checks for arguments
|
# checks for args
|
||||||
|
# easier to work with args here because it allows for reuse of functions above
|
||||||
if request.args:
|
if request.args:
|
||||||
if request.args['easting'] and request.args['northing'] and request.args['zone'] and request.args['itemName'] and request.args['itemPrice'] and request.args['itemVolume']:
|
if 'easting' in request.args and 'northing' in request.args and 'zone' in request.args and 'zoneLetter' and 'name' in request.args:
|
||||||
|
northing = float(request.args['northing'])
|
||||||
|
easting = float(request.args['easting'])
|
||||||
|
zone = int(request.args['zone'])
|
||||||
# checks if this is posting a new store
|
# checks if this is posting a new store
|
||||||
if request.args['name']:
|
# test for presence of stores in close vicinity with similar name
|
||||||
# test for presence of stores in close vicinity with similar names
|
|
||||||
nameSubstrings = request.args['name'].split()
|
|
||||||
for nameSubstring in nameSubstrings:
|
|
||||||
fetch = storesDB.fetch({
|
fetch = storesDB.fetch({
|
||||||
'easting?r': [request.args['easting'] - 10, request.args['easting'] + 10],
|
'easting?r': [easting - 10, easting + 10],
|
||||||
'northing?r': [request.args['northing'] - 10, request.args['northing'] + 10],
|
'northing?r': [northing - 10, northing + 10],
|
||||||
'zone': request.args['zone'],
|
'zone': zone,
|
||||||
'name?contains': nameSubstring,
|
'lowername?contains': request.args['name'].lower(),
|
||||||
})
|
})
|
||||||
stores = fetch.items
|
stores = fetch.items
|
||||||
# if we didn't find the record on the first go around, try again
|
|
||||||
if not stores:
|
if not stores:
|
||||||
### inb4 infinite loop
|
|
||||||
while (fetch.last is not None):
|
while (fetch.last is not None):
|
||||||
fetch = storesDB.fetch({
|
fetch = storesDB.fetch({
|
||||||
'easting?r': [request.args['easting'] - 10, request.args['easting'] + 10],
|
'easting?r': [easting - 10, easting + 10],
|
||||||
'northing?r': [request.args['northing'] - 10, request.args['northing'] + 10],
|
'northing?r': [northing - 10, northing + 10],
|
||||||
'zone': request.args['zone'],
|
'zone': zone,
|
||||||
'name?contains': 'nameSubstring',
|
'lowername?contains': request.args['name'].lower(),
|
||||||
last: fetch.last
|
last: fetch.last
|
||||||
})
|
})
|
||||||
# check with each loop iteration if we've found it
|
# check with each loop iteration if we've found it
|
||||||
if fetch.items:
|
if fetch.items:
|
||||||
stores.append(fetch.items)
|
stores = stores + fetch.items
|
||||||
# if so, break loop
|
# if so, break loop
|
||||||
break
|
break
|
||||||
# if found a store within +/-10 meters named similarly to one of the substrings, abort
|
# if found a store within +/-10 meters named similarly, abort
|
||||||
if stores: abort(409)
|
if stores: abort(409)
|
||||||
# by this point, app would have aborted if there were a problem
|
# by this point, app would have aborted if there were a problem
|
||||||
# let's create the records
|
# let's create the records
|
||||||
# first the item dict
|
# first the item dict
|
||||||
|
price = float(request.args['itemPrice'])
|
||||||
|
volume = int(request.args['itemVolume'])
|
||||||
item = {
|
item = {
|
||||||
'name': request.args['itemName'],
|
'name': request.args['itemName'],
|
||||||
'volumeFloz': request.args['itemVolume'],
|
'lowername': request.args['itemName'].lower(),
|
||||||
'price': request.args['itemPrice'],
|
'volumeFloz': volume,
|
||||||
'perFloz': request.args['itemPrice'] / request.args['itemVolume'],
|
'price': price,
|
||||||
'lastUpdated': datetime.now().strftime('%m-%d-%Y %H:%M:%S'),
|
'perFloz': round(price / float(volume), 2),
|
||||||
|
'lastUpdated': datetime.utcnow().strftime('%Y-%m-%d %H:%M:%S'),
|
||||||
}
|
}
|
||||||
# upload store to DB
|
# upload store to DB
|
||||||
store = storesDB.put({
|
store = storesDB.put({
|
||||||
'easting': request.args['easting'],
|
'easting': easting,
|
||||||
'northing': request.args['northing'],
|
'northing': northing,
|
||||||
'zone': request.args['zone'],
|
'zone': zone,
|
||||||
|
'zoneLetter': request.args['zoneLetter'],
|
||||||
'name': request.args['name'],
|
'name': request.args['name'],
|
||||||
|
'lowername': request.args['name'].lower(),
|
||||||
'cheapestItem': item['name'],
|
'cheapestItem': item['name'],
|
||||||
'cheapestFloz': item['perFloz'],
|
'cheapestFloz': item['perFloz'],
|
||||||
'lastUpdated': datetime.now().strftime('%m-%d-%Y %H:%M:%S'),
|
'lastUpdated': datetime.utcnow().strftime('%Y-%m-%d %H:%M:%S'),
|
||||||
})
|
})
|
||||||
# upload item to DB
|
# upload item to DB
|
||||||
item['store'] = store['key']
|
item['store'] = store['key']
|
||||||
itemsDB.put(item)
|
itemsDB.put(item)
|
||||||
# if there is an image upload
|
# if there is an image upload
|
||||||
if request.args['image']:
|
if request.files:
|
||||||
drive.put(store.key, data=request.args['image'])
|
if 'file' in request.files:
|
||||||
|
file = request.files['file']
|
||||||
|
drive.put(store['key'], data=request.files['file'].read())
|
||||||
# finish successfully
|
# finish successfully
|
||||||
return {}
|
return make_response("Success", 200)
|
||||||
# or if it's posting a new item in the store
|
# or if it's posting a new item in the store
|
||||||
else:
|
elif 'storeKey' in request.args and 'itemName' in request.args and 'itemPrice' in request.args and 'itemVolume' in request.args:
|
||||||
stores = fetchStores()
|
store = fetchStore(request.args['storeKey'])
|
||||||
if not stores: abort(404)
|
if not store: abort(404)
|
||||||
store = stores[0]
|
|
||||||
# check for existence of duplicate item
|
# check for existence of duplicate item
|
||||||
found = fetchItems(store)
|
found = fetchItem(store)
|
||||||
if (found): abort(409)
|
if (found): abort(409)
|
||||||
# let's create the record
|
# let's create the record
|
||||||
|
price = float(request.args['itemPrice'])
|
||||||
|
volume = int(request.args['itemVolume'])
|
||||||
item = {
|
item = {
|
||||||
'name': request.args['itemName'],
|
'name': request.args['itemName'],
|
||||||
'volumeFloz': request.args['itemVolume'],
|
'lowername': request.args['itemName'].lower(),
|
||||||
'price': request.args['itemPrice'],
|
'volumeFloz': volume,
|
||||||
'perFloz': request.args['itemPrice'] / request.args['itemVolume'],
|
'price': price,
|
||||||
'lastUpdated': datetime.now().strftime('%m-%d-%Y %H:%M:%S'),
|
'perFloz': round(price / float(volume), 2),
|
||||||
|
'lastUpdated': datetime.utcnow().strftime('%Y-%m-%d %H:%M:%S'),
|
||||||
'store': store['key']
|
'store': store['key']
|
||||||
}
|
}
|
||||||
updateCheapest(item, store)
|
updateCheapest(item, store)
|
||||||
# beam it up
|
# beam it up
|
||||||
itemsDB.put(item)
|
itemsDB.put(item)
|
||||||
# finish successfully
|
# finish successfully
|
||||||
return {}
|
return make_response("Success", 200)
|
||||||
# aborts if conditional chains not met
|
|
||||||
abort(400)
|
abort(400)
|
||||||
|
|
||||||
@app.route('/', methods=['PUT'])
|
@app.route('/', methods=['PUT'])
|
||||||
def put():
|
def put():
|
||||||
# checks for arguments
|
# checks for arguments
|
||||||
if request.args:
|
if request.args:
|
||||||
if request.args['easting'] and request.args['northing'] and request.args['zone']:
|
if 'storeKey' in request.args:
|
||||||
# find store
|
# find store
|
||||||
stores = fetchStores()
|
store = fetchStore(request.args['storeKey'])
|
||||||
if not stores: abort(404)
|
if not store: abort(404)
|
||||||
store = stores[0]
|
|
||||||
# updating item price
|
# updating item price
|
||||||
if request.args['itemName'] and request.args['price']:
|
if 'itemName' in request.args and 'itemPrice' in request.args:
|
||||||
# find item
|
# find item
|
||||||
items = fetchItems(store)
|
items = fetchItem(store)
|
||||||
if not items: abort (404)
|
if not items: abort (404)
|
||||||
item = items[0]
|
item = items[0]
|
||||||
# change price
|
# change price
|
||||||
item['price'] = request.args['itemPrice']
|
price = float(request.args['itemPrice'])
|
||||||
item['perFloz'] = item['price'] / item['volumeFloz']
|
volume = int(item['volumeFloz'])
|
||||||
item['lastUpdated'] = datetime.now().strftime('%m-%d-%Y %H:%M:%S')
|
item['price'] = price
|
||||||
|
item['perFloz'] = round(price / float(volume), 2)
|
||||||
|
item['lastUpdated'] = datetime.utcnow().strftime('%Y-%m-%d %H:%M:%S')
|
||||||
updateCheapest(item, store)
|
updateCheapest(item, store)
|
||||||
# beam it up
|
# beam it up
|
||||||
itemsDB.put(item)
|
itemsDB.put(item)
|
||||||
# finish successfully
|
# finish successfully
|
||||||
return {}
|
return make_response("Success", 200)
|
||||||
# updating store image
|
# updating store image
|
||||||
if request.args['image']:
|
# if there is an image upload
|
||||||
store['lastUpdated'] = datetime.now().strftime('%m-%d-%Y %H:%M:%S')
|
if request.files:
|
||||||
|
if 'file' in request.files:
|
||||||
|
store['lastUpdated'] = datetime.utcnow().strftime('%Y-%m-%d %H:%M:%S')
|
||||||
storesDB.put(store)
|
storesDB.put(store)
|
||||||
drive.put(store.key, data=request.args['image'])
|
file = request.files['file']
|
||||||
return {}
|
drive.put(store['key'], data=request.files['file'].read())
|
||||||
# aborts if conditional chains not met
|
# finish successfully
|
||||||
|
return make_response("Success", 200)
|
||||||
|
abort(400)
|
||||||
|
|
||||||
|
@app.route('/img', methods=['GET'])
|
||||||
|
def getImage():
|
||||||
|
if not request.args: abort(400)
|
||||||
|
else:
|
||||||
|
if 'imageKey' in request.args:
|
||||||
|
mimetype = filetype.guess(drive.get(request.args['imageKey']).read()).mime
|
||||||
|
return send_file(drive.get(request.args['imageKey']), download_name = request.args['imageKey'] + ".png", mimetype = mimetype)
|
||||||
|
|
||||||
|
@app.route('/search', methods=['GET'])
|
||||||
|
def search():
|
||||||
|
if not request.args: abort(400)
|
||||||
|
else:
|
||||||
|
if 'easting' in request.args and 'northing' and 'query' in request.args:
|
||||||
|
# search all stores within 50 mile radius (80 km)
|
||||||
|
northing = float(request.args['northing'])
|
||||||
|
easting = float(request.args['easting'])
|
||||||
|
# fetch store from deta Base
|
||||||
|
fetch = storesDB.fetch({
|
||||||
|
'easting?r': [easting - 80000, easting + 80000],
|
||||||
|
'northing?r': [northing - 80000, northing + 80000]
|
||||||
|
})
|
||||||
|
stores = fetch.items
|
||||||
|
# if we didn't find records on the first go around, try again
|
||||||
|
if not stores:
|
||||||
|
while (fetch.last is not None):
|
||||||
|
fetch = storesDB.fetch({
|
||||||
|
'easting?r': [easting + 80000, easting + 80000],
|
||||||
|
'northing?r': [northing - 80000, northing + 80000],
|
||||||
|
last: fetch.last
|
||||||
|
})
|
||||||
|
stores = stores + fetch.items
|
||||||
|
if not stores: abort(404)
|
||||||
|
# for item matching query
|
||||||
|
items = []
|
||||||
|
for store in stores:
|
||||||
|
# fetch item from deta Base
|
||||||
|
querySubstrings = request.args['query'].split()
|
||||||
|
for querySubstring in querySubstrings:
|
||||||
|
fetch = itemsDB.fetch({
|
||||||
|
'store': store['key'],
|
||||||
|
'lowername?contains': querySubstring.lower(),
|
||||||
|
})
|
||||||
|
items = items + fetch.items
|
||||||
|
while (fetch.last is not None):
|
||||||
|
fetch = itemsDB.fetch({
|
||||||
|
'store': store['key'],
|
||||||
|
'lowername?contains': querySubstring.lower(),
|
||||||
|
last: fetch.last
|
||||||
|
})
|
||||||
|
items = items + fetch.items
|
||||||
|
# append distance information
|
||||||
|
for item in items:
|
||||||
|
if item['store'] == store['key']: item['distance'] = max(abs(store['easting'] - easting), abs(store['northing'] - northing))
|
||||||
|
if not items: abort(404)
|
||||||
|
return items
|
||||||
abort(400)
|
abort(400)
|
||||||
|
|
||||||
# no deletion planned (for now)
|
# no deletion planned (for now)
|
||||||
|
|
@ -1,11 +1,14 @@
|
||||||
blinker==1.7.0
|
blinker==1.7.0
|
||||||
click==8.1.7
|
click==8.1.7
|
||||||
deta==1.2.0
|
deta==1.2.0
|
||||||
|
filetype==1.2.0
|
||||||
Flask==3.0.0
|
Flask==3.0.0
|
||||||
|
Flask-Cors==4.0.0
|
||||||
gunicorn==21.2.0
|
gunicorn==21.2.0
|
||||||
itsdangerous==2.1.2
|
itsdangerous==2.1.2
|
||||||
Jinja2==3.1.2
|
Jinja2==3.1.2
|
||||||
MarkupSafe==2.1.3
|
MarkupSafe==2.1.3
|
||||||
packaging==23.2
|
packaging==23.2
|
||||||
|
passlib==1.7.4
|
||||||
python-dotenv==1.0.0
|
python-dotenv==1.0.0
|
||||||
Werkzeug==3.0.1
|
Werkzeug==3.0.1
|
||||||
|
|
|
||||||
|
|
@ -1,69 +1,67 @@
|
||||||
import "./style.css";
|
import "./style.css";
|
||||||
|
|
||||||
import { Map, View, Overlay, Feature } from "ol";
|
import { Map, View, Overlay } from "ol";
|
||||||
import { Icon, Style } from "ol/style";
|
|
||||||
import { Tile as TileLayer, Vector as VectorLayer } from "ol/layer";
|
import { Tile as TileLayer, Vector as VectorLayer } from "ol/layer";
|
||||||
import { TileJSON, Vector as VectorSource } from "ol/source";
|
import { TileJSON, Vector as VectorSource } from "ol/source";
|
||||||
import { useGeographic } from "ol/proj.js";
|
import { useGeographic } from "ol/proj.js";
|
||||||
import { Control, defaults as defaultControls } from "ol/control.js";
|
import { Control, defaults as defaultControls } from "ol/control.js";
|
||||||
import Point from "ol/geom/Point.js";
|
|
||||||
|
import Search from "ol-ext/control/Search.js";
|
||||||
|
|
||||||
import { Popover } from "bootstrap";
|
import { Popover } from "bootstrap";
|
||||||
|
import utmObj from "utm-latlng";
|
||||||
|
|
||||||
|
import makePin from "./modules/makePin.js";
|
||||||
|
|
||||||
|
// utm converter
|
||||||
|
const utm = new utmObj();
|
||||||
|
|
||||||
// specify to use real lon/lat coordinates
|
// specify to use real lon/lat coordinates
|
||||||
useGeographic();
|
useGeographic();
|
||||||
|
|
||||||
// state bool for locking pin overlay while adding pin
|
// state bool for locking pin overlay while adding pin
|
||||||
let pinning = false;
|
let pinning = false;
|
||||||
|
|
||||||
// creates pins
|
|
||||||
const makePin = (lon, lat, storeName, cheapest, flozPrice) => {
|
|
||||||
// define pin graphics (since this is all inline)
|
|
||||||
const pinSVG = `<svg width="32px" height="32px" viewBox="-0.12 -0.12 12.24 12.24" enable-background="new 0 0 12 12" id="Слой_1" version="1.1" xml:space="preserve" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" fill="#000000" stroke="#000000" stroke-width="0.12000000000000002"><g id="SVGRepo_bgCarrier" stroke-width="0"></g><g id="SVGRepo_tracerCarrier" stroke-linecap="round" stroke-linejoin="round"></g><g id="SVGRepo_iconCarrier"><path d="M6,0C3.2385864,0,1,2.2385864,1,5s2.5,5,5,7c2.5-2,5-4.2385864,5-7S8.7614136,0,6,0z M6,7 C4.8954468,7,4,6.1045532,4,5s0.8954468-2,2-2s2,0.8954468,2,2S7.1045532,7,6,7z" fill="#ed333b"></path></g></svg>`;
|
|
||||||
const pinSVGBlob = new Blob([pinSVG], {
|
|
||||||
type: "image/svg+xml",
|
|
||||||
});
|
|
||||||
const pinImageURL = URL.createObjectURL(pinSVGBlob);
|
|
||||||
|
|
||||||
// define style for all pins
|
|
||||||
const pinStyle = new Style({
|
|
||||||
image: new Icon({
|
|
||||||
src: pinImageURL,
|
|
||||||
anchor: [0.5, 1],
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
// create pin as feature
|
|
||||||
const pin = new Feature({
|
|
||||||
geometry: new Point([lon, lat]),
|
|
||||||
store: storeName,
|
|
||||||
cheapestItem: cheapest,
|
|
||||||
pricePerOz: flozPrice,
|
|
||||||
});
|
|
||||||
|
|
||||||
// set style for pin
|
|
||||||
pin.setStyle(pinStyle);
|
|
||||||
|
|
||||||
return pin;
|
|
||||||
};
|
|
||||||
|
|
||||||
// create new VectorSource for pins
|
// create new VectorSource for pins
|
||||||
const pinSource = new VectorSource();
|
const pinSource = new VectorSource();
|
||||||
|
|
||||||
// FLASK PULL LOOP
|
|
||||||
|
|
||||||
// test
|
|
||||||
const pin = makePin(0, 0, "Null Store", "Modelo", "0.30");
|
|
||||||
|
|
||||||
// add pin
|
|
||||||
pinSource.addFeature(pin);
|
|
||||||
|
|
||||||
// END FLASK PULL LOOP
|
|
||||||
|
|
||||||
// create vector layer for rendering pins and popups
|
// create vector layer for rendering pins and popups
|
||||||
const pinLayer = new VectorLayer({
|
const pinLayer = new VectorLayer({
|
||||||
source: pinSource,
|
source: pinSource,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// pull data from flask
|
||||||
|
fetch(import.meta.env.VITE_BACKEND_URL)
|
||||||
|
.then((res) => res.json())
|
||||||
|
.then((dbPins) => {
|
||||||
|
dbPins.forEach((dbPin) => {
|
||||||
|
// convert to lat/lon
|
||||||
|
const [lat, lon] = [
|
||||||
|
utm.convertUtmToLatLng(
|
||||||
|
dbPin.easting,
|
||||||
|
dbPin.northing,
|
||||||
|
dbPin.zone,
|
||||||
|
dbPin.zoneLetter
|
||||||
|
).lat,
|
||||||
|
utm.convertUtmToLatLng(
|
||||||
|
dbPin.easting,
|
||||||
|
dbPin.northing,
|
||||||
|
dbPin.zone,
|
||||||
|
dbPin.zoneLetter
|
||||||
|
).lng,
|
||||||
|
];
|
||||||
|
// make pin feature
|
||||||
|
const pin = makePin(
|
||||||
|
lon.toFixed(5),
|
||||||
|
lat.toFixed(5),
|
||||||
|
dbPin.name,
|
||||||
|
dbPin.cheapestItem,
|
||||||
|
dbPin.cheapestFloz
|
||||||
|
);
|
||||||
|
// add
|
||||||
|
pinSource.addFeature(pin);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// create button
|
// create button
|
||||||
// made in global scope so it can be modified
|
// made in global scope so it can be modified
|
||||||
const button = document.createElement("button");
|
const button = document.createElement("button");
|
||||||
|
|
@ -89,6 +87,15 @@ class ButtonControl extends Control {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// create search bar
|
||||||
|
const search = new Search({
|
||||||
|
// disable autocompletion
|
||||||
|
typing: -1,
|
||||||
|
placeholder: "Search for products...",
|
||||||
|
collapsed: "false",
|
||||||
|
className: "searchBar",
|
||||||
|
});
|
||||||
|
|
||||||
// create OSM map
|
// create OSM map
|
||||||
const map = new Map({
|
const map = new Map({
|
||||||
controls: defaultControls().extend([new ButtonControl()]),
|
controls: defaultControls().extend([new ButtonControl()]),
|
||||||
|
|
@ -96,7 +103,9 @@ const map = new Map({
|
||||||
layers: [
|
layers: [
|
||||||
new TileLayer({
|
new TileLayer({
|
||||||
source: new TileJSON({
|
source: new TileJSON({
|
||||||
url: "https://api.maptiler.com/maps/hybrid/tiles.json?key=iE1hOruK6f8SDwxrEIir",
|
url: `https://api.maptiler.com/maps/hybrid/tiles.json?key=${
|
||||||
|
import.meta.env.VITE_MAPTILER_KEY
|
||||||
|
}`,
|
||||||
tileSize: 512,
|
tileSize: 512,
|
||||||
crossOrigin: "anonymous",
|
crossOrigin: "anonymous",
|
||||||
}),
|
}),
|
||||||
|
|
@ -121,6 +130,20 @@ const popup = new Overlay({
|
||||||
// add popup overlay to map
|
// add popup overlay to map
|
||||||
map.addOverlay(popup);
|
map.addOverlay(popup);
|
||||||
|
|
||||||
|
// add search
|
||||||
|
map.addControl(search);
|
||||||
|
|
||||||
|
// get search input field
|
||||||
|
const searchInput = search.getInputField();
|
||||||
|
searchInput.onkeydown = (event) => {
|
||||||
|
if (event.key === "Enter") {
|
||||||
|
// emits to RN
|
||||||
|
window.ReactNativeWebView?.postMessage(
|
||||||
|
`search@${map.getView().getCenter()}:${event.target.value}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// set state for popup visibility
|
// set state for popup visibility
|
||||||
let popover;
|
let popover;
|
||||||
const disposePopover = () => {
|
const disposePopover = () => {
|
||||||
|
|
@ -132,7 +155,6 @@ const disposePopover = () => {
|
||||||
|
|
||||||
// on click events
|
// on click events
|
||||||
map.on("click", (event) => {
|
map.on("click", (event) => {
|
||||||
console.log(event.coordinate);
|
|
||||||
// get features at pixel
|
// get features at pixel
|
||||||
const feature = map.forEachFeatureAtPixel(event.pixel, (feature) => feature);
|
const feature = map.forEachFeatureAtPixel(event.pixel, (feature) => feature);
|
||||||
// preemptively remove any previous popup overlays
|
// preemptively remove any previous popup overlays
|
||||||
|
|
@ -146,9 +168,26 @@ map.on("click", (event) => {
|
||||||
placement: "top",
|
placement: "top",
|
||||||
html: true,
|
html: true,
|
||||||
title: feature.get("store"),
|
title: feature.get("store"),
|
||||||
content: `Cheapest Item: ${feature.get(
|
content: function () {
|
||||||
"cheapestItem"
|
const holder = document.createElement("div");
|
||||||
)}<br/>$ / oz.: $0.30<br/><a href=''>More...</a><br/>`,
|
const one = document.createElement("div");
|
||||||
|
one.textContent = `Cheapest Item: ${feature.get("cheapestItem")}`;
|
||||||
|
holder.appendChild(one);
|
||||||
|
const two = document.createElement("div");
|
||||||
|
two.textContent = `$ / oz.: ${feature.get("pricePerOz")}`;
|
||||||
|
holder.appendChild(two);
|
||||||
|
const link = document.createElement("a");
|
||||||
|
link.href = "#";
|
||||||
|
link.onclick = function (event) {
|
||||||
|
event.preventDefault();
|
||||||
|
window.ReactNativeWebView?.postMessage(
|
||||||
|
`open@${feature.getGeometry().getCoordinates()}`
|
||||||
|
);
|
||||||
|
};
|
||||||
|
link.textContent = "More...";
|
||||||
|
holder.appendChild(link);
|
||||||
|
return holder;
|
||||||
|
},
|
||||||
});
|
});
|
||||||
// show popover
|
// show popover
|
||||||
popover.show();
|
popover.show();
|
||||||
|
|
@ -188,6 +227,7 @@ window.passLocation = (lon, lat) => {
|
||||||
});
|
});
|
||||||
|
|
||||||
map.setView(view);
|
map.setView(view);
|
||||||
|
map.getLayers().extend([pinLayer]);
|
||||||
|
|
||||||
// sets NEW listener to listen for zoom changes
|
// sets NEW listener to listen for zoom changes
|
||||||
view.on("change:resolution", () => {
|
view.on("change:resolution", () => {
|
||||||
|
|
@ -219,7 +259,9 @@ window.placePin = () => {
|
||||||
// removes pin in center of view
|
// removes pin in center of view
|
||||||
document.getElementById("centerPin").style.display = "none";
|
document.getElementById("centerPin").style.display = "none";
|
||||||
// emits to RN
|
// emits to RN
|
||||||
window.ReactNativeWebView?.postMessage(`@${map.getView().getCenter()}`);
|
window.ReactNativeWebView?.postMessage(
|
||||||
|
`create@${map.getView().getCenter()}`
|
||||||
|
);
|
||||||
}
|
}
|
||||||
button.addEventListener("click", OKButtonHandler, false);
|
button.addEventListener("click", OKButtonHandler, false);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
36
openlayers/modules/makePin.js
Normal file
36
openlayers/modules/makePin.js
Normal file
|
|
@ -0,0 +1,36 @@
|
||||||
|
// creates pins
|
||||||
|
import { Feature } from "ol";
|
||||||
|
import { Icon, Style } from "ol/style";
|
||||||
|
import Point from "ol/geom/Point.js";
|
||||||
|
|
||||||
|
const makePin = (lon, lat, storeName, cheapest, flozPrice) => {
|
||||||
|
// define pin graphics (since this is all inline)
|
||||||
|
const pinSVG = `<svg width="32px" height="32px" viewBox="-0.12 -0.12 12.24 12.24" enable-background="new 0 0 12 12" id="Слой_1" version="1.1" xml:space="preserve" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" fill="#000000" stroke="#000000" stroke-width="0.12000000000000002"><g id="SVGRepo_bgCarrier" stroke-width="0"></g><g id="SVGRepo_tracerCarrier" stroke-linecap="round" stroke-linejoin="round"></g><g id="SVGRepo_iconCarrier"><path d="M6,0C3.2385864,0,1,2.2385864,1,5s2.5,5,5,7c2.5-2,5-4.2385864,5-7S8.7614136,0,6,0z M6,7 C4.8954468,7,4,6.1045532,4,5s0.8954468-2,2-2s2,0.8954468,2,2S7.1045532,7,6,7z" fill="#ed333b"></path></g></svg>`;
|
||||||
|
const pinSVGBlob = new Blob([pinSVG], {
|
||||||
|
type: "image/svg+xml",
|
||||||
|
});
|
||||||
|
const pinImageURL = URL.createObjectURL(pinSVGBlob);
|
||||||
|
|
||||||
|
// define style for all pins
|
||||||
|
const pinStyle = new Style({
|
||||||
|
image: new Icon({
|
||||||
|
src: pinImageURL,
|
||||||
|
anchor: [0.5, 1],
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
// create pin as feature
|
||||||
|
const pin = new Feature({
|
||||||
|
geometry: new Point([lon, lat]),
|
||||||
|
store: storeName,
|
||||||
|
cheapestItem: cheapest,
|
||||||
|
pricePerOz: flozPrice,
|
||||||
|
});
|
||||||
|
|
||||||
|
// set style for pin
|
||||||
|
pin.setStyle(pinStyle);
|
||||||
|
|
||||||
|
return pin;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default makePin;
|
||||||
26
openlayers/package-lock.json
generated
26
openlayers/package-lock.json
generated
|
|
@ -11,6 +11,8 @@
|
||||||
"bootstrap": "^5.3.2",
|
"bootstrap": "^5.3.2",
|
||||||
"dotenv": "^16.3.1",
|
"dotenv": "^16.3.1",
|
||||||
"ol": "latest",
|
"ol": "latest",
|
||||||
|
"ol-ext": "^4.0.13",
|
||||||
|
"utm-latlng": "^1.0.7",
|
||||||
"vite-plugin-singlefile": "^0.13.5"
|
"vite-plugin-singlefile": "^0.13.5"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|
@ -653,6 +655,14 @@
|
||||||
"url": "https://opencollective.com/openlayers"
|
"url": "https://opencollective.com/openlayers"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/ol-ext": {
|
||||||
|
"version": "4.0.13",
|
||||||
|
"resolved": "https://registry.npmjs.org/ol-ext/-/ol-ext-4.0.13.tgz",
|
||||||
|
"integrity": "sha512-eNUKmPXBp7pOI8lE/qhv+oIbCwFyrqW4gGcILxTlvjhICKyaNkcmXGm3lOvHd2PnsKBtbjwg2knHiJKpEQNDtg==",
|
||||||
|
"peerDependencies": {
|
||||||
|
"ol": ">= 5.3.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/ol/node_modules/ol-mapbox-style": {
|
"node_modules/ol/node_modules/ol-mapbox-style": {
|
||||||
"version": "9.1.0",
|
"version": "9.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/ol-mapbox-style/-/ol-mapbox-style-9.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/ol-mapbox-style/-/ol-mapbox-style-9.1.0.tgz",
|
||||||
|
|
@ -848,6 +858,11 @@
|
||||||
"node": ">=8.0"
|
"node": ">=8.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/utm-latlng": {
|
||||||
|
"version": "1.0.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/utm-latlng/-/utm-latlng-1.0.7.tgz",
|
||||||
|
"integrity": "sha512-Ij1TCHzrThdHSwYM7SJh3yPNSOqUVnUNQPnF1Dpy1OfaK+ej4a0sDfymMvIrHBzJlERCS114VBW/94WKF++Big=="
|
||||||
|
},
|
||||||
"node_modules/vite": {
|
"node_modules/vite": {
|
||||||
"version": "4.0.4",
|
"version": "4.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/vite/-/vite-4.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/vite/-/vite-4.0.4.tgz",
|
||||||
|
|
@ -1283,6 +1298,12 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"ol-ext": {
|
||||||
|
"version": "4.0.13",
|
||||||
|
"resolved": "https://registry.npmjs.org/ol-ext/-/ol-ext-4.0.13.tgz",
|
||||||
|
"integrity": "sha512-eNUKmPXBp7pOI8lE/qhv+oIbCwFyrqW4gGcILxTlvjhICKyaNkcmXGm3lOvHd2PnsKBtbjwg2knHiJKpEQNDtg==",
|
||||||
|
"requires": {}
|
||||||
|
},
|
||||||
"pako": {
|
"pako": {
|
||||||
"version": "2.0.4",
|
"version": "2.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/pako/-/pako-2.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/pako/-/pako-2.0.4.tgz",
|
||||||
|
|
@ -1413,6 +1434,11 @@
|
||||||
"is-number": "^7.0.0"
|
"is-number": "^7.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"utm-latlng": {
|
||||||
|
"version": "1.0.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/utm-latlng/-/utm-latlng-1.0.7.tgz",
|
||||||
|
"integrity": "sha512-Ij1TCHzrThdHSwYM7SJh3yPNSOqUVnUNQPnF1Dpy1OfaK+ej4a0sDfymMvIrHBzJlERCS114VBW/94WKF++Big=="
|
||||||
|
},
|
||||||
"vite": {
|
"vite": {
|
||||||
"version": "4.0.4",
|
"version": "4.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/vite/-/vite-4.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/vite/-/vite-4.0.4.tgz",
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,8 @@
|
||||||
"bootstrap": "^5.3.2",
|
"bootstrap": "^5.3.2",
|
||||||
"dotenv": "^16.3.1",
|
"dotenv": "^16.3.1",
|
||||||
"ol": "latest",
|
"ol": "latest",
|
||||||
|
"ol-ext": "^4.0.13",
|
||||||
|
"utm-latlng": "^1.0.7",
|
||||||
"vite-plugin-singlefile": "^0.13.5"
|
"vite-plugin-singlefile": "^0.13.5"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ html,
|
||||||
body {
|
body {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
|
background-color: black;
|
||||||
}
|
}
|
||||||
#map {
|
#map {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
|
|
@ -19,16 +20,16 @@ body {
|
||||||
.new-button {
|
.new-button {
|
||||||
left: 0;
|
left: 0;
|
||||||
right: 0;
|
right: 0;
|
||||||
bottom: 1.25em;
|
bottom: 1.1em;
|
||||||
width: fit-content;
|
width: fit-content;
|
||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
margin-right: auto;
|
margin-right: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.new-button > button {
|
.new-button > button {
|
||||||
padding: 1.25em;
|
padding: 1.1em;
|
||||||
width: fit-content;
|
width: fit-content;
|
||||||
line-height: 0.2em;
|
line-height: 0.1em;
|
||||||
}
|
}
|
||||||
|
|
||||||
#centerPin {
|
#centerPin {
|
||||||
|
|
@ -42,3 +43,25 @@ body {
|
||||||
#centerPin > svg {
|
#centerPin > svg {
|
||||||
left: -25%;
|
left: -25%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.searchBar {
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
top: 1.25em;
|
||||||
|
width: fit-content;
|
||||||
|
margin-left: auto;
|
||||||
|
margin-right: auto;
|
||||||
|
font-size: medium;
|
||||||
|
}
|
||||||
|
|
||||||
|
.searchBar > button,
|
||||||
|
.searchBar > ul {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.searchBar > input {
|
||||||
|
padding-right: 0.5em;
|
||||||
|
padding-left: 0.5em;
|
||||||
|
padding-top: 0.25em;
|
||||||
|
padding-bottom: 0.25em;
|
||||||
|
}
|
||||||
|
|
|
||||||
9
react-native/.gitignore
vendored
9
react-native/.gitignore
vendored
|
|
@ -30,6 +30,15 @@ yarn-error.*
|
||||||
|
|
||||||
# local env files
|
# local env files
|
||||||
.env*.local
|
.env*.local
|
||||||
|
.env
|
||||||
|
|
||||||
# typescript
|
# typescript
|
||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
|
|
||||||
|
# @generated expo-cli sync-2b81b286409207a5da26e14c78851eb30d8ccbdb
|
||||||
|
# The following patterns were generated by expo-cli
|
||||||
|
|
||||||
|
expo-env.d.ts
|
||||||
|
# @end expo-cli
|
||||||
|
|
||||||
|
eas.json
|
||||||
|
|
@ -1,258 +0,0 @@
|
||||||
// taken from https://www.npmjs.com/package/utm-projection due to expo/npm glitch
|
|
||||||
// all rights aliothor
|
|
||||||
|
|
||||||
var K0 = 0.9996;
|
|
||||||
|
|
||||||
var E = 0.00669438;
|
|
||||||
var E2 = Math.pow(E, 2);
|
|
||||||
var E3 = Math.pow(E, 3);
|
|
||||||
var E_P2 = E / (1 - E);
|
|
||||||
|
|
||||||
var SQRT_E = Math.sqrt(1 - E);
|
|
||||||
var _E = (1 - SQRT_E) / (1 + SQRT_E);
|
|
||||||
var _E2 = Math.pow(_E, 2);
|
|
||||||
var _E3 = Math.pow(_E, 3);
|
|
||||||
var _E4 = Math.pow(_E, 4);
|
|
||||||
var _E5 = Math.pow(_E, 5);
|
|
||||||
|
|
||||||
var M1 = 1 - E / 4 - (3 * E2) / 64 - (5 * E3) / 256;
|
|
||||||
var M2 = (3 * E) / 8 + (3 * E2) / 32 + (45 * E3) / 1024;
|
|
||||||
var M3 = (15 * E2) / 256 + (45 * E3) / 1024;
|
|
||||||
var M4 = (35 * E3) / 3072;
|
|
||||||
|
|
||||||
var P2 = (3 / 2) * _E - (27 / 32) * _E3 + (269 / 512) * _E5;
|
|
||||||
var P3 = (21 / 16) * _E2 - (55 / 32) * _E4;
|
|
||||||
var P4 = (151 / 96) * _E3 - (417 / 128) * _E5;
|
|
||||||
var P5 = (1097 / 512) * _E4;
|
|
||||||
|
|
||||||
var R = 6378137;
|
|
||||||
|
|
||||||
var ZONE_LETTERS = "CDEFGHJKLMNPQRSTUVWXX";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* UTM坐标转WGS84坐标
|
|
||||||
* @param easting
|
|
||||||
* @param northing
|
|
||||||
* @param zoneNum utm带号
|
|
||||||
* @param zoneLetter
|
|
||||||
* @param northern
|
|
||||||
* @param strict
|
|
||||||
* @returns
|
|
||||||
*/
|
|
||||||
export function toLatLon(
|
|
||||||
easting: number,
|
|
||||||
northing: number,
|
|
||||||
zoneNum: number,
|
|
||||||
zoneLetter: string | null = null,
|
|
||||||
northern: boolean = true,
|
|
||||||
strict?: boolean
|
|
||||||
) {
|
|
||||||
strict = strict !== undefined ? strict : true;
|
|
||||||
|
|
||||||
if (!zoneLetter && northern === undefined) {
|
|
||||||
throw new Error("either zoneLetter or northern needs to be set");
|
|
||||||
} else if (zoneLetter && northern !== undefined) {
|
|
||||||
throw new Error("set either zoneLetter or northern, but not both");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (strict) {
|
|
||||||
if (easting < 100000 || 1000000 <= easting) {
|
|
||||||
throw new RangeError(
|
|
||||||
"easting out of range (must be between 100 000 m and 999 999 m)"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (northing < 0 || northing > 10000000) {
|
|
||||||
throw new RangeError(
|
|
||||||
"northing out of range (must be between 0 m and 10 000 000 m)"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (zoneNum < 1 || zoneNum > 60) {
|
|
||||||
throw new RangeError("zone number out of range (must be between 1 and 60)");
|
|
||||||
}
|
|
||||||
if (zoneLetter) {
|
|
||||||
zoneLetter = zoneLetter.toUpperCase();
|
|
||||||
if (zoneLetter.length !== 1 || ZONE_LETTERS.indexOf(zoneLetter) === -1) {
|
|
||||||
throw new RangeError(
|
|
||||||
"zone letter out of range (must be between C and X)"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
northern = zoneLetter >= "N";
|
|
||||||
}
|
|
||||||
|
|
||||||
var x = easting - 500000;
|
|
||||||
var y = northing;
|
|
||||||
|
|
||||||
if (!northern) y -= 1e7;
|
|
||||||
|
|
||||||
var m = y / K0;
|
|
||||||
var mu = m / (R * M1);
|
|
||||||
|
|
||||||
var pRad =
|
|
||||||
mu +
|
|
||||||
P2 * Math.sin(2 * mu) +
|
|
||||||
P3 * Math.sin(4 * mu) +
|
|
||||||
P4 * Math.sin(6 * mu) +
|
|
||||||
P5 * Math.sin(8 * mu);
|
|
||||||
|
|
||||||
var pSin = Math.sin(pRad);
|
|
||||||
var pSin2 = Math.pow(pSin, 2);
|
|
||||||
|
|
||||||
var pCos = Math.cos(pRad);
|
|
||||||
|
|
||||||
var pTan = Math.tan(pRad);
|
|
||||||
var pTan2 = Math.pow(pTan, 2);
|
|
||||||
var pTan4 = Math.pow(pTan, 4);
|
|
||||||
|
|
||||||
var epSin = 1 - E * pSin2;
|
|
||||||
var epSinSqrt = Math.sqrt(epSin);
|
|
||||||
|
|
||||||
var n = R / epSinSqrt;
|
|
||||||
var r = (1 - E) / epSin;
|
|
||||||
|
|
||||||
var c = _E * pCos * pCos;
|
|
||||||
var c2 = c * c;
|
|
||||||
|
|
||||||
var d = x / (n * K0);
|
|
||||||
var d2 = Math.pow(d, 2);
|
|
||||||
var d3 = Math.pow(d, 3);
|
|
||||||
var d4 = Math.pow(d, 4);
|
|
||||||
var d5 = Math.pow(d, 5);
|
|
||||||
var d6 = Math.pow(d, 6);
|
|
||||||
|
|
||||||
var latitude =
|
|
||||||
pRad -
|
|
||||||
(pTan / r) *
|
|
||||||
(d2 / 2 - (d4 / 24) * (5 + 3 * pTan2 + 10 * c - 4 * c2 - 9 * E_P2)) +
|
|
||||||
(d6 / 720) * (61 + 90 * pTan2 + 298 * c + 45 * pTan4 - 252 * E_P2 - 3 * c2);
|
|
||||||
var longitude =
|
|
||||||
(d -
|
|
||||||
(d3 / 6) * (1 + 2 * pTan2 + c) +
|
|
||||||
(d5 / 120) * (5 - 2 * c + 28 * pTan2 - 3 * c2 + 8 * E_P2 + 24 * pTan4)) /
|
|
||||||
pCos;
|
|
||||||
|
|
||||||
return {
|
|
||||||
latitude: toDegrees(latitude),
|
|
||||||
longitude: toDegrees(longitude) + zoneNumberToCentralLongitude(zoneNum),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* WGS84坐标转UTM坐标
|
|
||||||
* @param latitude
|
|
||||||
* @param longitude
|
|
||||||
* @param forceZoneNum
|
|
||||||
* @returns
|
|
||||||
*/
|
|
||||||
export function fromLatLon(
|
|
||||||
latitude: number,
|
|
||||||
longitude: number,
|
|
||||||
forceZoneNum?: number
|
|
||||||
) {
|
|
||||||
if (latitude > 84 || latitude < -80) {
|
|
||||||
throw new RangeError(
|
|
||||||
"latitude out of range (must be between 80 deg S and 84 deg N)"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (longitude > 180 || longitude < -180) {
|
|
||||||
throw new RangeError(
|
|
||||||
"longitude out of range (must be between 180 deg W and 180 deg E)"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
var latRad = toRadians(latitude);
|
|
||||||
var latSin = Math.sin(latRad);
|
|
||||||
var latCos = Math.cos(latRad);
|
|
||||||
|
|
||||||
var latTan = Math.tan(latRad);
|
|
||||||
var latTan2 = Math.pow(latTan, 2);
|
|
||||||
var latTan4 = Math.pow(latTan, 4);
|
|
||||||
|
|
||||||
var zoneNum: number;
|
|
||||||
|
|
||||||
if (forceZoneNum === undefined) {
|
|
||||||
zoneNum = latLonToZoneNumber(latitude, longitude);
|
|
||||||
} else {
|
|
||||||
zoneNum = forceZoneNum;
|
|
||||||
}
|
|
||||||
|
|
||||||
var zoneLetter = latitudeToZoneLetter(latitude);
|
|
||||||
|
|
||||||
var lonRad = toRadians(longitude);
|
|
||||||
var centralLon = zoneNumberToCentralLongitude(zoneNum);
|
|
||||||
var centralLonRad = toRadians(centralLon);
|
|
||||||
|
|
||||||
var n = R / Math.sqrt(1 - E * latSin * latSin);
|
|
||||||
var c = E_P2 * latCos * latCos;
|
|
||||||
|
|
||||||
var a = latCos * (lonRad - centralLonRad);
|
|
||||||
var a2 = Math.pow(a, 2);
|
|
||||||
var a3 = Math.pow(a, 3);
|
|
||||||
var a4 = Math.pow(a, 4);
|
|
||||||
var a5 = Math.pow(a, 5);
|
|
||||||
var a6 = Math.pow(a, 6);
|
|
||||||
|
|
||||||
var m =
|
|
||||||
R *
|
|
||||||
(M1 * latRad -
|
|
||||||
M2 * Math.sin(2 * latRad) +
|
|
||||||
M3 * Math.sin(4 * latRad) -
|
|
||||||
M4 * Math.sin(6 * latRad));
|
|
||||||
var easting =
|
|
||||||
K0 *
|
|
||||||
n *
|
|
||||||
(a +
|
|
||||||
(a3 / 6) * (1 - latTan2 + c) +
|
|
||||||
(a5 / 120) * (5 - 18 * latTan2 + latTan4 + 72 * c - 58 * E_P2)) +
|
|
||||||
500000;
|
|
||||||
var northing =
|
|
||||||
K0 *
|
|
||||||
(m +
|
|
||||||
n *
|
|
||||||
latTan *
|
|
||||||
(a2 / 2 +
|
|
||||||
(a4 / 24) * (5 - latTan2 + 9 * c + 4 * c * c) +
|
|
||||||
(a6 / 720) * (61 - 58 * latTan2 + latTan4 + 600 * c - 330 * E_P2)));
|
|
||||||
if (latitude < 0) northing += 1e7;
|
|
||||||
|
|
||||||
return {
|
|
||||||
easting: easting,
|
|
||||||
northing: northing,
|
|
||||||
zoneNum: zoneNum,
|
|
||||||
zoneLetter: zoneLetter,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function latitudeToZoneLetter(latitude: number) {
|
|
||||||
if (-80 <= latitude && latitude <= 84) {
|
|
||||||
return ZONE_LETTERS[Math.floor((latitude + 80) / 8)];
|
|
||||||
} else {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function latLonToZoneNumber(latitude: number, longitude: number) {
|
|
||||||
if (56 <= latitude && latitude < 64 && 3 <= longitude && longitude < 12)
|
|
||||||
return 32;
|
|
||||||
|
|
||||||
if (72 <= latitude && latitude <= 84 && longitude >= 0) {
|
|
||||||
if (longitude < 9) return 31;
|
|
||||||
if (longitude < 21) return 33;
|
|
||||||
if (longitude < 33) return 35;
|
|
||||||
if (longitude < 42) return 37;
|
|
||||||
}
|
|
||||||
|
|
||||||
return Math.floor((longitude + 180) / 6) + 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
function zoneNumberToCentralLongitude(zoneNum: number) {
|
|
||||||
return (zoneNum - 1) * 6 - 180 + 3;
|
|
||||||
}
|
|
||||||
|
|
||||||
function toDegrees(rad: number) {
|
|
||||||
return (rad / Math.PI) * 180;
|
|
||||||
}
|
|
||||||
|
|
||||||
function toRadians(deg: number) {
|
|
||||||
return (deg * Math.PI) / 180;
|
|
||||||
}
|
|
||||||
|
|
@ -5,14 +5,16 @@
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"orientation": "portrait",
|
"orientation": "portrait",
|
||||||
"icon": "./assets/images/icon.png",
|
"icon": "./assets/images/icon.png",
|
||||||
"scheme": "myapp",
|
"scheme": "beerbuddy",
|
||||||
"userInterfaceStyle": "automatic",
|
"userInterfaceStyle": "automatic",
|
||||||
"splash": {
|
"splash": {
|
||||||
"image": "./assets/images/splash.png",
|
"image": "./assets/images/splash.png",
|
||||||
"resizeMode": "contain",
|
"resizeMode": "contain",
|
||||||
"backgroundColor": "#000000"
|
"backgroundColor": "#000000"
|
||||||
},
|
},
|
||||||
"assetBundlePatterns": ["**/*"],
|
"assetBundlePatterns": [
|
||||||
|
"**/*"
|
||||||
|
],
|
||||||
"ios": {
|
"ios": {
|
||||||
"supportsTablet": true
|
"supportsTablet": true
|
||||||
},
|
},
|
||||||
|
|
@ -20,7 +22,14 @@
|
||||||
"adaptiveIcon": {
|
"adaptiveIcon": {
|
||||||
"foregroundImage": "./assets/images/adaptive-icon.png",
|
"foregroundImage": "./assets/images/adaptive-icon.png",
|
||||||
"backgroundColor": "#000000"
|
"backgroundColor": "#000000"
|
||||||
}
|
},
|
||||||
|
"permissions": [
|
||||||
|
"android.permission.ACCESS_COARSE_LOCATION",
|
||||||
|
"android.permission.ACCESS_FINE_LOCATION",
|
||||||
|
"android.permission.FOREGROUND_SERVICE",
|
||||||
|
"android.permission.RECORD_AUDIO"
|
||||||
|
],
|
||||||
|
"package": "com.oneflux.beerbuddy"
|
||||||
},
|
},
|
||||||
"web": {
|
"web": {
|
||||||
"bundler": "metro",
|
"bundler": "metro",
|
||||||
|
|
@ -28,16 +37,30 @@
|
||||||
"favicon": "./assets/images/favicon.png"
|
"favicon": "./assets/images/favicon.png"
|
||||||
},
|
},
|
||||||
"plugins": [
|
"plugins": [
|
||||||
|
"expo-router",
|
||||||
[
|
[
|
||||||
"expo-location",
|
"expo-location",
|
||||||
{
|
{
|
||||||
"locationAlwaysAndWhenInUsePermission": "Allow $(PRODUCT_NAME) to use your location."
|
"locationAlwaysAndWhenInUsePermission": "Allow $(PRODUCT_NAME) to use your location."
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"expo-router"
|
[
|
||||||
|
"expo-image-picker",
|
||||||
|
{
|
||||||
|
"photosPermission": "The app accesses your photos to let you share them with your friends."
|
||||||
|
}
|
||||||
|
]
|
||||||
],
|
],
|
||||||
"experiments": {
|
"experiments": {
|
||||||
"typedRoutes": true
|
"typedRoutes": true
|
||||||
|
},
|
||||||
|
"extra": {
|
||||||
|
"router": {
|
||||||
|
"origin": false
|
||||||
|
},
|
||||||
|
"eas": {
|
||||||
|
"projectId": "4eeadfda-9d8d-4579-bbc0-9521dfaf69b3"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,55 +0,0 @@
|
||||||
import FontAwesome from '@expo/vector-icons/FontAwesome';
|
|
||||||
import { Link, Tabs } from 'expo-router';
|
|
||||||
import { Pressable, useColorScheme } from 'react-native';
|
|
||||||
|
|
||||||
import Colors from '../../constants/Colors';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* You can explore the built-in icon families and icons on the web at https://icons.expo.fyi/
|
|
||||||
*/
|
|
||||||
function TabBarIcon(props: {
|
|
||||||
name: React.ComponentProps<typeof FontAwesome>['name'];
|
|
||||||
color: string;
|
|
||||||
}) {
|
|
||||||
return <FontAwesome size={28} style={{ marginBottom: -3 }} {...props} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function TabLayout() {
|
|
||||||
const colorScheme = useColorScheme();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Tabs
|
|
||||||
screenOptions={{
|
|
||||||
tabBarActiveTintColor: Colors[colorScheme ?? 'light'].tint,
|
|
||||||
}}>
|
|
||||||
<Tabs.Screen
|
|
||||||
name="index"
|
|
||||||
options={{
|
|
||||||
title: 'Tab One',
|
|
||||||
tabBarIcon: ({ color }) => <TabBarIcon name="code" color={color} />,
|
|
||||||
headerRight: () => (
|
|
||||||
<Link href="/modal" asChild>
|
|
||||||
<Pressable>
|
|
||||||
{({ pressed }) => (
|
|
||||||
<FontAwesome
|
|
||||||
name="info-circle"
|
|
||||||
size={25}
|
|
||||||
color={Colors[colorScheme ?? 'light'].text}
|
|
||||||
style={{ marginRight: 15, opacity: pressed ? 0.5 : 1 }}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</Pressable>
|
|
||||||
</Link>
|
|
||||||
),
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Tabs.Screen
|
|
||||||
name="two"
|
|
||||||
options={{
|
|
||||||
title: 'Tab Two',
|
|
||||||
tabBarIcon: ({ color }) => <TabBarIcon name="code" color={color} />,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Tabs>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,31 +0,0 @@
|
||||||
import { StyleSheet } from 'react-native';
|
|
||||||
|
|
||||||
import EditScreenInfo from '../../components/EditScreenInfo';
|
|
||||||
import { Text, View } from '../../components/Themed';
|
|
||||||
|
|
||||||
export default function TabOneScreen() {
|
|
||||||
return (
|
|
||||||
<View style={styles.container}>
|
|
||||||
<Text style={styles.title}>Tab One</Text>
|
|
||||||
<View style={styles.separator} lightColor="#eee" darkColor="rgba(255,255,255,0.1)" />
|
|
||||||
<EditScreenInfo path="app/(tabs)/index.tsx" />
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
|
||||||
container: {
|
|
||||||
flex: 1,
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
},
|
|
||||||
title: {
|
|
||||||
fontSize: 20,
|
|
||||||
fontWeight: 'bold',
|
|
||||||
},
|
|
||||||
separator: {
|
|
||||||
marginVertical: 30,
|
|
||||||
height: 1,
|
|
||||||
width: '80%',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
@ -1,31 +0,0 @@
|
||||||
import { StyleSheet } from 'react-native';
|
|
||||||
|
|
||||||
import EditScreenInfo from '../../components/EditScreenInfo';
|
|
||||||
import { Text, View } from '../../components/Themed';
|
|
||||||
|
|
||||||
export default function TabTwoScreen() {
|
|
||||||
return (
|
|
||||||
<View style={styles.container}>
|
|
||||||
<Text style={styles.title}>Tab Two</Text>
|
|
||||||
<View style={styles.separator} lightColor="#eee" darkColor="rgba(255,255,255,0.1)" />
|
|
||||||
<EditScreenInfo path="app/(tabs)/two.tsx" />
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
|
||||||
container: {
|
|
||||||
flex: 1,
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
},
|
|
||||||
title: {
|
|
||||||
fontSize: 20,
|
|
||||||
fontWeight: 'bold',
|
|
||||||
},
|
|
||||||
separator: {
|
|
||||||
marginVertical: 30,
|
|
||||||
height: 1,
|
|
||||||
width: '80%',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
@ -1,40 +0,0 @@
|
||||||
import { Link, Stack } from 'expo-router';
|
|
||||||
import { StyleSheet } from 'react-native';
|
|
||||||
|
|
||||||
import { Text, View } from '../components/Themed';
|
|
||||||
|
|
||||||
export default function NotFoundScreen() {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Stack.Screen options={{ title: 'Oops!' }} />
|
|
||||||
<View style={styles.container}>
|
|
||||||
<Text style={styles.title}>This screen doesn't exist.</Text>
|
|
||||||
|
|
||||||
<Link href="/" style={styles.link}>
|
|
||||||
<Text style={styles.linkText}>Go to home screen!</Text>
|
|
||||||
</Link>
|
|
||||||
</View>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
|
||||||
container: {
|
|
||||||
flex: 1,
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
padding: 20,
|
|
||||||
},
|
|
||||||
title: {
|
|
||||||
fontSize: 20,
|
|
||||||
fontWeight: 'bold',
|
|
||||||
},
|
|
||||||
link: {
|
|
||||||
marginTop: 15,
|
|
||||||
paddingVertical: 15,
|
|
||||||
},
|
|
||||||
linkText: {
|
|
||||||
fontSize: 14,
|
|
||||||
color: '#2e78b7',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
@ -1,56 +1,21 @@
|
||||||
import FontAwesome from '@expo/vector-icons/FontAwesome';
|
import {
|
||||||
import { DarkTheme, DefaultTheme, ThemeProvider } from '@react-navigation/native';
|
DarkTheme,
|
||||||
import { useFonts } from 'expo-font';
|
DefaultTheme,
|
||||||
import { SplashScreen, Stack } from 'expo-router';
|
ThemeProvider,
|
||||||
import { useEffect } from 'react';
|
} from "@react-navigation/native";
|
||||||
import { useColorScheme } from 'react-native';
|
import { Stack } from "expo-router";
|
||||||
|
import { useColorScheme } from "react-native";
|
||||||
|
|
||||||
export {
|
export {
|
||||||
// Catch any errors thrown by the Layout component.
|
// Catch any errors thrown by the Layout component.
|
||||||
ErrorBoundary,
|
ErrorBoundary,
|
||||||
} from 'expo-router';
|
} from "expo-router";
|
||||||
|
|
||||||
export const unstable_settings = {
|
export default function layout() {
|
||||||
// Ensure that reloading on `/modal` keeps a back button present.
|
|
||||||
initialRouteName: '(tabs)',
|
|
||||||
};
|
|
||||||
|
|
||||||
// Prevent the splash screen from auto-hiding before asset loading is complete.
|
|
||||||
SplashScreen.preventAutoHideAsync();
|
|
||||||
|
|
||||||
export default function RootLayout() {
|
|
||||||
const [loaded, error] = useFonts({
|
|
||||||
SpaceMono: require('../assets/fonts/SpaceMono-Regular.ttf'),
|
|
||||||
...FontAwesome.font,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Expo Router uses Error Boundaries to catch errors in the navigation tree.
|
|
||||||
useEffect(() => {
|
|
||||||
if (error) throw error;
|
|
||||||
}, [error]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (loaded) {
|
|
||||||
SplashScreen.hideAsync();
|
|
||||||
}
|
|
||||||
}, [loaded]);
|
|
||||||
|
|
||||||
if (!loaded) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return <RootLayoutNav />;
|
|
||||||
}
|
|
||||||
|
|
||||||
function RootLayoutNav() {
|
|
||||||
const colorScheme = useColorScheme();
|
const colorScheme = useColorScheme();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ThemeProvider value={colorScheme === 'dark' ? DarkTheme : DefaultTheme}>
|
<ThemeProvider value={colorScheme === "dark" ? DarkTheme : DefaultTheme}>
|
||||||
<Stack>
|
<Stack />
|
||||||
<Stack.Screen name="(tabs)" options={{ headerShown: false }} />
|
|
||||||
<Stack.Screen name="modal" options={{ presentation: 'modal' }} />
|
|
||||||
</Stack>
|
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
139
react-native/app/index.tsx
Normal file
139
react-native/app/index.tsx
Normal file
|
|
@ -0,0 +1,139 @@
|
||||||
|
import { useState, useRef, useEffect } from "react";
|
||||||
|
import { useWindowDimensions, Alert, StyleSheet } from "react-native";
|
||||||
|
import { SafeAreaProvider, SafeAreaView } from "react-native-safe-area-context";
|
||||||
|
import WebView, { WebViewMessageEvent } from "react-native-webview";
|
||||||
|
import { useAssets } from "expo-asset";
|
||||||
|
import * as Location from "expo-location";
|
||||||
|
import { useNavigation, router } from "expo-router";
|
||||||
|
|
||||||
|
export default function () {
|
||||||
|
const [assets] = useAssets([require("../assets/index.html")]);
|
||||||
|
const [htmlString, setHtmlString] = useState<string>();
|
||||||
|
const dimensions = useWindowDimensions();
|
||||||
|
const webViewRef = useRef<WebView | null>();
|
||||||
|
const navigation = useNavigation();
|
||||||
|
|
||||||
|
const messageHandler = (event: WebViewMessageEvent) => {
|
||||||
|
if (typeof event.nativeEvent.data == "string") {
|
||||||
|
const message = event.nativeEvent.data;
|
||||||
|
if (message === "new pin start") {
|
||||||
|
Alert.alert(
|
||||||
|
"New Pin",
|
||||||
|
`Please select location for new pin then press "OK"`,
|
||||||
|
[
|
||||||
|
{
|
||||||
|
text: "OK",
|
||||||
|
onPress: () => {
|
||||||
|
// makes injectable javascript string
|
||||||
|
const str = `window.placePin(); true()`;
|
||||||
|
// passes string to webview - there it is handled by OpenLayers to change the current location
|
||||||
|
webViewRef.current?.injectJavaScript(str);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
);
|
||||||
|
} else if (message.startsWith(`create@`)) {
|
||||||
|
const coords = message.slice(7).split(",");
|
||||||
|
router.push({
|
||||||
|
pathname: "./store/new/[coords]",
|
||||||
|
params: {
|
||||||
|
coords: `${coords[0]}+${coords[1]}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} else if (message.startsWith(`open@`)) {
|
||||||
|
const coords = message.slice(5).split(",");
|
||||||
|
router.push({
|
||||||
|
pathname: "./store/[coords]",
|
||||||
|
params: {
|
||||||
|
coords: `${coords[0]}+${coords[1]}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} else if (message.startsWith(`search@`)) {
|
||||||
|
const chunks = message.slice(7).split(":");
|
||||||
|
const coords = chunks[0].split(",");
|
||||||
|
router.push({
|
||||||
|
pathname: "./search/[slug]",
|
||||||
|
params: {
|
||||||
|
slug: `${coords[0]}+${coords[1]}+${chunks[1]}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getLocation = async () => {
|
||||||
|
const { status } = await Location.requestForegroundPermissionsAsync();
|
||||||
|
if (status !== "granted") return;
|
||||||
|
const location = await Location.getCurrentPositionAsync({
|
||||||
|
distanceInterval: 0, // for IOS
|
||||||
|
accuracy: Location.Accuracy.High,
|
||||||
|
timeInterval: 3000, // for Android
|
||||||
|
});
|
||||||
|
// makes injectable javascript string
|
||||||
|
const str = `window.passLocation(${location.coords.longitude}, ${location.coords.latitude}); true()`;
|
||||||
|
// passes string to webview - there it is handled by OpenLayers to change the current location
|
||||||
|
webViewRef.current?.injectJavaScript(str);
|
||||||
|
};
|
||||||
|
|
||||||
|
// refresh on navigating back to this page
|
||||||
|
useEffect(() => {
|
||||||
|
// remove header
|
||||||
|
navigation.setOptions({ headerShown: false });
|
||||||
|
const focusHandler = navigation.addListener("focus", () => {
|
||||||
|
webViewRef.current?.reload();
|
||||||
|
getLocation();
|
||||||
|
});
|
||||||
|
return focusHandler;
|
||||||
|
}, [navigation]);
|
||||||
|
|
||||||
|
// loads assets
|
||||||
|
useEffect(() => {
|
||||||
|
if (assets) {
|
||||||
|
fetch(assets[0].localUri || "")
|
||||||
|
.then((res) => res.text())
|
||||||
|
.then((html) => setHtmlString(html));
|
||||||
|
}
|
||||||
|
}, [assets]);
|
||||||
|
|
||||||
|
// exits if no map passed in
|
||||||
|
if (!htmlString) {
|
||||||
|
return <></>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
flex: 1,
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SafeAreaProvider>
|
||||||
|
<SafeAreaView style={styles.container}>
|
||||||
|
<WebView
|
||||||
|
ref={(currentRef) => (webViewRef.current = currentRef)}
|
||||||
|
injectedJavascript=""
|
||||||
|
source={{
|
||||||
|
html: htmlString,
|
||||||
|
}}
|
||||||
|
javaScriptEnabled
|
||||||
|
style={{
|
||||||
|
width: dimensions.width,
|
||||||
|
height: dimensions.height,
|
||||||
|
}}
|
||||||
|
scrollEnabled={false}
|
||||||
|
overScrollMode="never"
|
||||||
|
showsVerticalScrollIndicator={false}
|
||||||
|
showsHorizontalScrollIndicator={false}
|
||||||
|
scalesPageToFit={false}
|
||||||
|
containerStyle={{
|
||||||
|
flex: 1,
|
||||||
|
}}
|
||||||
|
onMessage={messageHandler}
|
||||||
|
webviewDebuggingEnabled={true}
|
||||||
|
/>
|
||||||
|
</SafeAreaView>
|
||||||
|
</SafeAreaProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
191
react-native/app/item/new/[storeKey].tsx
Normal file
191
react-native/app/item/new/[storeKey].tsx
Normal file
|
|
@ -0,0 +1,191 @@
|
||||||
|
import React, { useState } from "react";
|
||||||
|
import {
|
||||||
|
TouchableOpacity,
|
||||||
|
Text,
|
||||||
|
Image,
|
||||||
|
View,
|
||||||
|
ScrollView,
|
||||||
|
TextInput,
|
||||||
|
Appearance,
|
||||||
|
Alert,
|
||||||
|
} from "react-native";
|
||||||
|
import { Stack, useLocalSearchParams, router } from "expo-router";
|
||||||
|
import { styled } from "nativewind";
|
||||||
|
|
||||||
|
declare function isNaN(x: string | number): boolean;
|
||||||
|
|
||||||
|
export default function () {
|
||||||
|
const [itemName, onitemName] = useState("");
|
||||||
|
const [itemVolume, onitemVolume] = useState("");
|
||||||
|
const [itemPrice, onitemPrice] = useState("");
|
||||||
|
|
||||||
|
const { storeKey } = useLocalSearchParams();
|
||||||
|
|
||||||
|
const StyledText = styled(Text);
|
||||||
|
|
||||||
|
const handleSubmit = (price: string, vol: string, name: string) => {
|
||||||
|
if (price === "" || vol === "" || name === "") {
|
||||||
|
Alert.alert(
|
||||||
|
"Insufficient information!",
|
||||||
|
"Please double-check to ensure all fields have been filled"
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// send data to flask
|
||||||
|
fetch(
|
||||||
|
`${process.env.EXPO_PUBLIC_BACKEND_URL}/?` +
|
||||||
|
new URLSearchParams({
|
||||||
|
storeKey: storeKey.toString(),
|
||||||
|
itemName: name,
|
||||||
|
itemPrice: price,
|
||||||
|
itemVolume: vol,
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
mode: "cors",
|
||||||
|
redirect: "follow",
|
||||||
|
}
|
||||||
|
).then((res) => {
|
||||||
|
if (res.ok) {
|
||||||
|
Alert.alert(
|
||||||
|
"Success!",
|
||||||
|
"Item has been successfully added. Thank you!",
|
||||||
|
[
|
||||||
|
{
|
||||||
|
text: "OK",
|
||||||
|
onPress: () => {
|
||||||
|
router.back();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
switch (res.status) {
|
||||||
|
case 400:
|
||||||
|
Alert.alert(
|
||||||
|
"Error!",
|
||||||
|
"Please double-check all fields are filled in."
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 409:
|
||||||
|
Alert.alert(
|
||||||
|
"Error!",
|
||||||
|
"Duplicate of existing item. Please go to that item to edit its price.",
|
||||||
|
[
|
||||||
|
{
|
||||||
|
text: "OK",
|
||||||
|
onPress: () => {
|
||||||
|
router.push("/");
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 500:
|
||||||
|
Alert.alert(
|
||||||
|
"Error!",
|
||||||
|
"Backend server error. Please report to ak95@riseup.net"
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
Alert.alert(
|
||||||
|
"Error!",
|
||||||
|
"Unspecified error. Please report to ak95@riseup.net"
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ScrollView contentContainerStyle={{ flexGrow: 1 }}>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
alignItems: "center",
|
||||||
|
height: "100%",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Stack.Screen
|
||||||
|
options={{
|
||||||
|
title: "New Item",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
justifyContent: "space-between",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<TextInput
|
||||||
|
className="w-[90vw] mt-4 h-12 text-xl border rounded text-center dark:border-white dark:text-white"
|
||||||
|
onChangeText={onitemName}
|
||||||
|
placeholder="Item Name"
|
||||||
|
placeholderTextColor={
|
||||||
|
Appearance.getColorScheme() === "dark" ? "#fff" : "#000"
|
||||||
|
}
|
||||||
|
value={itemName}
|
||||||
|
/>
|
||||||
|
<TextInput
|
||||||
|
className="w-[90vw] mt-4 h-12 text-xl border rounded text-center dark:border-white dark:text-white"
|
||||||
|
onChangeText={(text) => {
|
||||||
|
const split = text.split("");
|
||||||
|
let numeric = true;
|
||||||
|
split.forEach((char: string | number) => {
|
||||||
|
if (isNaN(char)) {
|
||||||
|
numeric = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (numeric) onitemVolume(text);
|
||||||
|
}}
|
||||||
|
placeholder="Item Volume (fl. oz.)"
|
||||||
|
placeholderTextColor={
|
||||||
|
Appearance.getColorScheme() === "dark" ? "#fff" : "#000"
|
||||||
|
}
|
||||||
|
inputMode="numeric"
|
||||||
|
value={itemVolume}
|
||||||
|
/>
|
||||||
|
<TextInput
|
||||||
|
className="w-[90vw] mt-4 h-12 text-xl border rounded text-center dark:border-white dark:text-white"
|
||||||
|
onChangeText={(text) => {
|
||||||
|
text = text.slice(2);
|
||||||
|
if (text.includes(".")) {
|
||||||
|
const split = text.split(".");
|
||||||
|
if (split.length < 3) {
|
||||||
|
const righthand = split[1];
|
||||||
|
if (righthand.length <= 2) {
|
||||||
|
onitemPrice(text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else onitemPrice(text);
|
||||||
|
}}
|
||||||
|
placeholder="Item Price"
|
||||||
|
inputMode="decimal"
|
||||||
|
placeholderTextColor={
|
||||||
|
Appearance.getColorScheme() === "dark" ? "#fff" : "#000"
|
||||||
|
}
|
||||||
|
value={`$ ${itemPrice}`}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
<TouchableOpacity
|
||||||
|
className="px-8 py-4 my-4 bg-green-700 rounded"
|
||||||
|
onPress={() => handleSubmit(itemPrice, itemVolume, itemName)}
|
||||||
|
>
|
||||||
|
<StyledText className="text-white text-center text-2xl">
|
||||||
|
Submit
|
||||||
|
</StyledText>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,35 +0,0 @@
|
||||||
import { StatusBar } from 'expo-status-bar';
|
|
||||||
import { Platform, StyleSheet } from 'react-native';
|
|
||||||
|
|
||||||
import EditScreenInfo from '../components/EditScreenInfo';
|
|
||||||
import { Text, View } from '../components/Themed';
|
|
||||||
|
|
||||||
export default function ModalScreen() {
|
|
||||||
return (
|
|
||||||
<View style={styles.container}>
|
|
||||||
<Text style={styles.title}>Modal</Text>
|
|
||||||
<View style={styles.separator} lightColor="#eee" darkColor="rgba(255,255,255,0.1)" />
|
|
||||||
<EditScreenInfo path="app/modal.tsx" />
|
|
||||||
|
|
||||||
{/* Use a light status bar on iOS to account for the black space above the modal */}
|
|
||||||
<StatusBar style={Platform.OS === 'ios' ? 'light' : 'auto'} />
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
|
||||||
container: {
|
|
||||||
flex: 1,
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
},
|
|
||||||
title: {
|
|
||||||
fontSize: 20,
|
|
||||||
fontWeight: 'bold',
|
|
||||||
},
|
|
||||||
separator: {
|
|
||||||
marginVertical: 30,
|
|
||||||
height: 1,
|
|
||||||
width: '80%',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
180
react-native/app/search/[slug].tsx
Normal file
180
react-native/app/search/[slug].tsx
Normal file
|
|
@ -0,0 +1,180 @@
|
||||||
|
import { useEffect, useState, useRef } from "react";
|
||||||
|
import { Text, View, ScrollView, Alert } from "react-native";
|
||||||
|
|
||||||
|
import { Picker } from "@react-native-picker/picker";
|
||||||
|
|
||||||
|
import { Stack, useLocalSearchParams } from "expo-router";
|
||||||
|
|
||||||
|
import { AntDesign } from "@expo/vector-icons";
|
||||||
|
|
||||||
|
import { styled } from "nativewind";
|
||||||
|
|
||||||
|
import utmObj from "utm-latlng";
|
||||||
|
|
||||||
|
import QueryItem from "../../components/QueryItem";
|
||||||
|
|
||||||
|
declare function isNaN(x: string | number): boolean;
|
||||||
|
|
||||||
|
interface itemInterface {
|
||||||
|
key: String;
|
||||||
|
lastUpdated: String;
|
||||||
|
name: String;
|
||||||
|
perFloz: Number;
|
||||||
|
price: Number;
|
||||||
|
store: String;
|
||||||
|
volumeFloz: Number;
|
||||||
|
distance: Number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function () {
|
||||||
|
const [items, setItems] = useState([]);
|
||||||
|
const [queryType, setQueryType] = useState("");
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
const utm = new utmObj();
|
||||||
|
|
||||||
|
const StyledPicker = styled(Picker);
|
||||||
|
const StyledText = styled(Text);
|
||||||
|
const StyledIcon = styled(AntDesign);
|
||||||
|
|
||||||
|
const slug = useLocalSearchParams().slug.toString().split("%2B");
|
||||||
|
const lon = slug[0];
|
||||||
|
const lat = slug[1];
|
||||||
|
const utmCoords = utm.convertLatLngToUtm(lat, lon, 5);
|
||||||
|
const query = useRef(slug[2]);
|
||||||
|
|
||||||
|
// get store information
|
||||||
|
const getItems = async (sortBy: string) => {
|
||||||
|
setLoading(true);
|
||||||
|
const res = await fetch(
|
||||||
|
`${process.env.EXPO_PUBLIC_BACKEND_URL}/search?` +
|
||||||
|
new URLSearchParams({
|
||||||
|
easting: utmCoords.Easting,
|
||||||
|
northing: utmCoords.Northing,
|
||||||
|
query: query.current,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
if (!res.ok) {
|
||||||
|
if (res.status === 404) {
|
||||||
|
const notFound = (
|
||||||
|
<View
|
||||||
|
className="flex flex-row w-[100vw] py-4 justify-center items-center dark:text-white"
|
||||||
|
style={{ flex: 1 }}
|
||||||
|
>
|
||||||
|
<StyledText className="text-xl dark:text-white">
|
||||||
|
No results for "{slug[2]}"
|
||||||
|
</StyledText>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
setItems(notFound);
|
||||||
|
return setLoading(false);
|
||||||
|
} else
|
||||||
|
return Alert.alert(
|
||||||
|
"Error!",
|
||||||
|
"Server error. Please report to ak95@riseup.net"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const json = await res.json();
|
||||||
|
switch (sortBy) {
|
||||||
|
case "Distance":
|
||||||
|
json.sort((a: itemInterface, b: itemInterface) => {
|
||||||
|
return Number(a.distance) - Number(b.distance);
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
case "Price":
|
||||||
|
json.sort((a: itemInterface, b: itemInterface) => {
|
||||||
|
return Number(a.price) - Number(b.price);
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
case "Price per Fluid Ounce":
|
||||||
|
json.sort((a: itemInterface, b: itemInterface) => {
|
||||||
|
return Number(a.perFloz) - Number(b.perFloz);
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
case "Last Updated":
|
||||||
|
json.sort((a: itemInterface, b: itemInterface) => {
|
||||||
|
const aDate = new Date(a.lastUpdated.replace(" ", "T") + "Z");
|
||||||
|
const bDate = new Date(b.lastUpdated.replace(" ", "T") + "Z");
|
||||||
|
if (aDate < bDate) return -1;
|
||||||
|
else if (aDate > bDate) return 1;
|
||||||
|
return 0;
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
const jsonItems = json.map((item: itemInterface, index: number) => {
|
||||||
|
const date = new Date(item.lastUpdated.replace(" ", "T") + "Z");
|
||||||
|
return (
|
||||||
|
<View className="flex flex-row w-[100vw]" key={index}>
|
||||||
|
<QueryItem
|
||||||
|
name={item.name.toString()}
|
||||||
|
volume={item.volumeFloz.toString()}
|
||||||
|
price={item.price.toString()}
|
||||||
|
date={date}
|
||||||
|
storeKey={item.store.toString()}
|
||||||
|
distance={item.distance}
|
||||||
|
perFloz={item.perFloz}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
setItems(jsonItems);
|
||||||
|
setLoading(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
getItems(queryType);
|
||||||
|
}, [queryType]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ScrollView contentContainerStyle={{ flexGrow: 1 }}>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
alignItems: "center",
|
||||||
|
height: "100%",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Stack.Screen
|
||||||
|
options={{
|
||||||
|
title: `Results for "${query.current}"`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<ScrollView style={{ flex: 1 }}>
|
||||||
|
<View
|
||||||
|
className={
|
||||||
|
loading
|
||||||
|
? "border-b dark:border-white flex-1 opacity-0"
|
||||||
|
: "border-b dark:border-white flex-1"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<View className="flex absolute h-full w-full">
|
||||||
|
<View className="flex flex-row flex-1 justify-end items-center gap-1 px-2">
|
||||||
|
<StyledText className="text-lg dark:text-white">
|
||||||
|
Sort by {queryType}
|
||||||
|
</StyledText>
|
||||||
|
<StyledIcon name="down" className="text-lg dark:text-white" />
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
<StyledPicker
|
||||||
|
selectedValue={queryType}
|
||||||
|
onValueChange={(value, index: number) => {
|
||||||
|
setQueryType(value);
|
||||||
|
}}
|
||||||
|
className="opacity-0 bg-red-700"
|
||||||
|
>
|
||||||
|
<Picker.Item label="Distance" value="Distance" />
|
||||||
|
<Picker.Item label="Price" value="Price" />
|
||||||
|
<Picker.Item
|
||||||
|
label="Price per Fluid Ounce"
|
||||||
|
value="Price per Fluid Ounce"
|
||||||
|
/>
|
||||||
|
<Picker.Item label="Last Updated" value="Last Updated" />
|
||||||
|
</StyledPicker>
|
||||||
|
</View>
|
||||||
|
<View className={loading && "opacity-0"}>{items}</View>
|
||||||
|
</ScrollView>
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
);
|
||||||
|
}
|
||||||
199
react-native/app/store/[coords].tsx
Normal file
199
react-native/app/store/[coords].tsx
Normal file
|
|
@ -0,0 +1,199 @@
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import {
|
||||||
|
TouchableOpacity,
|
||||||
|
Image,
|
||||||
|
Text,
|
||||||
|
View,
|
||||||
|
ScrollView,
|
||||||
|
Alert,
|
||||||
|
} from "react-native";
|
||||||
|
|
||||||
|
import {
|
||||||
|
useNavigation,
|
||||||
|
Stack,
|
||||||
|
useLocalSearchParams,
|
||||||
|
router,
|
||||||
|
} from "expo-router";
|
||||||
|
import * as ImagePicker from "expo-image-picker";
|
||||||
|
import * as FileSystem from "expo-file-system";
|
||||||
|
|
||||||
|
import { styled } from "nativewind";
|
||||||
|
|
||||||
|
import utmObj from "utm-latlng";
|
||||||
|
import mime from "mime";
|
||||||
|
|
||||||
|
import StoreItem from "../../components/StoreItem";
|
||||||
|
|
||||||
|
declare function isNaN(x: string | number): boolean;
|
||||||
|
|
||||||
|
interface storeItemInterface {
|
||||||
|
key: String;
|
||||||
|
lastUpdated: String;
|
||||||
|
name: String;
|
||||||
|
perFloz: Number;
|
||||||
|
price: Number;
|
||||||
|
store: String;
|
||||||
|
volumeFloz: Number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function () {
|
||||||
|
const [storeKey, setStoreKey] = useState("");
|
||||||
|
const [storeName, setStoreName] = useState("");
|
||||||
|
const [storeItems, setStoreItems] = useState([]);
|
||||||
|
const [image, setImage] = useState(null);
|
||||||
|
|
||||||
|
const navigation = useNavigation();
|
||||||
|
const utm = new utmObj();
|
||||||
|
|
||||||
|
const StyledText = styled(Text);
|
||||||
|
|
||||||
|
const coords = useLocalSearchParams().coords.toString().split("%2B");
|
||||||
|
const lon = coords[0];
|
||||||
|
const lat = coords[1];
|
||||||
|
const utmCoords = utm.convertLatLngToUtm(lat, lon, 5);
|
||||||
|
|
||||||
|
// get store information
|
||||||
|
const getStore = async () => {
|
||||||
|
const res = await fetch(
|
||||||
|
`${process.env.EXPO_PUBLIC_BACKEND_URL}/?` +
|
||||||
|
new URLSearchParams({
|
||||||
|
easting: utmCoords.Easting,
|
||||||
|
northing: utmCoords.Northing,
|
||||||
|
zone: utmCoords.ZoneNumber,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
if (!res.ok)
|
||||||
|
return Alert.alert(
|
||||||
|
"Error!",
|
||||||
|
"Server error. Please report to ak95@riseup.net"
|
||||||
|
);
|
||||||
|
|
||||||
|
const json = await res.json();
|
||||||
|
setStoreName(json.name);
|
||||||
|
setStoreKey(json.key.toString());
|
||||||
|
|
||||||
|
const items = json.items.map((item: storeItemInterface, index: number) => {
|
||||||
|
const date = new Date(item.lastUpdated.replace(" ", "T") + "Z");
|
||||||
|
return (
|
||||||
|
<View className="flex flex-row w-[100vw]" key={index}>
|
||||||
|
<StoreItem
|
||||||
|
name={item.name.toString()}
|
||||||
|
volume={item.volumeFloz.toString()}
|
||||||
|
price={item.price.toString()}
|
||||||
|
date={date}
|
||||||
|
storeKey={item.store.toString()}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
setStoreItems(items);
|
||||||
|
|
||||||
|
const imageURL = `${process.env.EXPO_PUBLIC_BACKEND_URL}/img?imageKey=${storeKey}`;
|
||||||
|
await FileSystem.downloadAsync(
|
||||||
|
imageURL,
|
||||||
|
FileSystem.documentDirectory + storeKey
|
||||||
|
)
|
||||||
|
.then(({ uri }) => {
|
||||||
|
setImage(uri);
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
getStore();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const uploadImage = async () => {
|
||||||
|
// No permissions request is necessary for launching the image library
|
||||||
|
let result = await ImagePicker.launchImageLibraryAsync({
|
||||||
|
mediaTypes: ImagePicker.MediaTypeOptions.All,
|
||||||
|
allowsEditing: true,
|
||||||
|
aspect: [4, 3],
|
||||||
|
quality: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!result.canceled) {
|
||||||
|
setImage(result.assets[0]);
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append("file", {
|
||||||
|
uri: image.uri,
|
||||||
|
type: mime.getType(image.uri),
|
||||||
|
name: "file",
|
||||||
|
} as unknown as File);
|
||||||
|
const res = await fetch(
|
||||||
|
`${process.env.EXPO_PUBLIC_BACKEND_URL}/?` +
|
||||||
|
new URLSearchParams({
|
||||||
|
storeKey: storeKey,
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
method: "PUT",
|
||||||
|
body: formData,
|
||||||
|
mode: "cors",
|
||||||
|
redirect: "follow",
|
||||||
|
}
|
||||||
|
);
|
||||||
|
if (!res.ok) {
|
||||||
|
Alert.alert("Error!", "Server error! Please report to ak95@riseup.net");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAddItem = () => {
|
||||||
|
router.push({
|
||||||
|
pathname: "/item/new/[storeKey]",
|
||||||
|
params: {
|
||||||
|
storeKey: storeKey,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// remove header
|
||||||
|
const focusHandler = navigation.addListener("focus", () => {
|
||||||
|
getStore();
|
||||||
|
});
|
||||||
|
return focusHandler;
|
||||||
|
}, [navigation]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ScrollView contentContainerStyle={{ flexGrow: 1 }}>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
alignItems: "center",
|
||||||
|
height: "100%",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Stack.Screen
|
||||||
|
options={{
|
||||||
|
title: storeName,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<TouchableOpacity onPress={uploadImage}>
|
||||||
|
{image ? (
|
||||||
|
<Image source={{ uri: image }} className="h-[33vh] w-[100vw]" />
|
||||||
|
) : (
|
||||||
|
<Image
|
||||||
|
source={require("../../assets/images/photo_placeholder.png")}
|
||||||
|
className="h-[33vh] w-[100vw]"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</TouchableOpacity>
|
||||||
|
<View className="flex w-[100vw] py-6 justify-center items-center border-b dark:border-white">
|
||||||
|
<StyledText className="text-2xl dark:text-white">
|
||||||
|
{storeName}
|
||||||
|
</StyledText>
|
||||||
|
</View>
|
||||||
|
<ScrollView style={{ flex: 1 }}>{storeItems}</ScrollView>
|
||||||
|
<TouchableOpacity
|
||||||
|
className="px-8 py-4 my-4 bg-green-700 rounded"
|
||||||
|
onPress={handleAddItem}
|
||||||
|
>
|
||||||
|
<StyledText className="text-white text-center text-2xl">
|
||||||
|
Add
|
||||||
|
</StyledText>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
);
|
||||||
|
}
|
||||||
272
react-native/app/store/new/[coords].tsx
Normal file
272
react-native/app/store/new/[coords].tsx
Normal file
|
|
@ -0,0 +1,272 @@
|
||||||
|
import React, { useState } from "react";
|
||||||
|
import {
|
||||||
|
TouchableOpacity,
|
||||||
|
Text,
|
||||||
|
Image,
|
||||||
|
View,
|
||||||
|
ScrollView,
|
||||||
|
TextInput,
|
||||||
|
Appearance,
|
||||||
|
Alert,
|
||||||
|
} from "react-native";
|
||||||
|
import { Stack, useLocalSearchParams, router } from "expo-router";
|
||||||
|
import * as ImagePicker from "expo-image-picker";
|
||||||
|
import utmObj from "utm-latlng";
|
||||||
|
import { styled } from "nativewind";
|
||||||
|
import mime from "mime";
|
||||||
|
|
||||||
|
declare function isNaN(x: string | number): boolean;
|
||||||
|
|
||||||
|
export default function () {
|
||||||
|
const utm = new utmObj();
|
||||||
|
const [image, setImage] = useState(null);
|
||||||
|
const [storeName, onstoreName] = useState("");
|
||||||
|
const [itemName, onitemName] = useState("");
|
||||||
|
const [itemVolume, onitemVolume] = useState("");
|
||||||
|
const [itemPrice, onitemPrice] = useState("");
|
||||||
|
|
||||||
|
let { coords } = useLocalSearchParams();
|
||||||
|
|
||||||
|
const uploadImage = async () => {
|
||||||
|
// No permissions request is necessary for launching the image library
|
||||||
|
let result = await ImagePicker.launchImageLibraryAsync({
|
||||||
|
mediaTypes: ImagePicker.MediaTypeOptions.All,
|
||||||
|
allowsEditing: true,
|
||||||
|
aspect: [4, 3],
|
||||||
|
quality: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!result.canceled) {
|
||||||
|
setImage(result.assets[0]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
coords = coords.split("%2B");
|
||||||
|
const lon = coords[0];
|
||||||
|
const lat = coords[1];
|
||||||
|
const utmCoords = utm.convertLatLngToUtm(lat, lon, 5);
|
||||||
|
if (
|
||||||
|
itemPrice === "" ||
|
||||||
|
itemVolume === "" ||
|
||||||
|
itemName === "" ||
|
||||||
|
storeName === ""
|
||||||
|
) {
|
||||||
|
Alert.alert(
|
||||||
|
"Insufficient information!",
|
||||||
|
"Please double-check to ensure all fields have been filled"
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
let res;
|
||||||
|
// add image if image
|
||||||
|
if (image) {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append("file", {
|
||||||
|
uri: image.uri,
|
||||||
|
type: mime.getType(image.uri),
|
||||||
|
name: "file",
|
||||||
|
} as unknown as File);
|
||||||
|
res = await fetch(
|
||||||
|
`${process.env.EXPO_PUBLIC_BACKEND_URL}/?` +
|
||||||
|
new URLSearchParams({
|
||||||
|
easting: utmCoords.Easting,
|
||||||
|
northing: utmCoords.Northing,
|
||||||
|
zone: utmCoords.ZoneNumber,
|
||||||
|
zoneLetter: utmCoords.ZoneLetter,
|
||||||
|
name: storeName,
|
||||||
|
itemName: itemName,
|
||||||
|
itemPrice: itemPrice,
|
||||||
|
itemVolume: itemVolume,
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
body: formData,
|
||||||
|
mode: "cors",
|
||||||
|
redirect: "follow",
|
||||||
|
}
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
res = await fetch(
|
||||||
|
`${process.env.EXPO_PUBLIC_BACKEND_URL}/?` +
|
||||||
|
new URLSearchParams({
|
||||||
|
easting: utmCoords.Easting,
|
||||||
|
northing: utmCoords.Northing,
|
||||||
|
zone: utmCoords.ZoneNumber,
|
||||||
|
zoneLetter: utmCoords.ZoneLetter,
|
||||||
|
name: storeName,
|
||||||
|
itemName: itemName,
|
||||||
|
itemPrice: itemPrice,
|
||||||
|
itemVolume: itemVolume,
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
mode: "cors",
|
||||||
|
redirect: "follow",
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
Alert.alert(
|
||||||
|
"Success!",
|
||||||
|
"Store has been successfully added. Thank you!",
|
||||||
|
[
|
||||||
|
{
|
||||||
|
text: "OK",
|
||||||
|
onPress: () => {
|
||||||
|
router.push("/");
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
switch (res.status) {
|
||||||
|
case 400:
|
||||||
|
Alert.alert(
|
||||||
|
"Error!",
|
||||||
|
"Please double-check all fields are filled in."
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 409:
|
||||||
|
Alert.alert(
|
||||||
|
"Error!",
|
||||||
|
"This store already exists! Please click 'More' on its pin and upload an item there.",
|
||||||
|
[
|
||||||
|
{
|
||||||
|
text: "OK",
|
||||||
|
onPress: () => {
|
||||||
|
router.push("/");
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 500:
|
||||||
|
Alert.alert(
|
||||||
|
"Error!",
|
||||||
|
"Backend server error. Please report to ak95@riseup.net"
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
Alert.alert(
|
||||||
|
"Error!",
|
||||||
|
"Unspecified error. Please report to ak95@riseup.net"
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const StyledText = styled(Text);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ScrollView contentContainerStyle={{ flexGrow: 1 }}>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
alignItems: "center",
|
||||||
|
height: "100%",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Stack.Screen
|
||||||
|
options={{
|
||||||
|
title: "New Store",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<TouchableOpacity onPress={uploadImage}>
|
||||||
|
{image ? (
|
||||||
|
<Image source={{ uri: image.uri }} className="h-[33vh] w-[100vw]" />
|
||||||
|
) : (
|
||||||
|
<Image
|
||||||
|
source={require("../../../assets/images/photo_placeholder.png")}
|
||||||
|
className="h-[33vh] w-[100vw]"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</TouchableOpacity>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
justifyContent: "space-between",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<TextInput
|
||||||
|
className="w-[90vw] mt-4 h-14 text-2xl border rounded text-center dark:border-white dark:text-white"
|
||||||
|
onChangeText={onstoreName}
|
||||||
|
placeholder="Store Name"
|
||||||
|
placeholderTextColor={
|
||||||
|
Appearance.getColorScheme() === "dark" ? "#fff" : "#000"
|
||||||
|
}
|
||||||
|
value={storeName}
|
||||||
|
/>
|
||||||
|
<TextInput
|
||||||
|
className="w-[90vw] mt-4 h-12 text-xl border rounded text-center dark:border-white dark:text-white"
|
||||||
|
onChangeText={onitemName}
|
||||||
|
placeholder="Item Name"
|
||||||
|
placeholderTextColor={
|
||||||
|
Appearance.getColorScheme() === "dark" ? "#fff" : "#000"
|
||||||
|
}
|
||||||
|
value={itemName}
|
||||||
|
/>
|
||||||
|
<TextInput
|
||||||
|
className="w-[90vw] mt-4 h-12 text-xl border rounded text-center dark:border-white dark:text-white"
|
||||||
|
onChangeText={(text) => {
|
||||||
|
const split = text.split("");
|
||||||
|
let numeric = true;
|
||||||
|
split.forEach((char: string | number) => {
|
||||||
|
if (isNaN(char)) {
|
||||||
|
numeric = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (numeric) onitemVolume(text);
|
||||||
|
}}
|
||||||
|
placeholder="Item Volume (fl. oz.)"
|
||||||
|
placeholderTextColor={
|
||||||
|
Appearance.getColorScheme() === "dark" ? "#fff" : "#000"
|
||||||
|
}
|
||||||
|
inputMode="numeric"
|
||||||
|
value={itemVolume}
|
||||||
|
/>
|
||||||
|
<TextInput
|
||||||
|
className="w-[90vw] mt-4 h-12 text-xl border rounded text-center dark:border-white dark:text-white"
|
||||||
|
onChangeText={(text) => {
|
||||||
|
text = text.slice(2);
|
||||||
|
if (text.includes(".")) {
|
||||||
|
const split = text.split(".");
|
||||||
|
if (split.length < 3) {
|
||||||
|
const righthand = split[1];
|
||||||
|
if (righthand.length <= 2) {
|
||||||
|
onitemPrice(text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else onitemPrice(text);
|
||||||
|
}}
|
||||||
|
placeholder="Item Price"
|
||||||
|
inputMode="decimal"
|
||||||
|
placeholderTextColor={
|
||||||
|
Appearance.getColorScheme() === "dark" ? "#fff" : "#000"
|
||||||
|
}
|
||||||
|
value={`$ ${itemPrice}`}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
<TouchableOpacity
|
||||||
|
className="px-8 py-4 my-4 bg-green-700 rounded"
|
||||||
|
onPress={handleSubmit}
|
||||||
|
>
|
||||||
|
<StyledText className="text-white text-center text-2xl">
|
||||||
|
Submit
|
||||||
|
</StyledText>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
);
|
||||||
|
}
|
||||||
4
react-native/assets/images/index.d.ts
vendored
Normal file
4
react-native/assets/images/index.d.ts
vendored
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
declare module "*.png" {
|
||||||
|
const value: any;
|
||||||
|
export = value;
|
||||||
|
}
|
||||||
BIN
react-native/assets/images/photo_placeholder.png
Normal file
BIN
react-native/assets/images/photo_placeholder.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 14 KiB |
File diff suppressed because one or more lines are too long
|
|
@ -1,10 +1,12 @@
|
||||||
module.exports = function (api) {
|
module.exports = function (api) {
|
||||||
api.cache(true);
|
api.cache(true);
|
||||||
return {
|
return {
|
||||||
presets: ['babel-preset-expo'],
|
presets: ["babel-preset-expo"],
|
||||||
plugins: [
|
plugins: [
|
||||||
// Required for expo-router
|
// Required for expo-router
|
||||||
'expo-router/babel',
|
"expo-router/babel",
|
||||||
|
// nativewind
|
||||||
|
"nativewind/babel",
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,77 +0,0 @@
|
||||||
import React from 'react';
|
|
||||||
import { StyleSheet } from 'react-native';
|
|
||||||
|
|
||||||
import Colors from '../constants/Colors';
|
|
||||||
import { ExternalLink } from './ExternalLink';
|
|
||||||
import { MonoText } from './StyledText';
|
|
||||||
import { Text, View } from './Themed';
|
|
||||||
|
|
||||||
|
|
||||||
export default function EditScreenInfo({ path }: { path: string }) {
|
|
||||||
return (
|
|
||||||
<View>
|
|
||||||
<View style={styles.getStartedContainer}>
|
|
||||||
<Text
|
|
||||||
style={styles.getStartedText}
|
|
||||||
lightColor="rgba(0,0,0,0.8)"
|
|
||||||
darkColor="rgba(255,255,255,0.8)">
|
|
||||||
Open up the code for this screen:
|
|
||||||
</Text>
|
|
||||||
|
|
||||||
<View
|
|
||||||
style={[styles.codeHighlightContainer, styles.homeScreenFilename]}
|
|
||||||
darkColor="rgba(255,255,255,0.05)"
|
|
||||||
lightColor="rgba(0,0,0,0.05)">
|
|
||||||
<MonoText>{path}</MonoText>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<Text
|
|
||||||
style={styles.getStartedText}
|
|
||||||
lightColor="rgba(0,0,0,0.8)"
|
|
||||||
darkColor="rgba(255,255,255,0.8)">
|
|
||||||
Change any of the text, save the file, and your app will automatically update.
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<View style={styles.helpContainer}>
|
|
||||||
<ExternalLink
|
|
||||||
style={styles.helpLink}
|
|
||||||
href="https://docs.expo.io/get-started/create-a-new-app/#opening-the-app-on-your-phonetablet">
|
|
||||||
<Text style={styles.helpLinkText} lightColor={Colors.light.tint}>
|
|
||||||
Tap here if your app doesn't automatically update after making changes
|
|
||||||
</Text>
|
|
||||||
</ExternalLink>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
|
||||||
getStartedContainer: {
|
|
||||||
alignItems: 'center',
|
|
||||||
marginHorizontal: 50,
|
|
||||||
},
|
|
||||||
homeScreenFilename: {
|
|
||||||
marginVertical: 7,
|
|
||||||
},
|
|
||||||
codeHighlightContainer: {
|
|
||||||
borderRadius: 3,
|
|
||||||
paddingHorizontal: 4,
|
|
||||||
},
|
|
||||||
getStartedText: {
|
|
||||||
fontSize: 17,
|
|
||||||
lineHeight: 24,
|
|
||||||
textAlign: 'center',
|
|
||||||
},
|
|
||||||
helpContainer: {
|
|
||||||
marginTop: 15,
|
|
||||||
marginHorizontal: 20,
|
|
||||||
alignItems: 'center',
|
|
||||||
},
|
|
||||||
helpLink: {
|
|
||||||
paddingVertical: 15,
|
|
||||||
},
|
|
||||||
helpLinkText: {
|
|
||||||
textAlign: 'center',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
@ -1,28 +0,0 @@
|
||||||
import { Link } from 'expo-router';
|
|
||||||
import * as WebBrowser from 'expo-web-browser';
|
|
||||||
import React from 'react';
|
|
||||||
import { Platform } from 'react-native';
|
|
||||||
|
|
||||||
export function ExternalLink(
|
|
||||||
props: Omit<React.ComponentProps<typeof Link>, 'href'> & { href: string }
|
|
||||||
) {
|
|
||||||
return (
|
|
||||||
<Link
|
|
||||||
hrefAttrs={{
|
|
||||||
// On web, launch the link in a new tab.
|
|
||||||
target: '_blank',
|
|
||||||
}}
|
|
||||||
{...props}
|
|
||||||
// @ts-expect-error: External URLs are not typed.
|
|
||||||
href={props.href}
|
|
||||||
onPress={(e) => {
|
|
||||||
if (Platform.OS !== 'web') {
|
|
||||||
// Prevent the default behavior of linking to the default browser on native.
|
|
||||||
e.preventDefault();
|
|
||||||
// Open the link in an in-app browser.
|
|
||||||
WebBrowser.openBrowserAsync(props.href as string);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,104 +0,0 @@
|
||||||
import { useAssets } from "expo-asset";
|
|
||||||
import { useState, useRef, useEffect } from "react";
|
|
||||||
import { useWindowDimensions, Alert, Button } from "react-native";
|
|
||||||
import WebView, { WebViewMessageEvent } from "react-native-webview";
|
|
||||||
import * as Location from "expo-location";
|
|
||||||
import { fromLatLon, toLatLon } from "../UTM_converter";
|
|
||||||
|
|
||||||
const Map = () => {
|
|
||||||
const [assets] = useAssets([require("../assets/index.html")]);
|
|
||||||
const [htmlString, setHtmlString] = useState<string>();
|
|
||||||
|
|
||||||
const dimensions = useWindowDimensions();
|
|
||||||
const webViewRef = useRef<WebView | null>();
|
|
||||||
|
|
||||||
// gets location
|
|
||||||
useEffect(() => {
|
|
||||||
(async () => {
|
|
||||||
const { status } = await Location.requestForegroundPermissionsAsync();
|
|
||||||
if (status !== "granted") return;
|
|
||||||
const location = await Location.getCurrentPositionAsync({
|
|
||||||
distanceInterval: 0, // for IOS
|
|
||||||
accuracy: Location.Accuracy.High,
|
|
||||||
timeInterval: 3000, // for Android
|
|
||||||
});
|
|
||||||
// makes injectable javascript string
|
|
||||||
const str = `window.passLocation(${location.coords.longitude}, ${location.coords.latitude}); true()`;
|
|
||||||
// passes string to webview - there it is handled by OpenLayers to change the current location
|
|
||||||
webViewRef.current?.injectJavaScript(str);
|
|
||||||
})();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// loads assets
|
|
||||||
useEffect(() => {
|
|
||||||
if (assets) {
|
|
||||||
fetch(assets[0].localUri || "")
|
|
||||||
.then((res) => res.text())
|
|
||||||
.then((html) => setHtmlString(html));
|
|
||||||
}
|
|
||||||
}, [assets]);
|
|
||||||
|
|
||||||
// exits if no map passed in
|
|
||||||
if (!htmlString) {
|
|
||||||
return <></>;
|
|
||||||
}
|
|
||||||
|
|
||||||
const messageHandler = (event: WebViewMessageEvent) => {
|
|
||||||
if (typeof event.nativeEvent.data == "string") {
|
|
||||||
const message = event.nativeEvent.data;
|
|
||||||
if (message === "new pin start") {
|
|
||||||
Alert.alert(
|
|
||||||
"New Pin",
|
|
||||||
`Please select location for new pin then press "OK"`,
|
|
||||||
[
|
|
||||||
{
|
|
||||||
text: "OK",
|
|
||||||
onPress: () => {
|
|
||||||
// makes injectable javascript string
|
|
||||||
const str = `window.placePin(); true()`;
|
|
||||||
// passes string to webview - there it is handled by OpenLayers to change the current location
|
|
||||||
webViewRef.current?.injectJavaScript(str);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
]
|
|
||||||
);
|
|
||||||
} else if (message.startsWith(`@`)) {
|
|
||||||
const coords = message.slice(1).split(",");
|
|
||||||
const lon = Number(coords[0]);
|
|
||||||
const lat = Number(coords[1]);
|
|
||||||
const UTM = fromLatLon(lat, lon);
|
|
||||||
Alert.alert(
|
|
||||||
"Coords",
|
|
||||||
`${UTM.easting}, ${UTM.northing}, ${UTM.zoneNum}`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<WebView
|
|
||||||
ref={(currentRef) => (webViewRef.current = currentRef)}
|
|
||||||
injectedJavascript=""
|
|
||||||
source={{
|
|
||||||
html: htmlString,
|
|
||||||
}}
|
|
||||||
javaScriptEnabled
|
|
||||||
style={{
|
|
||||||
width: dimensions.width,
|
|
||||||
height: dimensions.height,
|
|
||||||
}}
|
|
||||||
scrollEnabled={false}
|
|
||||||
overScrollMode="never"
|
|
||||||
showsVerticalScrollIndicator={false}
|
|
||||||
showsHorizontalScrollIndicator={false}
|
|
||||||
scalesPageToFit={false}
|
|
||||||
containerStyle={{
|
|
||||||
flex: 1,
|
|
||||||
}}
|
|
||||||
onMessage={messageHandler}
|
|
||||||
webviewDebuggingEnabled={true}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default Map;
|
|
||||||
92
react-native/components/QueryItem.tsx
Normal file
92
react-native/components/QueryItem.tsx
Normal file
|
|
@ -0,0 +1,92 @@
|
||||||
|
import { View, Text, Alert, TouchableOpacity } from "react-native";
|
||||||
|
|
||||||
|
import TimeAgo from "@andordavoti/react-native-timeago";
|
||||||
|
|
||||||
|
import { router } from "expo-router";
|
||||||
|
|
||||||
|
import { styled } from "nativewind";
|
||||||
|
|
||||||
|
import utmObj from "utm-latlng";
|
||||||
|
|
||||||
|
type QueryItemProps = {
|
||||||
|
name: string;
|
||||||
|
volume: string;
|
||||||
|
price: string;
|
||||||
|
date: Date;
|
||||||
|
storeKey: string;
|
||||||
|
distance: Number;
|
||||||
|
perFloz: Number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function QueryItem(props: QueryItemProps) {
|
||||||
|
const utm = new utmObj();
|
||||||
|
|
||||||
|
const StyledText = styled(Text);
|
||||||
|
const StyledTimeAgo = styled(TimeAgo);
|
||||||
|
|
||||||
|
const distanceInMeters = Math.round(Number(props.distance));
|
||||||
|
const distance =
|
||||||
|
distanceInMeters > 1609
|
||||||
|
? `${Math.round(distanceInMeters / 1609)} mi.`
|
||||||
|
: `${Math.round(distanceInMeters * 3.28084)} ft.`;
|
||||||
|
|
||||||
|
const navToStore = async () => {
|
||||||
|
const res = await fetch(
|
||||||
|
`${process.env.EXPO_PUBLIC_BACKEND_URL}/?` +
|
||||||
|
new URLSearchParams({
|
||||||
|
storeKey: props.storeKey,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
if (!res.ok)
|
||||||
|
return Alert.alert(
|
||||||
|
"Error!",
|
||||||
|
"Server error. Please report to ak95@riseup.net"
|
||||||
|
);
|
||||||
|
|
||||||
|
const json = await res.json();
|
||||||
|
const { lat, lng } = utm.convertUtmToLatLng(
|
||||||
|
json.easting,
|
||||||
|
json.northing,
|
||||||
|
json.zone,
|
||||||
|
json.zoneLetter
|
||||||
|
);
|
||||||
|
router.push({
|
||||||
|
pathname: "/store/[coords]",
|
||||||
|
params: {
|
||||||
|
coords: `${lng}+${lat}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TouchableOpacity onPress={navToStore}>
|
||||||
|
<View
|
||||||
|
className="flex flex-row w-[100vw] p-2 justify-between items-center border-b dark:text-white dark:border-white"
|
||||||
|
style={{ flex: 1 }}
|
||||||
|
>
|
||||||
|
<View className="flex justify-center items-start gap-2">
|
||||||
|
<StyledText className="text-xl dark:text-white">
|
||||||
|
{props.name} ({props.volume} fl. oz.)
|
||||||
|
</StyledText>
|
||||||
|
<View className="flex flex-row items-center items-center">
|
||||||
|
<StyledText className="text-lg dark:text-white">
|
||||||
|
${props.price}
|
||||||
|
</StyledText>
|
||||||
|
<StyledText className="text-lg dark:text-white">
|
||||||
|
(${props.perFloz}/fl. oz.)
|
||||||
|
</StyledText>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
<View className="flex justify-center items-end gap-2">
|
||||||
|
<StyledText className="text-xl dark:text-white">
|
||||||
|
{distance} away
|
||||||
|
</StyledText>
|
||||||
|
<StyledTimeAgo
|
||||||
|
dateTo={props.date}
|
||||||
|
className="text-lg dark:text-white"
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</TouchableOpacity>
|
||||||
|
);
|
||||||
|
}
|
||||||
116
react-native/components/StoreItem.tsx
Normal file
116
react-native/components/StoreItem.tsx
Normal file
|
|
@ -0,0 +1,116 @@
|
||||||
|
import { useState } from "react";
|
||||||
|
import { TouchableOpacity, View, Text, TextInput, Alert } from "react-native";
|
||||||
|
|
||||||
|
import { styled } from "nativewind";
|
||||||
|
import { FontAwesome5 } from "@expo/vector-icons";
|
||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
|
||||||
|
import TimeAgo from "@andordavoti/react-native-timeago";
|
||||||
|
|
||||||
|
type StoreItemProps = {
|
||||||
|
name: string;
|
||||||
|
volume: string;
|
||||||
|
price: string;
|
||||||
|
date: Date;
|
||||||
|
storeKey: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function StoreItem(props: StoreItemProps) {
|
||||||
|
const StyledText = styled(Text);
|
||||||
|
const StyledTimeAgo = styled(TimeAgo);
|
||||||
|
const StyledFontAwesome = styled(FontAwesome5);
|
||||||
|
const StyledIonicons = styled(Ionicons);
|
||||||
|
|
||||||
|
const [editing, setEditing] = useState(false);
|
||||||
|
const [locked, setLocked] = useState(false);
|
||||||
|
const [price, setPrice] = useState(props.price);
|
||||||
|
|
||||||
|
const updatePrice = async () => {
|
||||||
|
setLocked(true);
|
||||||
|
const res = await fetch(
|
||||||
|
`${process.env.EXPO_PUBLIC_BACKEND_URL}/?` +
|
||||||
|
new URLSearchParams({
|
||||||
|
storeKey: props.storeKey,
|
||||||
|
itemName: props.name,
|
||||||
|
itemPrice: price,
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
method: "PUT",
|
||||||
|
mode: "cors",
|
||||||
|
redirect: "follow",
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!res.ok)
|
||||||
|
Alert.alert("Error!", "Server error. Please report to ak95@riseup.net");
|
||||||
|
|
||||||
|
setLocked(false);
|
||||||
|
setEditing(!editing);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
className="flex flex-row w-[100vw] p-2 justify-between items-center border-b dark:text-white dark:border-white"
|
||||||
|
style={{ flex: 1 }}
|
||||||
|
>
|
||||||
|
<View className="flex justify-center items-start gap-2">
|
||||||
|
<StyledText className="text-xl dark:text-white">
|
||||||
|
{props.name}
|
||||||
|
</StyledText>
|
||||||
|
<StyledText className="text-lg dark:text-white">
|
||||||
|
{props.volume} Fl. Oz.
|
||||||
|
</StyledText>
|
||||||
|
</View>
|
||||||
|
<View className="flex justify-center items-end gap-0">
|
||||||
|
<View className="flex flex-row items-center items-center gap-x-1">
|
||||||
|
<StyledText className="text-lg dark:text-white">$</StyledText>
|
||||||
|
{editing ? (
|
||||||
|
<View className="flex flex-row border rounded dark:border-white items-center gap-x-2 py-1 pr-2">
|
||||||
|
<TextInput
|
||||||
|
className="text-lg dark:text-white"
|
||||||
|
textAlign={"center"}
|
||||||
|
onChangeText={(text) => {
|
||||||
|
if (!locked) {
|
||||||
|
if (text.includes(".")) {
|
||||||
|
const split = text.split(".");
|
||||||
|
if (split.length < 3) {
|
||||||
|
const righthand = split[1];
|
||||||
|
if (righthand.length <= 2) {
|
||||||
|
setPrice(text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else setPrice(text);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
inputMode="decimal"
|
||||||
|
value={price}
|
||||||
|
/>
|
||||||
|
<TouchableOpacity onPress={updatePrice}>
|
||||||
|
<StyledIonicons
|
||||||
|
name="checkmark"
|
||||||
|
className="text-lg dark:text-white"
|
||||||
|
/>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
<View className="flex flex-row items-end gap-x-2 py-1 pr-2">
|
||||||
|
<StyledText className="text-lg dark:text-white">
|
||||||
|
{price}
|
||||||
|
</StyledText>
|
||||||
|
<TouchableOpacity onPress={() => setEditing(!editing)}>
|
||||||
|
<StyledFontAwesome
|
||||||
|
name="pencil-alt"
|
||||||
|
className="text-lg dark:text-white"
|
||||||
|
/>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
<StyledTimeAgo
|
||||||
|
dateTo={props.date}
|
||||||
|
className="text-lg dark:text-white"
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,5 +0,0 @@
|
||||||
import { Text, TextProps } from './Themed';
|
|
||||||
|
|
||||||
export function MonoText(props: TextProps) {
|
|
||||||
return <Text {...props} style={[props.style, { fontFamily: 'SpaceMono' }]} />;
|
|
||||||
}
|
|
||||||
|
|
@ -1,44 +0,0 @@
|
||||||
/**
|
|
||||||
* Learn more about Light and Dark modes:
|
|
||||||
* https://docs.expo.io/guides/color-schemes/
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { Text as DefaultText, useColorScheme, View as DefaultView } from 'react-native';
|
|
||||||
|
|
||||||
import Colors from '../constants/Colors';
|
|
||||||
|
|
||||||
type ThemeProps = {
|
|
||||||
lightColor?: string;
|
|
||||||
darkColor?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type TextProps = ThemeProps & DefaultText['props'];
|
|
||||||
export type ViewProps = ThemeProps & DefaultView['props'];
|
|
||||||
|
|
||||||
export function useThemeColor(
|
|
||||||
props: { light?: string; dark?: string },
|
|
||||||
colorName: keyof typeof Colors.light & keyof typeof Colors.dark
|
|
||||||
) {
|
|
||||||
const theme = useColorScheme() ?? 'light';
|
|
||||||
const colorFromProps = props[theme];
|
|
||||||
|
|
||||||
if (colorFromProps) {
|
|
||||||
return colorFromProps;
|
|
||||||
} else {
|
|
||||||
return Colors[theme][colorName];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function Text(props: TextProps) {
|
|
||||||
const { style, lightColor, darkColor, ...otherProps } = props;
|
|
||||||
const color = useThemeColor({ light: lightColor, dark: darkColor }, 'text');
|
|
||||||
|
|
||||||
return <DefaultText style={[{ color }, style]} {...otherProps} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function View(props: ViewProps) {
|
|
||||||
const { style, lightColor, darkColor, ...otherProps } = props;
|
|
||||||
const backgroundColor = useThemeColor({ light: lightColor, dark: darkColor }, 'background');
|
|
||||||
|
|
||||||
return <DefaultView style={[{ backgroundColor }, style]} {...otherProps} />;
|
|
||||||
}
|
|
||||||
18
react-native/eas.json
Normal file
18
react-native/eas.json
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
{
|
||||||
|
"cli": {
|
||||||
|
"version": ">= 5.9.1"
|
||||||
|
},
|
||||||
|
"build": {
|
||||||
|
"development": {
|
||||||
|
"developmentClient": true,
|
||||||
|
"distribution": "internal"
|
||||||
|
},
|
||||||
|
"preview": {
|
||||||
|
"distribution": "internal"
|
||||||
|
},
|
||||||
|
"production": {}
|
||||||
|
},
|
||||||
|
"submit": {
|
||||||
|
"production": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
4513
react-native/package-lock.json
generated
4513
react-native/package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -1,32 +1,34 @@
|
||||||
{
|
{
|
||||||
"name": "skibidi-dop-dop-dop-yes-yes",
|
"name": "beerbuddy-frontend",
|
||||||
"main": "expo-router/entry",
|
"main": "expo-router/entry",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "expo start",
|
"start": "expo start",
|
||||||
"android": "expo start --android",
|
"android": "expo start --android",
|
||||||
"ios": "expo start --ios",
|
"ios": "expo start --ios",
|
||||||
"web": "expo start --web",
|
"web": "expo start --web"
|
||||||
"test": "jest --watchAll"
|
|
||||||
},
|
|
||||||
"jest": {
|
|
||||||
"preset": "jest-expo"
|
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@expo/webpack-config": "^19.0.0",
|
"@andordavoti/react-native-timeago": "^0.0.14",
|
||||||
"@expo/vector-icons": "^13.0.0",
|
"@expo/vector-icons": "^13.0.0",
|
||||||
|
"@expo/webpack-config": "^19.0.0",
|
||||||
"@react-navigation/native": "^6.0.2",
|
"@react-navigation/native": "^6.0.2",
|
||||||
"expo": "~49.0.15",
|
"expo": "~49.0.18",
|
||||||
"expo-asset": "^8.10.1",
|
"expo-asset": "^8.10.1",
|
||||||
"expo-constants": "^14.4.2",
|
"expo-constants": "^14.4.2",
|
||||||
|
"expo-file-system": "~15.4.5",
|
||||||
"expo-font": "~11.4.0",
|
"expo-font": "~11.4.0",
|
||||||
|
"expo-image-picker": "~14.3.2",
|
||||||
"expo-linking": "~5.0.2",
|
"expo-linking": "~5.0.2",
|
||||||
"expo-location": "~16.1.0",
|
"expo-location": "~16.1.0",
|
||||||
"expo-router": "^2.0.0",
|
"expo-router": "^2.0.0",
|
||||||
"expo-splash-screen": "~0.20.5",
|
"expo-splash-screen": "~0.20.5",
|
||||||
"expo-status-bar": "~1.6.0",
|
"expo-status-bar": "~1.6.0",
|
||||||
"expo-system-ui": "~2.4.0",
|
"expo-system-ui": "~2.4.0",
|
||||||
|
"expo-updates": "~0.18.17",
|
||||||
"expo-web-browser": "~12.3.2",
|
"expo-web-browser": "~12.3.2",
|
||||||
|
"mime": "^4.0.0",
|
||||||
|
"nativewind": "^2.0.11",
|
||||||
"react": "18.2.0",
|
"react": "18.2.0",
|
||||||
"react-dom": "18.2.0",
|
"react-dom": "18.2.0",
|
||||||
"react-native": "0.72.6",
|
"react-native": "0.72.6",
|
||||||
|
|
@ -34,14 +36,15 @@
|
||||||
"react-native-safe-area-context": "4.6.3",
|
"react-native-safe-area-context": "4.6.3",
|
||||||
"react-native-screens": "~3.22.0",
|
"react-native-screens": "~3.22.0",
|
||||||
"react-native-web": "~0.19.6",
|
"react-native-web": "~0.19.6",
|
||||||
"react-native-webview": "^13.6.3"
|
"react-native-webview": "13.2.2",
|
||||||
|
"utm-latlng": "^1.0.7",
|
||||||
|
"@react-native-picker/picker": "2.4.10"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@babel/core": "^7.20.0",
|
"@babel/core": "^7.20.0",
|
||||||
"@types/react": "~18.2.14",
|
"@types/react": "~18.2.14",
|
||||||
"jest": "^29.2.1",
|
|
||||||
"jest-expo": "~49.0.0",
|
|
||||||
"react-test-renderer": "18.2.0",
|
"react-test-renderer": "18.2.0",
|
||||||
|
"tailwindcss": "^3.3.2",
|
||||||
"typescript": "^5.1.3"
|
"typescript": "^5.1.3"
|
||||||
},
|
},
|
||||||
"overrides": {
|
"overrides": {
|
||||||
|
|
|
||||||
8
react-native/tailwind.config.js
Normal file
8
react-native/tailwind.config.js
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
/** @type {import('tailwindcss').Config} */
|
||||||
|
module.exports = {
|
||||||
|
content: ["./app/**/*.{js,jsx,ts,tsx}", "./components/*.{js,jsx,ts,tsx}"],
|
||||||
|
theme: {
|
||||||
|
extend: {},
|
||||||
|
},
|
||||||
|
plugins: [],
|
||||||
|
};
|
||||||
Loading…
Add table
Reference in a new issue