diff --git a/app/src/UI/Header/Header.tsx b/app/src/UI/Header/Header.tsx index 096616fbe..7cad976c4 100644 --- a/app/src/UI/Header/Header.tsx +++ b/app/src/UI/Header/Header.tsx @@ -13,7 +13,6 @@ import { MenuItem, Switch } from '@mui/material'; -import { useDispatch } from 'react-redux'; import { AUTH_OPEN_OFFLINE_USER_SELECTION_DIALOG, AUTH_SIGNIN_REQUEST, @@ -39,12 +38,13 @@ import invbclogo from '/assets/InvasivesBC_Icon.svg'; import ArrowRightIcon from '@mui/icons-material/ArrowRight'; import ArrowLeftIcon from '@mui/icons-material/ArrowLeft'; import { RENDER_DEBUG } from 'UI/App'; -import { useSelector } from 'utils/use_selector'; +import { AppDispatch, useDispatch, useSelector } from 'utils/use_selector'; import { selectAuth } from 'state/reducers/auth'; import { OfflineSyncHeaderButton } from 'UI/Header/OfflineSyncHeaderButton'; import RefreshButton from './RefreshButton'; import { MOBILE } from 'state/build-time-config'; import NetworkActions from 'state/actions/network/NetworkActions'; +import MapActions from 'state/actions/map'; type TabPredicate = | 'authenticated_any' @@ -185,10 +185,20 @@ const LoginButton = ({ labelText = 'Login' }) => { const LogoutButton = () => { const dispatch = useDispatch(); + const signOutAndTogglePanel = () => { + return (dispatch: AppDispatch) => { + dispatch({ + type: TOGGLE_PANEL, + payload: { panelOpen: false } + }); + dispatch({ type: AUTH_SIGNOUT_REQUEST }); + dispatch(MapActions.toggleOverlay('public_layer')); + }; + }; return ( { - dispatch({ type: AUTH_SIGNOUT_REQUEST }); + dispatch(signOutAndTogglePanel()); }} > diff --git a/app/src/UI/Overlay/Records/Activity/PhotoContainer.tsx b/app/src/UI/Overlay/Records/Activity/PhotoContainer.tsx index 6d5ed3e7f..18db6be9c 100644 --- a/app/src/UI/Overlay/Records/Activity/PhotoContainer.tsx +++ b/app/src/UI/Overlay/Records/Activity/PhotoContainer.tsx @@ -13,12 +13,14 @@ import { Typography } from '@mui/material'; import EditIcon from '@mui/icons-material/Edit'; -import { AddAPhoto, DeleteForever } from '@mui/icons-material'; +import { PhotoCamera, PhotoLibrary, DeleteForever } from '@mui/icons-material'; import React, { useState } from 'react'; import Activity from 'state/actions/activity/Activity'; import UploadedPhoto from 'interfaces/UploadedPhoto'; import { useDispatch, useSelector } from 'utils/use_selector'; import './PhotoContainer.css'; +import Alerts from 'state/actions/alerts/Alerts'; +import { AlertSeverity, AlertSubjects } from 'constants/alertEnums'; export interface IPhoto { file_name: string; webviewPath?: string; @@ -37,8 +39,53 @@ const PhotoContainer: React.FC = (props) => { const dispatch = useDispatch(); const media = useSelector((state) => state.ActivityPage.activity?.media || []); - const takePhoto = async () => { + async function convertWebPathToDataUrl(webPath: string): Promise { + const response = await fetch(webPath); + + // convert response into a blob + const blob = await response.blob(); + + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onloadend = () => resolve(reader.result as string); // result is a dataUrl + reader.onerror = reject; + reader.readAsDataURL(blob); + }); + } + + const checkPermissionsAndAlert = async (photoOption: CameraSource): Promise => { + try { + const permissions = await Camera.checkPermissions(); + + if (photoOption === CameraSource.Camera && permissions.camera === 'denied') { + dispatch( + Alerts.create({ + content: + 'Camera access is denied. Please enable camera permissions in your device settings to take photos.', + severity: AlertSeverity.Warning, + subject: AlertSubjects.Photo, + autoClose: 5 + }) + ); + } else if (photoOption === CameraSource.Photos && permissions.photos === 'denied') { + dispatch( + Alerts.create({ + content: + 'Photo library access is denied. Please enable photo library permissions in your device settings to choose photos.', + severity: AlertSeverity.Warning, + subject: AlertSubjects.Photo, + autoClose: 5 + }) + ); + } + } catch (error) { + console.error('Error checking permissions:', error); + } + }; + + const takePhotoFromCamera = async () => { try { + await checkPermissionsAndAlert(CameraSource.Camera); const cameraPhoto = await Camera.getPhoto({ presentationStyle: 'fullscreen', resultType: CameraResultType.DataUrl, @@ -60,6 +107,49 @@ const PhotoContainer: React.FC = (props) => { } }; + const choosePhotosFromLibrary = async () => { + try { + await checkPermissionsAndAlert(CameraSource.Photos); + const multiplePhotos = await Camera.pickImages({ + quality: 100, + limit: 10 + }); + + if (!multiplePhotos.photos.length) { + console.log('No photos selected'); + return; + } + + // process all photos concurrently + const processedPhotos = await Promise.all( + multiplePhotos.photos.map(async (photo, index) => { + try { + const fileName = `${new Date().getTime()}-${index}.${photo.format}`; + const dataUrl = await convertWebPathToDataUrl(photo.webPath); + + return { + file_name: fileName, + encoded_file: dataUrl, + description: 'untitled', + editing: false + } as UploadedPhoto; + } catch (error) { + console.error(`Error processing photo ${index + 1}:`, error); + return null; // skip photo on failure + } + }) + ); + + // filter out failed photo conversions + const validPhotos = processedPhotos.filter((photo) => photo !== null); + validPhotos.forEach((photo) => { + if (photo) dispatch(Activity.Photo.add(photo)); + }); + } catch (e) { + console.error('error occurred: ', e); + } + }; + const deletePhoto = async (photo: UploadedPhoto) => { dispatch(Activity.Photo.delete(photo)); }; @@ -129,8 +219,18 @@ const PhotoContainer: React.FC = (props) => { - + + + diff --git a/app/src/UI/Overlay/Records/RecordSet/RecordTable.tsx b/app/src/UI/Overlay/Records/RecordSet/RecordTable.tsx index 10ef5cc3b..9f64349bd 100644 --- a/app/src/UI/Overlay/Records/RecordSet/RecordTable.tsx +++ b/app/src/UI/Overlay/Records/RecordSet/RecordTable.tsx @@ -59,8 +59,8 @@ export const RecordTable = ({ setID, userOfflineMobile }: PropTypes) => { View/Edit )} - {tableType === 'Activity' - ? activityColumnsToDisplay.map((col: any, i) => ( + {tableType === RecordSetType.Activity + ? activityColumnsToDisplay.map((col: any) => ( { type: USER_CLICKED_RECORD, payload: { recordType: tableType, - id: tableType === 'Activity' ? row.activity_id : row.site_id, + id: tableType === RecordSetType.Activity ? row.activity_id : row.site_id, row: row } }); @@ -119,13 +119,13 @@ export const RecordTable = ({ setID, userOfflineMobile }: PropTypes) => { type: USER_TOUCHED_RECORD, payload: { recordType: tableType, - id: tableType === 'Activity' ? row.activity_id : row.site_id, + id: tableType === RecordSetType.Activity ? row.activity_id : row.site_id, row: row } }); }} className="record_table_row" - key={row?.activity_id} + key={row?.activity_id ?? row?.site_id} > {isTouch && ( { type: USER_CLICKED_RECORD, payload: { recordType: tableType, - id: tableType === 'Activity' ? row.activity_id : row.site_id, + id: tableType === RecordSetType.Activity ? row.activity_id : row.site_id, row: row } }); @@ -145,7 +145,7 @@ export const RecordTable = ({ setID, userOfflineMobile }: PropTypes) => { )} - {tableType === 'Activity' + {tableType === RecordSetType.Activity ? activityColumnsToDisplay.map((col) => { return ( diff --git a/app/src/constants/offline_state_version.ts b/app/src/constants/offline_state_version.ts index ef9c0dbe8..8d1b09538 100644 --- a/app/src/constants/offline_state_version.ts +++ b/app/src/constants/offline_state_version.ts @@ -1,5 +1,5 @@ /* handle purging the offline storage on app version upgrade */ -export const CURRENT_MIGRATION_VERSION = 20241118; +export const CURRENT_MIGRATION_VERSION = 20250113; export const MIGRATION_VERSION_KEY = '_persistedMigrationVersion'; diff --git a/app/src/state/actions/userSettings/RecordSet.ts b/app/src/state/actions/userSettings/RecordSet.ts index 5a7f3d420..2ea6152ed 100644 --- a/app/src/state/actions/userSettings/RecordSet.ts +++ b/app/src/state/actions/userSettings/RecordSet.ts @@ -78,7 +78,7 @@ class RecordSet { static readonly removeFilter = createAction(`${this.PREFIX}/removeFilter`); private static readonly createDefaultRecordset = (type: RecordSetType): UserRecordSet => ({ - tableFilters: null, + tableFilters: [], id: nanoid(), color: RECORD_COLOURS[0], drawOrder: 0, diff --git a/app/src/state/sagas/map/dataAccess.ts b/app/src/state/sagas/map/dataAccess.ts index 9a0e13902..a9711d245 100644 --- a/app/src/state/sagas/map/dataAccess.ts +++ b/app/src/state/sagas/map/dataAccess.ts @@ -15,7 +15,7 @@ import { } from 'state/actions'; import { ACTIVITY_GEOJSON_SOURCE_KEYS, selectMap } from 'state/reducers/map'; import WhatsHere from 'state/actions/whatsHere/WhatsHere'; -import { RecordSetType } from 'interfaces/UserRecordSet'; +import { RecordSetType, UserRecordSet } from 'interfaces/UserRecordSet'; import { MOBILE } from 'state/build-time-config'; import { RecordCacheServiceFactory } from 'utils/record-cache/context'; import GeoShapes from 'constants/geoShapes'; @@ -60,7 +60,7 @@ export function* handle_PREP_FILTERS_FOR_VECTOR_ENDPOINT(action) { try { const currentState = yield select((state) => state?.UserSettings); const clientBoundaries = yield select((state) => state.Map?.clientBoundaries); - const cacheMetadata = currentState.recordSets[action.payload.recordSetID].cacheMetadata; + const recordset: UserRecordSet = currentState.recordSets[action.payload.recordSetID]; const filterObject = getRecordFilterObjectFromStateForAPI( action.payload.recordSetID, currentState, @@ -83,8 +83,8 @@ export function* handle_PREP_FILTERS_FOR_VECTOR_ENDPOINT(action) { filterObject: filterObject, recordSetID: action.payload.recordSetID, tableFiltersHash: action.payload.tableFiltersHash, - recordSetType: action.payload.recordSetType, - cacheMetadata: cacheMetadata ?? null + recordSetType: recordset.recordSetType, + cacheMetadata: recordset.cacheMetadata ?? null } }); } catch (e) { @@ -404,7 +404,6 @@ export function* handle_MAP_WHATS_HERE_INIT_GET_ACTIVITY(action) { } export function getSelectColumnsByRecordSetType(recordSetType: any) { - //throw new Error('Function not implemented.'); let columns: string[] = []; if (recordSetType === 'Activity') { columns = [ diff --git a/app/src/utils/closestWellsHelpers.tsx b/app/src/utils/closestWellsHelpers.tsx index 6872ed4f8..b18d642b0 100644 --- a/app/src/utils/closestWellsHelpers.tsx +++ b/app/src/utils/closestWellsHelpers.tsx @@ -4,13 +4,13 @@ import polygonToLine from '@turf/polygon-to-line'; import inside from '@turf/inside'; import buffer from '@turf/buffer'; import { getDataFromDataBCv2 } from './WFSConsumer'; -import { selectNetworkConnected } from 'state/reducers/network'; +import { selectNetworkState } from 'state/reducers/network'; import { select } from 'redux-saga/effects'; //gets layer data based on the layer name export function* getClosestWells(inputGeometry) { const firstFeature = inputGeometry; - const networkState = yield select(selectNetworkConnected); + const networkState = yield select(selectNetworkState); //get the map extent as geoJson polygon feature const bufferedGeo = buffer(firstFeature, 1, { units: 'kilometers' }); //if well layer is selected diff --git a/database/src/migrations/0030_add_crd_ipmas.ts b/database/src/migrations/0030_add_crd_ipmas.ts new file mode 100644 index 000000000..0a49e8224 --- /dev/null +++ b/database/src/migrations/0030_add_crd_ipmas.ts @@ -0,0 +1,28 @@ +import { Knex } from 'knex'; +import axios from 'axios'; +import { ungzip } from 'node-gzip'; + +export async function up(knex: Knex): Promise { + try { + const url = 'https://nrs.objectstore.gov.bc.ca/seeds/CRD_IPMAS.sql.gz'; + const { data } = await axios.get(url, { responseType: 'arraybuffer' }); + const sql = await ungzip(data); + + await knex.raw(sql.toString()); + } catch (e) { + console.error('Failed to insert IPMAS data:', e); + throw e; + } +} + +export async function down(knex: Knex): Promise { + try { + await knex.raw(` + DELETE FROM public.invasive_plant_management_areas + WHERE agency_cd = 'CRD'; + `); + } catch (e) { + console.error('Failed to rollback IPMAS data:', e); + throw e; + } +} diff --git a/database/src/migrations/0031_add_nrrm_to_regional_districts.ts b/database/src/migrations/0031_add_nrrm_to_regional_districts.ts new file mode 100644 index 000000000..8ba474e9d --- /dev/null +++ b/database/src/migrations/0031_add_nrrm_to_regional_districts.ts @@ -0,0 +1,28 @@ +import { Knex } from 'knex'; +import axios from 'axios'; +import { ungzip } from 'node-gzip'; + +export async function up(knex: Knex): Promise { + try { + const url = 'https://nrs.objectstore.gov.bc.ca/seeds/NRRM.sql.gz'; + const { data } = await axios.get(url, { responseType: 'arraybuffer' }); + const sql = await ungzip(data); + + await knex.raw(sql.toString()); + } catch (e) { + console.error('Failed to insert NRRM data:', e); + throw e; + } +} + +export async function down(knex: Knex): Promise { + try { + await knex.raw(` + DELETE FROM public.regional_districts + WHERE agency_cd = 'NRRM'; + `); + } catch (e) { + console.error('Failed to rollback NRRM data:', e); + throw e; + } +}