chase преди 2 години
родител
ревизия
f59361a2e8
променени са 100 файла, в които са добавени 2546 реда и са изтрити 196 реда
  1. 3 3
      App.tsx
  2. 1 1
      metro.config.js
  3. 3 0
      src/api/api-store.ts
  4. 31 0
      src/api/comments/comments.add.ts
  5. 29 0
      src/api/comments/comments.delete.ts
  6. 30 0
      src/api/comments/comments.edit.ts
  7. 34 0
      src/api/comments/comments.get.ts
  8. 4 0
      src/api/comments/index.ts
  9. 6 0
      src/api/landmarks/index.ts
  10. 71 0
      src/api/landmarks/landmark.add.ts
  11. 34 0
      src/api/landmarks/landmark.delete.ts
  12. 42 0
      src/api/landmarks/landmark.edit.ts
  13. 40 0
      src/api/landmarks/landmark.get-all.ts
  14. 30 0
      src/api/landmarks/landmark.get-one.ts
  15. 31 0
      src/api/landmarks/landmark.rate.ts
  16. 2 0
      src/api/lm-photos.ts/index.ts
  17. 40 0
      src/api/lm-photos.ts/lm-photo.add.ts
  18. 40 0
      src/api/lm-photos.ts/lm-photo.delete.ts
  19. 4 4
      src/api/notifications.ts
  20. 5 0
      src/api/profile/index.ts
  21. 28 0
      src/api/profile/profiles.change-pass.ts
  22. 36 0
      src/api/profile/profiles.delete.ts
  23. 28 0
      src/api/profile/profiles.edit.ts
  24. 42 0
      src/api/profile/profiles.get-own.ts
  25. 28 0
      src/api/profile/profiles.toggle-tips.ts
  26. 0 0
      src/api/query-keys.ts
  27. 3 3
      src/components/Atlas.tsx
  28. 0 0
      src/components/Badge.tsx
  29. 2 1
      src/components/Buttons.tsx
  30. 0 0
      src/components/Count.tsx
  31. 0 0
      src/components/Error.tsx
  32. 0 0
      src/components/Home.tsx
  33. 0 0
      src/components/LandmarkTypePicker.tsx
  34. 0 0
      src/components/Loading.tsx
  35. 0 0
      src/components/PasswordForm.tsx
  36. 1 1
      src/components/PhotoPicker.tsx
  37. 0 0
      src/components/PrivacyLink.tsx
  38. 0 0
      src/components/Separator.tsx
  39. 34 0
      src/components/burger-menu.tsx
  40. 1 1
      src/components/feed/feed.tsx
  41. 0 0
      src/components/maps/Map.styles.tsx
  42. 43 0
      src/components/maps/indoor/floor-change-button.tsx
  43. 0 0
      src/components/maps/indoor/indoor-floor.tsx
  44. 13 0
      src/components/maps/indoor/indoor-map.store.ts
  45. 34 28
      src/components/maps/indoor/indoor-map.view.tsx
  46. 155 0
      src/components/maps/map-navigator.tsx
  47. 4 8
      src/components/maps/map-store.ts
  48. 164 0
      src/components/maps/outdoor/outdoor-map.logic.ts
  49. 202 0
      src/components/maps/outdoor/outdoor-map.view.tsx
  50. 0 0
      src/components/maps/panels/RoutingPanel.tsx
  51. 64 0
      src/components/maps/panels/add-landmark-panel/add-landmark-panel.api.ts
  52. 9 8
      src/components/maps/panels/add-landmark-panel/add-landmark-panel.store.ts
  53. 86 58
      src/components/maps/panels/add-landmark-panel/add-landmark-panel.view.tsx
  54. 0 0
      src/components/maps/panels/filter-panel/filter-lm-types.tsx
  55. 0 0
      src/components/maps/panels/filter-panel/filter-min-rating.tsx
  56. 29 0
      src/components/maps/panels/filter-panel/filter-panel.store.ts
  57. 5 2
      src/components/maps/panels/filter-panel/filter-panel.tsx
  58. 29 0
      src/components/maps/panels/nearby-landmarks-panel/nearby-landmarks-panel.store.ts
  59. 8 30
      src/components/maps/panels/nearby-landmarks-panel/neary-landmarks-panel.tsx
  60. 0 0
      src/components/maps/panels/selected-landmark-panel/CommentView.tsx
  61. 2 2
      src/components/maps/panels/selected-landmark-panel/CommentsContainer.tsx
  62. 1 1
      src/components/maps/panels/selected-landmark-panel/DetailsBody.tsx
  63. 1 1
      src/components/maps/panels/selected-landmark-panel/DetailsHeader.tsx
  64. 0 0
      src/components/maps/panels/selected-landmark-panel/LandmarkPhotos.tsx
  65. 0 0
      src/components/maps/panels/selected-landmark-panel/TouchOpaq.tsx
  66. 0 0
      src/components/maps/panels/selected-landmark-panel/select-landmark-panel.query.ts
  67. 163 0
      src/components/maps/panels/selected-landmark-panel/select-landmark-panel.view.tsx
  68. 300 0
      src/components/maps/panels/selected-landmark-panel/selected-landmark-panel.api.ts
  69. 33 0
      src/components/maps/panels/selected-landmark-panel/selected-landmark-panel.store.ts
  70. 41 0
      src/components/maps/panels/voice-panel/voice-panel.api.tsx
  71. 208 0
      src/components/maps/panels/voice-panel/voice-panel.store.tsx
  72. 192 0
      src/components/maps/panels/voice-panel/voice-panel.view.tsx
  73. 2 2
      src/components/navigation/base-stack-navigator.tsx
  74. 19 21
      src/components/navigation/main-tabs-navigator.tsx
  75. 12 0
      src/components/navigation/navigation-store.ts
  76. 0 0
      src/components/navigation/root-navigator.tsx
  77. 0 0
      src/components/profile/AuthLayout.tsx
  78. 6 8
      src/components/profile/LoginView.tsx
  79. 2 2
      src/components/profile/Profile.tsx
  80. 0 0
      src/components/profile/ProfileHeader.tsx
  81. 0 0
      src/components/profile/ProfileSections.tsx
  82. 0 0
      src/components/profile/ProfileSections/ProfileAbout.tsx
  83. 0 0
      src/components/profile/ProfileSections/ProfileInformation.tsx
  84. 0 0
      src/components/profile/ProfileSections/ProfileLegal.tsx
  85. 0 0
      src/components/profile/ProfileSections/ProfilePrefs.tsx
  86. 0 0
      src/components/profile/ProfileSections/ProfileSection.tsx
  87. 0 0
      src/components/profile/ProfileSections/ProfileSectionHeader.tsx
  88. 0 0
      src/components/profile/ProfileSections/ProfileSkills.tsx
  89. 0 0
      src/components/profile/ProfileSections/ProfileSubscription.tsx
  90. 3 7
      src/components/profile/ProfileTemplate.tsx
  91. 1 1
      src/components/profile/Registration/RegisterMain.tsx
  92. 0 0
      src/components/profile/Registration/RegistrationSteps/RegisterCredential.tsx
  93. 0 0
      src/components/profile/Registration/RegistrationSteps/RegisterImage.tsx
  94. 0 0
      src/components/profile/Registration/RegistrationSteps/RegisterMeasurements.tsx
  95. 0 0
      src/components/profile/Registration/RegistrationSteps/RegisterPassword.tsx
  96. 0 0
      src/components/profile/Styles/Profile.styles.tsx
  97. 0 0
      src/components/profile/Styles/ProfileSections.styles.tsx
  98. 30 0
      src/main-store.ts
  99. 2 3
      src/permissions-context.tsx
  100. 0 0
      src/permissions-store.ts

+ 3 - 3
App.tsx

@@ -10,9 +10,9 @@ import { MenuProvider } from 'react-native-popup-menu';
 import { SafeAreaProvider } from 'react-native-safe-area-context';
 import { QueryClient, QueryClientProvider } from 'react-query';
 import { AuthContextProvider } from './src/state/external/auth-provider';
-import { PermissionsContextProvider } from './src/state/external/PermissionsContext';
-import { navigationRef } from './src/navigation/root-navigator';
-import Atlas from './src/presentation/Atlas';
+import { PermissionsContextProvider } from './src/permissions-context';
+import { navigationRef } from './src/components/navigation/root-navigator';
+import Atlas from './src/components/Atlas';
 import { colors } from './src/utils/GlobalUtils';
 import { LOGGING } from './src/utils/logging';
 

+ 1 - 1
metro.config.js

@@ -10,7 +10,7 @@ module.exports = (async () => {
       babelTransformerPath: require.resolve('react-native-svg-transformer'),
     },
     resolver: {
-      assetExts: assetExts.filter(ext => ext !== 'svg'),
+      assetExts: assetExts.filter(ext => ext !== 'svg').concat(['tflite', 'txt']),
       sourceExts: [...sourceExts, 'svg'],
     },
   };

+ 3 - 0
src/api/api-store.ts

@@ -0,0 +1,3 @@
+class ApiStore {
+    
+}

+ 31 - 0
src/api/comments/comments.add.ts

@@ -0,0 +1,31 @@
+import { useMutation, useQueryClient } from "react-query";
+import { useAuth } from "../../state/external/auth-provider"
+import { queryKeys } from "../query-keys";
+import { LMComment } from "../../types";
+
+export const useAddComment = () => {
+    const {sendApiRequestAsync} = useAuth();
+    const queryClient = useQueryClient()
+
+    const createComment = async (commentValue: LMComment) => {
+        if (commentValue) {
+            const response = await sendApiRequestAsync({
+                axiosConfig: {
+                    method: 'POST',
+                    data: commentValue,
+                    url: `/api/comments/`,
+                },
+                authorized: true,
+                errorMessage: 'Something went wrong when creating a comment',
+                loggingCategory: 'COMMENTS',
+            })
+            response?.data
+        }
+    }
+
+    return useMutation(createComment, {
+        onSuccess: data => {
+            queryClient.invalidateQueries(queryKeys.GET_COMMENTS)
+        },  
+    })
+}

+ 29 - 0
src/api/comments/comments.delete.ts

@@ -0,0 +1,29 @@
+import { useMutation, useQueryClient } from "react-query";
+import { useAuth } from "../../state/external/auth-provider";
+import { queryKeys } from "../query-keys";
+import { API_URL } from "../../utils/RequestUtils";
+
+export const useDeleteComment = () => {
+    const {sendApiRequestAsync} = useAuth()
+    const queryClient = useQueryClient()
+
+    const removeComment =  async (id?: string | null) => {
+        if (id) {
+            const response = await sendApiRequestAsync({
+                axiosConfig: {
+                    method: 'DELETE',
+                    url: API_URL + `/api/comments/${id}`,
+                },
+                authorized: true,
+                errorMessage: 'Something went wrong when deleting a comment',
+                loggingCategory: 'COMMENTS',
+            });   
+            return response?.data;
+        }
+    }
+
+    return useMutation(removeComment, {
+        onSuccess: () => queryClient.invalidateQueries(queryKeys.GET_COMMENTS),  
+        onError: () => queryClient.invalidateQueries(queryKeys.GET_COMMENTS),  
+    })
+}

+ 30 - 0
src/api/comments/comments.edit.ts

@@ -0,0 +1,30 @@
+import { useMutation, useQueryClient } from "react-query"
+import { useAuth } from "../../state/external/auth-provider"
+import { queryKeys } from "../query-keys"
+import { LMComment } from "../../types"
+
+export const useEditComment = () => {
+    const {sendApiRequestAsync} = useAuth()
+    const queryClient = useQueryClient()
+
+    const editComment =  async (commentValue: LMComment) => {
+        if (commentValue) { 
+            const response = await sendApiRequestAsync({
+                axiosConfig: {
+                    method: 'PUT',
+                    data: {...commentValue, edited: true},
+                    url: `/api/comments/`,
+                },
+                authorized: true,
+                errorMessage: 'Something went wrong when editing a comment',
+                loggingCategory: 'COMMENTS',
+            })
+            return response?.data;
+        }
+    }
+
+    return useMutation(editComment, {
+        onSuccess: () => queryClient.invalidateQueries(queryKeys.GET_COMMENTS),  
+        onError: () => queryClient.invalidateQueries(queryKeys.GET_COMMENTS),  
+    })
+}

+ 34 - 0
src/api/comments/comments.get.ts

@@ -0,0 +1,34 @@
+import { useQuery, useQueryClient } from "react-query";
+import { useAuth } from "../../state/external/auth-provider";
+import { queryKeys } from "../query-keys";
+import { LMComment } from "../../types";
+import { API_URL } from "../../utils/RequestUtils";
+
+export const useLandmarkComments = (landmarkId: string) => {
+    const {sendApiRequestAsync} = useAuth();
+    const queryClient = useQueryClient();
+
+    const getCommentsForLandmark = async (landmarkId: string) => {
+        if (landmarkId) { 
+    
+                const response = await sendApiRequestAsync({
+                    axiosConfig: {
+                        method: 'GET',
+                        url: `${API_URL}/api/comments/${landmarkId}`
+                    },
+                    authorized: false,
+                    errorMessage: 'Something went wrong when retrieving comments',
+                    loggingCategory: 'COMMENTS',
+                });
+                return response?.data?.reverse();
+        }
+    }
+
+    return useQuery<LMComment[], Error>([queryKeys.GET_COMMENTS, landmarkId], async () => getCommentsForLandmark(landmarkId), {
+        placeholderData: () => queryClient.getQueryData(queryKeys.GET_COMMENTS),
+        staleTime: 1000,
+        refetchInterval: 30000,
+        refetchOnReconnect: true,
+        refetchOnMount: false
+    })
+}

+ 4 - 0
src/api/comments/index.ts

@@ -0,0 +1,4 @@
+export * from './comments.add'
+export * from './comments.delete'
+export * from './comments.edit'
+export * from './comments.get'

+ 6 - 0
src/api/landmarks/index.ts

@@ -0,0 +1,6 @@
+export * from './landmark.add'
+export * from './landmark.edit'
+export * from './landmark.delete'
+export * from './landmark.get-all'
+export * from './landmark.get-one'
+export * from './landmark.rate'

+ 71 - 0
src/api/landmarks/landmark.add.ts

@@ -0,0 +1,71 @@
+import { useMutation, useQueryClient } from "react-query";
+import { useAuth } from "../../state/external/auth-provider";
+import { queryKeys } from "../query-keys";
+import { AddLandmarkData, Landmark } from "../../types";
+import { LOGGING } from "../../utils/logging";
+
+const landmarkMissingProps = (lm: Landmark) => {
+    const missingProps = []
+
+    Object.keys(lm).forEach(key => {
+        if (!lm[key]) 
+            missingProps.push(key);
+    });
+
+    return missingProps
+}
+
+export const useAddLandmark = () => {
+    const {sendApiRequestAsync, userId, anonUserId} = useAuth()
+    const queryClient = useQueryClient();
+
+    const createLandmark = async (data: AddLandmarkData): Promise<Landmark | undefined> => {
+        if(!data.landmark) {
+            LOGGING.log('ADD-LANDMARK', 'error', 'Landmark data is undefined');
+            return;
+        }
+
+        const missingProps = landmarkMissingProps(data.landmark);
+        if (missingProps.length > 0) {
+            LOGGING.log('ADD-LANDMARK', 'error', 'Landmark data is missing props: ' + missingProps.join(','));
+            return;
+        }
+            
+
+        // attach landmark owner's id
+        if (userId) {
+            data.landmark.user = userId
+        } else if (anonUserId) {
+            data.landmark.anonymous = anonUserId
+        }
+        else {
+            console.warn("[LandmarkData]: Couldn't create landmark, user id or anon id wasn't given.")
+            return
+        }
+
+        console.log(data)
+
+        const response = await sendApiRequestAsync({
+            axiosConfig: {
+                method: 'POST',
+                url: `/api/landmark/`,
+                data: {
+                    landmark: data.landmark,
+                    photos: data.photos,
+                    indoorLmLocImg: data.indoorMapSnapshotBase64
+                },
+            },
+            authorized: true,
+            errorMessage: "Something went wrong when creating a landmark",
+            loggingCategory: "LANDMARKS"
+        });   
+        return response?.data;
+        
+    }
+
+    return useMutation(createLandmark, {
+        onSuccess: landmark => {
+            queryClient.invalidateQueries(queryKeys.GET_LANDMARKS)
+        },  
+    })
+}

+ 34 - 0
src/api/landmarks/landmark.delete.ts

@@ -0,0 +1,34 @@
+import { AxiosRequestConfig } from "axios";
+import { useMutation, useQueryClient } from "react-query";
+import { useAuth } from "../../state/external/auth-provider";
+import { queryKeys } from "../query-keys";
+
+export const useDeleteLandmark = () => {
+    const {sendApiRequestAsync, accessToken, anonUserId} = useAuth()
+    const queryClient = useQueryClient();
+
+    const deleteLandmark =  async (id?: string) => {
+        if (id) {
+            const config: AxiosRequestConfig = {
+                method: 'DELETE',
+                url: `/api/landmark/${id}/`,
+            }
+
+            if (!accessToken) config.data = {...config.data, anonymous: anonUserId}
+
+
+            const response = await sendApiRequestAsync({
+                axiosConfig: config,
+                authorized: true,
+                errorMessage: 'Something went wrong when deleting a landmark',
+                loggingCategory: 'LANDMARKS'
+            });   
+            return response?.data;
+        }
+    }
+
+    return useMutation(deleteLandmark, {
+        onSuccess: () => queryClient.invalidateQueries(queryKeys.GET_LANDMARKS),  
+        onError: () => queryClient.invalidateQueries(queryKeys.GET_LANDMARKS),  
+    })
+}

+ 42 - 0
src/api/landmarks/landmark.edit.ts

@@ -0,0 +1,42 @@
+import { AxiosRequestConfig } from "axios";
+import { useMutation, useQueryClient } from "react-query";
+import { useAuth } from "../../state/external/auth-provider";
+import { queryKeys } from "../query-keys";
+import { Landmark } from "../../types";
+
+export const useEditLandmark = () => {
+    const {sendApiRequestAsync, accessToken, anonUserId} = useAuth()
+    const queryClient = useQueryClient();
+
+    const editLandmark =  async (landmarkValue: Landmark) => {
+        if (landmarkValue) {
+            const config: AxiosRequestConfig = {
+                method: 'PUT',
+                url: `/api/landmark/`,
+                data: {
+                    landmark: landmarkValue
+                }
+            }
+
+            if (!accessToken) {
+                config.data = {...config.data, anonymous: anonUserId}
+            }
+
+            const response = await sendApiRequestAsync({
+                axiosConfig: config, 
+                authorized: true,
+                errorMessage: 'Something went wrong when updating a landmark',
+                loggingCategory: 'LANDMARKS'
+            });   
+            return response?.data;
+        }
+        else {
+            console.warn("[LandmarkData]: Can't update landmark. Given landmark value is null.")
+        }
+    }
+
+    return useMutation(editLandmark, {
+        onSuccess: () => { queryClient.invalidateQueries(queryKeys.GET_LANDMARKS)},  
+        onError: () => queryClient.invalidateQueries(queryKeys.GET_LANDMARKS),  
+    })
+}

+ 40 - 0
src/api/landmarks/landmark.get-all.ts

@@ -0,0 +1,40 @@
+import { useQuery, useQueryClient } from "react-query";
+
+/**
+ * Interface representing a landmark object
+ */
+
+import { useAuth } from "../../state/external/auth-provider";
+import { queryKeys } from "../query-keys";
+import { Landmark } from "../../types";
+
+/**
+ * A custom hook containing [react-query]{@link https://react-query.tanstack.com/} queries and mutations and other logic related to interacting with {@link Landmark} objects.
+ * @category Hooks
+ * @namespace useLandmarks
+ */
+ export const useLandmarks = () => {
+    const { sendApiRequestAsync } = useAuth();
+    const queryClient = useQueryClient();
+
+    const getLandmarks = async () => {
+        const response = await sendApiRequestAsync({
+           axiosConfig:  {
+                method: 'GET',
+                url: `/api/landmarks/`,
+            }, 
+            authorized: false,
+            errorMessage: 'Something went wrong when retrieving landmarks',
+            loggingCategory: 'LANDMARKS'
+        });   
+        return response?.data
+    }
+
+    return useQuery<Landmark[], Error>(queryKeys.GET_LANDMARKS, () => getLandmarks(), {
+        placeholderData: () => queryClient.getQueryData(queryKeys.GET_LANDMARKS),
+        staleTime: 1000,
+        refetchInterval: 30000,
+        refetchOnReconnect: true,
+        refetchOnMount: false
+    })
+}

+ 30 - 0
src/api/landmarks/landmark.get-one.ts

@@ -0,0 +1,30 @@
+import { useQuery, useQueryClient } from "react-query";
+import { useAuth } from "../../state/external/auth-provider";
+import { queryKeys } from "../query-keys";
+import { Landmark } from "../../types";
+
+export const useLandmark = (landmarkId: string) => {
+    const {sendApiRequestAsync, userId} = useAuth()
+    const queryClient = useQueryClient();
+
+     const getLandmark = async (landmarkId?: string) => {
+        if (landmarkId) {
+            const response = await sendApiRequestAsync({
+                axiosConfig: {
+                    method: 'GET',
+                    url: `/api/landmark/${landmarkId}/`,
+                },
+                authorized: !!userId,
+                errorMessage: 'Something went wrong when retrieving the landmark',
+                loggingCategory: 'LANDMARKS'
+            });   
+            return response?.data 
+        }
+    }
+
+    return useQuery<{landmark: Landmark, ratedByUser: boolean}, Error>([queryKeys.GET_LANDMARKS, landmarkId], () => getLandmark(landmarkId), {
+        placeholderData: () => queryClient.getQueryData(queryKeys.GET_LANDMARKS),
+        refetchOnReconnect: true,
+        refetchOnMount: false
+    })
+}

+ 31 - 0
src/api/landmarks/landmark.rate.ts

@@ -0,0 +1,31 @@
+import { useMutation, useQueryClient } from "react-query";
+import { useAuth } from "../../state/external/auth-provider";
+import { queryKeys } from "../query-keys";
+
+export const useRateLandmark = () => {
+    const {sendApiRequestAsync} = useAuth()
+    const queryClient = useQueryClient();
+
+    const rateLandmark =  async (data: {id: string, rating: 1 | -1}) => {
+        if (data) {
+            const response = await sendApiRequestAsync({
+                axiosConfig:{
+                    method: 'POST',
+                    data: data,
+                    url: `/api/landmark/rate/`,
+                },
+                authorized: true,
+                errorMessage: 'Something went wrong when rating a landmark',
+                loggingCategory: 'LANDMARKS'
+            });   
+            return response?.data?.rating;
+        }
+    }
+
+    return useMutation(rateLandmark, {
+        onSuccess: () => { 
+            queryClient.invalidateQueries(queryKeys.GET_LANDMARKS)
+        },  
+        onError: () => queryClient.invalidateQueries(queryKeys.GET_LANDMARKS),  
+    })    
+}

+ 2 - 0
src/api/lm-photos.ts/index.ts

@@ -0,0 +1,2 @@
+export * from './lm-photo.add'
+export * from './lm-photo.delete'

+ 40 - 0
src/api/lm-photos.ts/lm-photo.add.ts

@@ -0,0 +1,40 @@
+import { AxiosRequestConfig } from "axios";
+import { useMutation, useQueryClient } from "react-query";
+import { useAuth } from "../../state/external/auth-provider";
+import { queryKeys } from "../query-keys";
+import { LMPhoto } from "../../types";
+
+export const useAddLandmarkPhoto = () => {
+    const {sendApiRequestAsync, accessToken, anonUserId} = useAuth()
+    const queryClient = useQueryClient();
+
+    const addLandmarkPhoto = async (photo: LMPhoto) => {
+        if (photo) {
+            const config: AxiosRequestConfig = {
+                method: 'POST',
+                url: `/api/landmark/photos/`,
+                data: photo
+            }
+        
+            if (!accessToken) config.data = {...config.data, anonymous: anonUserId}
+
+            const response = await sendApiRequestAsync({
+                axiosConfig: config, 
+                authorized: true,
+                errorMessage: 'Something went wrong when adding landmark photo',
+                loggingCategory: 'LANDMARKS'
+            });   
+            return response?.data
+        }
+    }
+
+    return useMutation(addLandmarkPhoto, {
+        onSuccess: () => {
+            queryClient.invalidateQueries(queryKeys.GET_LANDMARKS)
+        },
+        
+        onError: () => {
+            queryClient.invalidateQueries(queryKeys.GET_LANDMARKS)
+        },
+    })
+}

+ 40 - 0
src/api/lm-photos.ts/lm-photo.delete.ts

@@ -0,0 +1,40 @@
+import { AxiosRequestConfig } from "axios";
+import { useMutation, useQueryClient } from "react-query";
+import { useAuth } from "../../state/external/auth-provider";
+import { queryKeys } from "../query-keys";
+
+export const useDeleteLandmarkPhoto = () => {
+    const {sendApiRequestAsync, accessToken, anonUserId} = useAuth()
+    const queryClient = useQueryClient();
+
+    const deleteLandmarkPhoto = async (photoId: string) => {
+        if (photoId) {
+            const config: AxiosRequestConfig = {
+                method: 'DELETE',
+                url: `/api/landmark/photos/${photoId}/`
+            }
+
+            if (!accessToken) {
+                config.data = {...config.data, anonymous: anonUserId}
+            }
+
+            const response = await sendApiRequestAsync({
+                axiosConfig: config,
+                authorized: true,
+                errorMessage: 'Something went wrong when deleting landmark photos',
+                loggingCategory: 'LANDMARKS'
+            });   
+            return response?.data
+        }
+    }
+
+    return useMutation(deleteLandmarkPhoto, {
+        onSuccess: () => {
+            queryClient.invalidateQueries(queryKeys.GET_LANDMARKS)
+        },
+        
+        onError: () => {
+            queryClient.invalidateQueries(queryKeys.GET_LANDMARKS)
+        },
+    })
+}

+ 4 - 4
src/state/external/notifications.ts → src/api/notifications.ts

@@ -3,11 +3,11 @@ import * as Notifications from "expo-notifications";
 import { getItemAsync, setItemAsync } from "expo-secure-store";
 import { Platform } from "react-native";
 import { useMutation, useQuery, useQueryClient } from "react-query";
-import { navigate } from "../../navigation/root-navigator";
-import { LOGGING } from "../../utils/logging";
-import { SECURESTORE_NOTIFTOKEN, useAuth } from "./auth-provider";
+import { navigate } from "../components/navigation/root-navigator";
+import { LOGGING } from "../utils/logging";
+import { SECURESTORE_NOTIFTOKEN, useAuth } from "../state/external/auth-provider";
 import { useLandmarks } from "./landmarks";
-import { usePermissions } from "./PermissionsContext";
+import { usePermissions } from "../permissions-context";
 import { queryKeys } from "./query-keys";
 
 export interface UserNotification {

+ 5 - 0
src/api/profile/index.ts

@@ -0,0 +1,5 @@
+export * from './profiles.change-pass'
+export * from './profiles.delete'
+export * from './profiles.edit'
+export * from './profiles.get-own'
+export * from './profiles.toggle-tips'

+ 28 - 0
src/api/profile/profiles.change-pass.ts

@@ -0,0 +1,28 @@
+import { useMutation, useQueryClient } from "react-query";
+import { useAuth } from "../../state/external/auth-provider";
+import { queryKeys } from "../query-keys";
+
+export const useChangePassword = () => {
+    const queryClient = useQueryClient();
+    const {sendApiRequestAsync, userId} = useAuth()
+    const changePassword = async (password: string) => {
+        if (password && userId) { 
+            const response = await sendApiRequestAsync({
+                axiosConfig: {
+                    method: 'POST',
+                    url: `/api/user-profile/change-password/${userId}/`,
+                    data: {password: password},
+                }, 
+                authorized: true,
+                errorMessage: 'Something went wrong when changing password',
+                loggingCategory: 'PROFILE',
+            });   
+            return response.data;
+        }
+    }
+
+    return useMutation(changePassword, {
+        onSuccess: () => { queryClient.invalidateQueries(queryKeys.GET_OWN_PROFILE)},  
+        onError: () => queryClient.invalidateQueries(queryKeys.GET_OWN_PROFILE),  
+    })
+}

+ 36 - 0
src/api/profile/profiles.delete.ts

@@ -0,0 +1,36 @@
+import { useAuth } from "../../state/external/auth-provider";
+import { useOwnedProfile } from "./profiles.get-own";
+import { useMutation, useQueryClient } from "react-query";
+import { API_URL } from "../../utils/RequestUtils";
+import { queryKeys } from "../query-keys";
+
+export const useDeleteProfile = () => {
+    const {clearProfile} = useOwnedProfile()
+    const {clearAuthStorage} = useAuth()
+    const queryClient = useQueryClient();
+    const {sendApiRequestAsync, userId} = useAuth()
+
+    const deleteProfile = async () => {
+        if (userId) { 
+            const response = await sendApiRequestAsync({
+                axiosConfig: {
+                    method: 'DELETE',
+                    url: API_URL + `/api/user-profile/${userId}/`,
+                },
+                authorized: true,
+                errorMessage: 'Something went wrong when deleting profile',
+                loggingCategory: 'PROFILE',
+            });   
+            
+            if (response.status == 200) {
+                await clearProfile()
+                await clearAuthStorage()
+            }
+        }
+    }
+
+    return useMutation(deleteProfile, {
+        onSuccess: () => { queryClient.invalidateQueries(queryKeys.GET_OWN_PROFILE)},  
+        onError: () => queryClient.invalidateQueries(queryKeys.GET_OWN_PROFILE),  
+    })
+}

+ 28 - 0
src/api/profile/profiles.edit.ts

@@ -0,0 +1,28 @@
+import { useMutation, useQueryClient } from "react-query";
+import { useAuth } from "../../state/external/auth-provider";
+import { queryKeys } from "../query-keys";
+import { RegisterCredsValues } from "../../utils/RegistrationUtils";
+import { API_URL } from "../../utils/RequestUtils";
+
+export const useEditProfile = () => {
+    const queryClient = useQueryClient();
+    const {sendApiRequestAsync, userId} = useAuth()
+
+     const editProfile = async (values: RegisterCredsValues) => {
+        const response = await sendApiRequestAsync({
+            axiosConfig: {
+                method: 'PUT',
+                url: API_URL + `/api/user-profile/${userId}/`,
+                data: values,
+            },
+            authorized: true,
+            errorMessage: 'Something went wrong when editing profile',
+            loggingCategory: 'PROFILE',
+        });   
+        return response.data;
+    }
+    return useMutation(editProfile, {
+        onSuccess: () => { queryClient.invalidateQueries(queryKeys.GET_OWN_PROFILE)},  
+        onError: () => queryClient.invalidateQueries(queryKeys.GET_OWN_PROFILE),  
+    })
+}

+ 42 - 0
src/api/profile/profiles.get-own.ts

@@ -0,0 +1,42 @@
+import { useQuery, useQueryClient } from "react-query";
+import { useAuth } from "../../state/external/auth-provider";
+import { UserProfile } from "../../types";
+import { queryKeys } from "../query-keys";
+
+/**
+ * A custom hook containing [react-query]{@link https://react-query.tanstack.com/} queries and mutations and other logic related to interacting with {@link UserProfile} objects.
+ * @category Hooks
+ * @namespace useProfile
+ */
+ export const useOwnedProfile = () => {
+    const queryClient = useQueryClient();
+    const {sendApiRequestAsync, accessToken, userId} = useAuth()
+
+    const getOwnedProfile = async () => {
+        const response = await sendApiRequestAsync({
+            axiosConfig: {
+                method: 'GET',
+                url: `/api/user-profile/${userId}/`,
+            }, 
+            authorized: true,
+            errorMessage: 'Something went wrong when retrieving user profile',
+            loggingCategory: 'PROFILE',
+        });   
+        return response.data;
+    }
+
+    const {data: profile} = useQuery<UserProfile, Error>(queryKeys.GET_OWN_PROFILE, getOwnedProfile, {
+        enabled: !!userId && !!accessToken
+    })
+
+    const changeProfile = async (profile: UserProfile) => {
+        queryClient.setQueriesData(queryKeys.GET_OWN_PROFILE, profile)
+    }
+
+    const clearProfile = async () => {
+        queryClient.setQueryData(queryKeys.GET_OWN_PROFILE, null);
+        queryClient.removeQueries();
+    }
+
+ return {profile, changeProfile, clearProfile}
+}

+ 28 - 0
src/api/profile/profiles.toggle-tips.ts

@@ -0,0 +1,28 @@
+import { useAuth } from "../../state/external/auth-provider";
+import { useMutation, useQueryClient } from "react-query";
+import { queryKeys } from "../query-keys";
+
+export const useToggleTips = () => {
+    const {sendApiRequestAsync, userId} = useAuth()
+    const queryClient = useQueryClient();
+
+    const toggleTips = async () => {
+        if (userId) { 
+            const response = await sendApiRequestAsync({
+                axiosConfig: {
+                    method: 'POST',
+                    url: `/api/user-profile/toggle-tips/${userId}/`,
+                },
+                authorized: true,
+                errorMessage: 'Something went wrong when toggling tips',
+                loggingCategory: 'PROFILE',
+            });   
+            return response.data;
+        }
+    }
+
+    return useMutation(toggleTips, {
+        onSuccess: () => { queryClient.invalidateQueries(queryKeys.GET_OWN_PROFILE)},  
+        onError: () => queryClient.invalidateQueries(queryKeys.GET_OWN_PROFILE),  
+    })
+}

+ 0 - 0
src/state/external/query-keys.ts → src/api/query-keys.ts


+ 3 - 3
src/presentation/Atlas.tsx → src/components/Atlas.tsx

@@ -10,11 +10,11 @@ import React from 'react';
 import { QueryClient } from 'react-query';
 import { Loading } from './Loading';
 import { useAuth } from '../state/external/auth-provider';
-import BaseStackNavigator from '../navigation/base-stack-navigator';
+import BaseStackNavigator from './navigation/base-stack-navigator';
 import { Error } from './Error';
 
-import { navigationRef } from '../navigation/root-navigator';
-import { usePermissions } from '../state/external/PermissionsContext';
+import { navigationRef } from './navigation/root-navigator';
+import { usePermissions } from '../permissions-context';
 
 export enum TokenState {
   CheckingToken,

+ 0 - 0
src/presentation/Badge.tsx → src/components/Badge.tsx


+ 2 - 1
src/presentation/Buttons.tsx → src/components/Buttons.tsx

@@ -47,10 +47,11 @@ export const SecondaryButton: React.FC<TextButtonProps> = ({text, onPress, style
     </TouchableOpacity>)
 }
 
-export const IconButton: React.FC<IconButtonProps> = ({onPress, style, size, color, icon}) => {
+export const IconButton: React.FC<IconButtonProps> = ({onPress, style, size, color, icon, children}) => {
     return(
         <TouchableOpacity style={style} onPress={onPress}>
             <FontAwesome size={size} color={color} name={icon as any}/>
+            {children}
         </TouchableOpacity>
     )
 }

+ 0 - 0
src/presentation/Count.tsx → src/components/Count.tsx


+ 0 - 0
src/presentation/Error.tsx → src/components/Error.tsx


+ 0 - 0
src/presentation/Home.tsx → src/components/Home.tsx


+ 0 - 0
src/presentation/LandmarkTypePicker.tsx → src/components/LandmarkTypePicker.tsx


+ 0 - 0
src/presentation/Loading.tsx → src/components/Loading.tsx


+ 0 - 0
src/presentation/PasswordForm.tsx → src/components/PasswordForm.tsx


+ 1 - 1
src/presentation/PhotoPicker.tsx → src/components/PhotoPicker.tsx

@@ -10,7 +10,7 @@ import { ImageInfo } from "expo-image-picker/build/ImagePicker.types"
 import React, { useEffect, useState } from "react"
 import {Alert, AlertButton, Linking, Platform, Text} from 'react-native'
 import { Menu, MenuOption, MenuOptions, MenuTrigger, renderers } from "react-native-popup-menu"
-import { usePermissions } from "../state/external/PermissionsContext"
+import { usePermissions } from "../permissions-context"
 import { colors, getMediaPermissions } from "../utils/GlobalUtils"
 const {SlideInMenu} = renderers
 

+ 0 - 0
src/presentation/PrivacyLink.tsx → src/components/PrivacyLink.tsx


+ 0 - 0
src/presentation/Separator.tsx → src/components/Separator.tsx


+ 34 - 0
src/components/burger-menu.tsx

@@ -0,0 +1,34 @@
+import React, { useState } from "react"
+import { View, ViewStyle } from "react-native"
+import { Menu, MenuItem, MenuItemProps, MenuProps } from "react-native-material-menu"
+import { colors } from "../utils/GlobalUtils"
+import { IconButton } from "./Buttons"
+
+interface HamburgerMenuItem {
+    onPress: () => void
+    text: string;
+    disabled?: boolean;
+    extraProps?: Omit<MenuItemProps, 'children'>;
+}
+
+interface BurgerMenuProps {
+    menuItems: HamburgerMenuItem[];
+    styles?: ViewStyle;
+}
+
+export const BurgerMenu: React.FC<BurgerMenuProps> = ({menuItems, styles}) => {
+    const [visible, setVisible] = useState<boolean>();
+    return (
+        <View style={{ top: 100, right: 7.5, position: 'absolute'}}>
+            <Menu
+                visible={visible}
+                anchor={<IconButton size={16} color={colors.red} style={styles} icon="bars" onPress={() => setVisible(true)} />}
+                onRequestClose={() => setVisible(false)}>
+                {menuItems.map(item => {
+                    return <MenuItem {...item.extraProps} disabled={item.disabled} onPress={item.onPress}>{item.text}</MenuItem>
+                })}
+            </Menu>
+        </View>
+    )
+}
+    

+ 1 - 1
src/presentation/feed/feed.tsx → src/components/feed/feed.tsx

@@ -10,7 +10,7 @@ import React from "react"
 import { ListRenderItem, Text, View } from "react-native"
 import { FlatList, TouchableOpacity } from "react-native-gesture-handler"
 import { useAuth } from "../../state/external/auth-provider"
-import { useDeleteNotification, UserNotification } from "../../state/external/notifications"
+import { useDeleteNotification, UserNotification } from "../../api/notifications"
 
 /**
  * This component displays all the user's notifications sorted by most recent

+ 0 - 0
src/presentation/maps/Map.styles.tsx → src/components/maps/Map.styles.tsx


+ 43 - 0
src/components/maps/indoor/floor-change-button.tsx

@@ -0,0 +1,43 @@
+import { FontAwesome } from "@expo/vector-icons";
+import React from 'react';
+import { StyleSheet, TouchableOpacity, View } from 'react-native';
+import { store } from "../../../main-store";
+import { colors } from "../../../utils/GlobalUtils";
+
+interface FloorChangeButtonProps {
+  floorChange: -1 | 1 | 0;
+}
+
+const FloorChangeButton: React.FC<FloorChangeButtonProps> = ({floorChange}) => {
+  const icon = (() => floorChange == 1 ? "chevron-right" : floorChange == -1 ? "chevron-left" : "")();
+  const iconMargin = (() => store.mapIndoor.floorNumber == 1 ? 5: -5)();
+
+  if (store.mapIndoor.floorNumber != 0) {
+      return (
+          <TouchableOpacity style={styles.arrowButton} onPress={() => store.mapIndoor.changeFloor(floorChange)} >
+            <View style={{ flex: 1, justifyContent: "center", alignItems: "center" }}>
+              <FontAwesome style={{ marginLeft: iconMargin }} color={"white"} size={20} name={icon as any} />
+            </View>
+          </TouchableOpacity>
+      )
+    }
+    else {
+      return (
+        <View style={{ flex: 1.2, marginHorizontal: 7, height: 53.5, maxWidth: 60, borderRadius: 10 }}/>
+      )
+    }
+}
+
+
+const styles = StyleSheet.create({
+    arrowButton: {
+        flex: 1,
+        backgroundColor: colors.red,
+        height: 53.5,
+        borderRadius: 8,
+      },
+})
+
+
+
+export default FloorChangeButton

+ 0 - 0
src/presentation/maps/indoor/indoor-floor.tsx → src/components/maps/indoor/indoor-floor.tsx


+ 13 - 0
src/components/maps/indoor/indoor-map.store.ts

@@ -0,0 +1,13 @@
+import { makeAutoObservable } from "mobx";
+
+export class IndoorMapStore {
+    floorNumber: number;
+
+    constructor() {
+        makeAutoObservable(this);
+    }
+
+    changeFloor(floorChange: -1 | 1 | 0) {
+        this.floorNumber += floorChange;
+    }
+}

+ 34 - 28
src/presentation/maps/indoor/IndoorMap.tsx → src/components/maps/indoor/indoor-map.view.tsx

@@ -1,49 +1,47 @@
-import { FontAwesome } from "@expo/vector-icons";
 import ReactNativeZoomableView from '@openspacelabs/react-native-zoomable-view/src/ReactNativeZoomableView';
 import React, { useState } from 'react';
-import { Alert, Dimensions, GestureResponderEvent, ImageSourcePropType, StatusBar, StyleSheet, View } from 'react-native';
+import { Alert, Dimensions, GestureResponderEvent, ImageSourcePropType, Linking, StatusBar, StyleSheet, View } from 'react-native';
 import Picker from 'react-native-picker-select';
 import Toast from 'react-native-root-toast';
 import { Image, Svg } from 'react-native-svg';
-import { Landmark } from '../../../state/external/landmarks';
-import { MapStackNavigationProp } from "../MapNavigator";
+import { useLandmarks } from '../../../api/landmarks';
+import { store } from "../../../main-store";
+import { Landmark } from "../../../types";
 import { colors, lmTypesIndoor } from "../../../utils/GlobalUtils";
+import { BurgerMenu } from '../../burger-menu';
+import FloorChangeButton from "./floor-change-button";
 import IndoorFloor from './indoor-floor';
-import ArrowButton from './arrow-button';
-
-interface IndoorMapProps {
-  navigation: MapStackNavigationProp;
-  landmarks: Landmark[];
-  promptAddLandmark: (longitude?: number, latitude?: number, floor?: number) => void;
-  focusLandmark: (landmark: Landmark) => void;
-  applyFilter: (landmarks: Landmark[]) => Landmark[];
-}
+
+const UALBERTA_EMERG = 'https://www.ualberta.ca/facilities-operations/portfolio/emergency-management-office/emergency-procedures/alarms-evacuation.html'
+const UALBERTA_HOME = 'https://www.library.ualberta.ca/'
 
 const landmarkPinSize = 0.05 * Dimensions.get("window").width;
 
-const IndoorMap: React.FC<IndoorMapProps> = ({ landmarks, promptAddLandmark, focusLandmark, applyFilter }) => {
+const IndoorMap: React.FC = () => {
   const [floor, setFloor] = useState(1);
   const [SVGdim, setSVGdim] = useState([1, 1])
   const [localLandmarks, setLocalLandmarks] = useState<Landmark[]>([])
+  const {data: landmarks} = useLandmarks();
 
-  const renderedLandmarks = applyFilter(landmarks)?.map((item) => {
-    if (!lmTypesIndoor[item.landmark_type]) {
-      return null
-    }
-    if (item.floor == floor && SVGdim[0] != 1 && SVGdim[1] != 1) {
+  const renderedLandmarks = applyFilter(landmarks)?.filter(lm => lm.floor == floor && lmTypesIndoor[lm.landmark_type]).map((lm) => {
+    console.log(landmarks[0])
+    if (SVGdim[0] != 1 && SVGdim[1] != 1) {
       return (
         <Image
-          onPress={() => focusLandmark(item)}
-          key={item.id}
-          x={item.longitude * SVGdim[0]}
-          y={item.latitude * SVGdim[1]}
+          onPress={() => focusLandmark(lm)}
+          key={lm.id}
+          x={lm.longitude * SVGdim[0]}
+          y={lm.latitude * SVGdim[1]}
           width={landmarkPinSize}
           height={landmarkPinSize}
-          href={lmTypesIndoor[item.landmark_type].image as ImageSourcePropType} />
+          href={lmTypesIndoor[lm.landmark_type].image as ImageSourcePropType} />
       )
     }
   })
 
+  
+  console.log(landmarks.length, renderedLandmarks.length)
+
 
   function addLandmark(evt: GestureResponderEvent) {
     if (evt != null) {
@@ -75,6 +73,9 @@ const IndoorMap: React.FC<IndoorMapProps> = ({ landmarks, promptAddLandmark, foc
     return <Tag {...props}>{toWeb(props.children)}</Tag>;
   };
 
+  const leftFloorChange = (() => store.mapIndoor.floorNumber == 0 ? 0 : -1)()
+  const rightFloorChange = (() => store.mapIndoor.floorNumber == 5 ? 0 : 1)()
+
   const toWeb = (children: any) => React.Children.map(children, childToWeb);
   function changer(num) {
     setFloor(prevState => prevState + num)
@@ -83,6 +84,13 @@ const IndoorMap: React.FC<IndoorMapProps> = ({ landmarks, promptAddLandmark, foc
   // TODO: wire up promptaddlandmark, applyfilters, and focuslandmark methods passed from MapNavigator
   return (
     <View style={{ height: '100%', width: Dimensions.get("screen").width, backgroundColor:colors.red }}>
+      <BurgerMenu 
+        styles={{top: 100, right: 7.5, position: 'absolute'}}
+        menuItems={[
+          {onPress: () => navigate("Outdoor"), text: 'Go Back Outdoors'},
+          {onPress: () => Linking.openURL(UALBERTA_EMERG), text: 'Emergency Procedures'},
+          {onPress: () => Linking.openURL(UALBERTA_HOME), text: 'Resources'}
+        ]}/>
 
       <StatusBar backgroundColor={colors.red} />
       {/* <CustomModal /> */}
@@ -90,7 +98,7 @@ const IndoorMap: React.FC<IndoorMapProps> = ({ landmarks, promptAddLandmark, foc
 
       <View style={{ borderColor: "blue", borderWidth: 0, maxHeight: 50, flex: 1, flexDirection: "row", justifyContent: "center", }}>
 
-        {floor == 0 ? <ArrowButton num={0} fontAweIcon={""} /> : <ArrowButton num={-1} fontAweIcon={"chevron-left"} propEvent={() => changer(-1)} />}
+        <FloorChangeButton floorChange={leftFloorChange} />
 
         <View style={{flex: 5, height: 53.5, width: 200 }}>
           <Picker
@@ -117,9 +125,7 @@ const IndoorMap: React.FC<IndoorMapProps> = ({ landmarks, promptAddLandmark, foc
           />
         </View>
 
-
-        {/* {floor == 5 ? arrowBut(0, "") : arrowBut(1, "chevron-right")} */}
-        {floor == 5 ? <ArrowButton num={0} fontAweIcon={""} /> : <ArrowButton num={1} fontAweIcon={"chevron-right"} propEvent={() => changer(1)} />}
+        <FloorChangeButton floorChange={rightFloorChange} />
 
 
       </View>

+ 155 - 0
src/components/maps/map-navigator.tsx

@@ -0,0 +1,155 @@
+import { FontAwesome } from "@expo/vector-icons"
+import { useNavigation, useNavigationState, useRoute } from "@react-navigation/native"
+import { createNativeStackNavigator } from "@react-navigation/native-stack"
+import { StackNavigationProp } from "@react-navigation/stack"
+import { observer } from "mobx-react"
+import React, { useState } from "react"
+import { Image, Linking, ScrollView, StyleProp, StyleSheet, Text, View, ViewStyle } from "react-native"
+import { Menu, MenuDivider, MenuItem } from 'react-native-material-menu'
+import { Chip } from "react-native-paper"
+import { store } from "../../main-store"
+import { useAuth } from "../../state/external/auth-provider"
+import { colors, lmTypes } from "../../utils/GlobalUtils"
+import { IconButton } from "../Buttons"
+import { MainTabsNavigationProp } from "../navigation/main-tabs-navigator"
+import { navigate } from "../navigation/root-navigator"
+import IndoorMap from "./indoor/indoor-map.view"
+import mapStyles from "./Map.styles"
+import OutdoorMap, { AuthTabsMapRouteProp } from "./outdoor/outdoor-map.view"
+import AddLandmarkPanel from "./panels/add-landmark-panel/add-landmark-panel.view"
+import { FilterPanel } from "./panels/filter-panel/filter-panel"
+import LandmarkDetails from "./panels/selected-landmark-panel/select-landmark-panel.view"
+
+
+export type MapStackParamList = {
+    Outdoor: { selectedLandmark: string, selectedLandmarks: string[] },
+    Indoor: React.FC,
+}
+
+const MapStackNavigator = createNativeStackNavigator()
+
+export type MapStackNavigationProp = StackNavigationProp<MapStackParamList>
+
+interface FilterChipProps {
+    content: string;
+    onClose: () => void;
+    visible: boolean;
+    avatar: JSX.Element;
+    style: StyleProp<ViewStyle>
+}
+
+const FilterChip: React.FC<FilterChipProps> = ({onClose, content, visible, avatar}) => {
+    return (
+        <>
+            {visible && 
+            <Chip 
+                avatar={avatar} 
+                style={styles.filterChip} 
+                onClose={() => onClose()}>
+                {content}
+            </Chip>}
+        </>
+    )
+}
+
+const LandmarkTypeFilterChips = () => {
+    const filteredTypes = store.filters.current['landmark-types'] as number[];
+    const removeLandmarkTypeFilter = (landmarkType: number) => {
+        store.filters.setFilters([{
+            type: 'landmark-types', 
+            value: (store.filters.current["landmark-types"] as number[]).filter(t => t !== landmarkType)
+        }])
+    }
+
+    return (store.filters.current['landmark-types'] as number[]).map((type, i) => {
+        return (
+            <FilterChip 
+                key={i} 
+                avatar={(<Image style={{ height: 22, width: 17 }} source={lmTypes[type].image} />)} 
+                style={styles.landmarkTypeFilterChip} 
+                onClose={() => removeLandmarkTypeFilter(type)} 
+                content={lmTypes[type].label}
+                visible={filteredTypes.length > 0}/>
+        )
+    })
+}
+
+const FilterChips: React.FC = () => {
+
+    return (
+        <ScrollView horizontal={true} contentContainerStyle={{ alignItems: 'center' }} style={{ marginHorizontal: 10, flexDirection: 'row' }}>
+            {/* Min rating filter chip */}
+            <FilterChip 
+                visible={store.filters.current["min-rating"] > 0} 
+                content={"Min rating: " + store.filters.current['min-rating']}
+                onClose={() => store.filters.setFilters([{type: 'min-rating', value: 0}])}
+                avatar={(<FontAwesome name="star" size={20} color='gray' style={styles.filterChipIcon} />)}
+                style={styles.filterChip}/>
+
+            
+            <FilterChip 
+                visible={store.filters.current['only-owned'] as boolean} 
+                content={"My landmarks"}
+                onClose={() => store.filters.setFilters([{type: 'only-owned', value: false}])}
+                avatar={(<FontAwesome name="user" size={20} color='gray' style={styles.filterChipIcon} />)}
+                style={styles.filterChip}/>
+
+            {LandmarkTypeFilterChips()}
+        </ScrollView>
+    )
+}
+
+
+
+const MapNavigator: React.FC = () => {
+    const { landmarkOwnedByUser } = useAuth()
+    const authNavigation = useNavigation() as MainTabsNavigationProp
+    const authRoute = useRoute() as AuthTabsMapRouteProp
+    const navigationState = useNavigationState(state => state)
+    const [visible, setVisible] = useState(false);
+
+    // TODO: apply current filters when landmark data gets updated
+    
+    return (
+        <View style={{ flex: 1 }}>
+            <MapStackNavigator.Navigator screenOptions={{ headerShown: false }} initialRouteName="Outdoor">
+                <MapStackNavigator.Screen name="Outdoor" component={OutdoorMap} />
+                <MapStackNavigator.Screen name="Indoor" component={IndoorMap}/>
+            </MapStackNavigator.Navigator>
+
+
+
+            {/* Filter chips and button*/}
+            {store.filters.panelVisible ?
+                <View style={{ top: 60, right: 7.5, position: 'absolute', flexDirection: "row-reverse", justifyContent: 'flex-end' }}>
+                    <IconButton size={16} color={colors.red} style={[mapStyles.filterButtonIndoor]} icon="filter" onPress={() => store.filters.panelVisible} />
+                    <FilterChips />
+                </View>
+                :
+                <View style={{ top: 10, marginLeft: 40, marginRight: 20, position: 'absolute', flexDirection: "row-reverse", justifyContent: 'flex-end' }}>
+                    <IconButton size={20} color={colors.red} style={[mapStyles.filterButtonOutdoor]} icon="filter" onPress={() => store.filters.openPanel()} />
+                    
+                </View>
+            }
+
+            <AddLandmarkPanel/>
+            <LandmarkDetails/>
+            <FilterPanel
+                visible={mapState.filterVisible}
+                toggleOnlyOwned={mapState.toggleOnlyOwned}
+                onlyOwned={mapState.onlyOwned} toggleFilter={mapState.toggleFilter}
+                setMinLmRating={mapState.setMinLmRating}
+                setLmFilteredTypes={mapState.setLmTypeFilter}
+                lmFilteredTypes={mapState.lmFilteredTypes}
+                minLmRating={mapState.minLmRating} />
+        </View>
+    )
+}
+
+const styles = StyleSheet.create({
+    filterChipIcon: { textAlign: 'center', textAlignVertical: 'center' },
+    filterChip: { borderWidth: 1, borderColor: 'lightgray', marginRight: 5, marginLeft: 10 },
+    landmarkTypeFilterChip: { borderWidth: 1, borderColor: 'lightgray', marginHorizontal: 5, justifyContent: 'center' }
+})
+
+export default observer(MapNavigator)

+ 4 - 8
src/presentation/maps/outdoor/outdoor-map.store.ts → src/components/maps/map-store.ts

@@ -1,6 +1,6 @@
-import { makeAutoObservable } from "mobx"
+import { makeAutoObservable } from "mobx";
 
-class OutdoorMapStore {
+export class MapStore {
     addLandmarkPanelVisible: boolean = false
 
     constructor() {
@@ -9,9 +9,5 @@ class OutdoorMapStore {
 
     setAddLandmarkPanelVisible(state: boolean) {
         this.addLandmarkPanelVisible = state;
-    }
-
-    
-}
-
-export default new OutdoorMapStore()
+    }   
+}

+ 164 - 0
src/components/maps/outdoor/outdoor-map.logic.ts

@@ -0,0 +1,164 @@
+import { useNavigation, useNavigationState } from "@react-navigation/native";
+import { booleanPointInPolygon, circle } from "@turf/turf";
+import { useCallback, useEffect, useRef, useState } from "react";
+import MapView, { LatLng } from "react-native-maps";
+import { openSettings } from "react-native-permissions";
+import * as Spokestack from 'react-native-spokestack';
+import { useLandmarks } from "../../../api/landmarks";
+import { store } from "../../../main-store";
+import { usePermissions } from "../../../permissions-context";
+import { useAuth } from "../../../state/external/auth-provider";
+import { MapStackNavigationProp } from "../map-navigator";
+
+export interface UserLocation {
+    latitude: number;
+    longitude: number;
+    heading?: number;
+}
+
+export const useOutdoorMap = () => {
+    const {locationPermissionsGranted, voicePermissionsGranted} = usePermissions();
+    const {setAlert} = useAuth();
+
+    const [loading, setLoading] = useState<boolean>(false);
+    const [userLocation, setUserLocation] = useState<LatLng>();
+    const [followUser, setFollowUser] = useState<boolean>();
+
+    const {data: landmarks} = useLandmarks();
+    const fromIndoors = useRef<boolean>();
+    const mapRef = useRef<MapView>();
+    const mapNavigation = useNavigation<MapStackNavigationProp>();
+
+    // used to determine previous screens and decide if the map should be refreshed
+    const {index: mapNavigationIndex} = useNavigationState(state => state)
+
+    // when either the map navigation state or main navigation state changes, check to see if we are coming from indoor map. if so, refresh the map to prevent map lag
+    useEffect(() => {
+        if (fromIndoors.current) {
+            fromIndoors.current = false
+            setLoading(true)
+        }
+
+        setTimeout(() => {
+            setLoading(false)
+        }, 500);
+
+    }, [mapNavigationIndex])
+
+    // Toggle the lm details panel when a new selected landmark is detected (triggered by pressing on a map marker, or from the list of nearby landmarks)
+    useEffect(() => {
+        console.log("[LandmarkDetails]: Landmark selected - " + store.selectedLm.selectedLandmarkId)
+        if (store.selectedLm.selectedLandmarkId) {
+            const landmark = landmarks.find(lm => lm.id == store.selectedLm.selectedLandmarkId)
+            mapRef.current.animateToRegion({ latitude: landmark.latitude, longitude: landmark.longitude, latitudeDelta: 0.01, longitudeDelta: 0.01 })
+        }
+    }, [store.selectedLm.selectedLandmarkId])
+
+    
+    // Move to pressed location when newlandmark changes
+    useEffect(() => {
+        if (store.selectedLm.selectedLandmarkId) {
+            mapRef.current.animateToRegion({ 
+                latitude: store.addLm.pendingLandmark?.latitude, 
+                longitude: store.addLm.pendingLandmark?.longitude, 
+                latitudeDelta: 0.01, 
+                longitudeDelta: 0.01 
+            })
+        }
+    }, [store.addLm.pendingLandmark])
+
+    const goIndoors = () => useCallback(() => {
+        mapNavigation.navigate("Indoor");
+    }, [mapNavigation])
+
+    /**
+     * Animates the map to fly over to and focus on the user's location.
+     */
+    const flyToUser = () => useCallback(() => {
+        console.log('[Map]: Centering on user')
+        setFollowUser(true);
+        if (userLocation) {
+            mapRef.current?.animateToRegion({ 
+                latitude: userLocation.latitude, 
+                longitude: userLocation.longitude, 
+                latitudeDelta: 0.01, 
+                longitudeDelta: 0.01 
+            })
+        }
+    }, [userLocation])
+
+    /**
+     * Activates speech recognition and opens the voice panel
+     */
+    const startSpeech = async () => {
+        if (!(voicePermissionsGranted && locationPermissionsGranted)) {
+            setAlert({
+                title: "Permissions",
+                message: "Please enable location and voice permissions in the settings menu",
+                type: "warning",
+                callback: () => openSettings(),
+                callbackButtonText: 'Open settings'
+            })
+            return
+        }
+        store.selectedLm.close();
+        store.addLm.close();
+        const spokestackInitialized = await Spokestack.isInitialized()
+        const spokestackStarted = await Spokestack.isStarted()
+
+        if (spokestackInitialized && spokestackStarted) {
+            store.addLm.stageNewLandmarkWithLocation({latitude: userLocation.latitude, longitude: userLocation.longitude})
+            store.voice.toggleVisible(true)
+            Spokestack.activate()
+        }
+    }
+
+    /**
+     * Gets initial region that map should zoom into from current user location
+     */
+    const getInitialRegion = () => {
+        if (userLocation) {
+            return { latitude: userLocation.latitude, longitude: userLocation.longitude, latitudeDelta: 0.01, longitudeDelta: 0.01 }
+        }
+    }
+
+    const onLocationUpdate = async (newCoords: LatLng) => {
+        setUserLocation(newCoords)
+        // get 10m radius around user
+        const userAlertRadius = circle([newCoords.longitude, newCoords.latitude], 10, { units: 'meters' })
+
+        // check each landmark to see if its inside user radius. if it is, and it isn't already in the list of notified landmarks, add it
+        const newLandmarksNearUser = landmarks?.filter(lm => {
+            const landmarkNearUser = booleanPointInPolygon([lm.longitude, lm.latitude], userAlertRadius)
+            return landmarkNearUser
+        })
+
+        // to prevent duplicate notifications make a list of landmarks that weren't previously near the user. 
+        // these are the only ones that the user will be notified of
+        const newLandmarksNotPreviouslyNearUser = newLandmarksNearUser?.filter(lm => store.nearbyLm.nearbyLandmarks.some(origLm => lm == origLm.id))
+
+        store.nearbyLm.setNearbyLandmarks(newLandmarksNearUser)
+
+        // TODO: redo notifications
+        // // if there are any new landmarks near user, create a notification for them and send it
+        // if (newLandmarksNotPreviouslyNearUser?.length > 0) {
+        //     const body = newLandmarksNotPreviouslyNearUser.length > 1 ? "There are new landmarks near by. Tap here to view" : "There is a new landmark close by. Tap here to view"
+        //     const notifType: NotifType = newLandmarksNotPreviouslyNearUser.length > 1 ? 'near-landmarks' : 'near-landmark'
+        //     const data = { notif_type: notifType, landmarks: newLandmarksNotPreviouslyNearUser.length == 1 ? newLandmarksNearUser : null }
+        //     await Notifications.scheduleNotificationAsync({
+        //         content: {
+        //             title: "⚠ Landmarks close by ⚠",
+        //             body: body,
+        //             data: data
+        //         },
+        //         trigger: { seconds: 2 },
+        //     });
+        // }
+    }
+
+    const addLandmark = async () => {
+        await store.addLm.stageNewLandmarkWithLocation({longitude: userLocation.longitude, latitude: userLocation.latitude}, -1)
+    }
+    
+    return {flyToUser, mapRef, loading, getInitialRegion, onLocationUpdate, followUser, goIndoors, landmarks, userLocation, addLandmark}
+}

+ 202 - 0
src/components/maps/outdoor/outdoor-map.view.tsx

@@ -0,0 +1,202 @@
+/* Copyright (C) Click & Push Accessibility, Inc - All Rights Reserved
+ * Unauthorized copying of this file, via any medium is strictly prohibited
+ * Proprietary and confidential
+ * Written and maintained by the Click & Push Development team 
+ * <dev@clicknpush.ca>, January 2022
+ */
+
+import { FontAwesome } from "@expo/vector-icons";
+import { RouteProp, useNavigation } from "@react-navigation/native";
+import { observer } from "mobx-react";
+import React, { useState } from "react";
+import { ActivityIndicator, Alert, Image, Keyboard, Linking, Modal, StyleSheet, Text, TouchableOpacity, TouchableWithoutFeedback, View } from "react-native";
+import MapView, { MapPolygonProps, MapPolylineProps, Marker, Polygon, Polyline } from "react-native-maps";
+import { Menu, MenuProps } from "react-native-material-menu";
+import { useLandmarks } from "../../../api/landmarks";
+import { store } from "../../../main-store";
+import { usePermissions } from "../../../permissions-context";
+import { colors, lmTypes } from "../../../utils/GlobalUtils";
+import Badge from "../../Badge";
+import { BurgerMenu } from "../../burger-menu";
+import { IconButton } from "../../Buttons";
+import { MainTabsParamList } from "../../navigation/main-tabs-navigator";
+import { MapStackParamList } from "../map-navigator";
+import mapStyles from "../Map.styles";
+import NearbyLandmarksPanel from "../panels/nearby-landmarks-panel/neary-landmarks-panel";
+import { VoicePanel } from "../panels/voice-panel/voice-panel.view";
+import { useOutdoorMap } from "./outdoor-map.logic";
+
+/**
+ * An interface representing the user location retrieved from [expo-location]{@link https://docs.expo.dev/versions/latest/sdk/location/}.
+ * @category Map
+ */
+export interface UserLocation {
+    latitude: number;
+    longitude: number;
+    heading?: number;
+}
+
+export type MapStackRouteProp = RouteProp<MapStackParamList, 'Outdoor'>;
+export type AuthTabsMapRouteProp = RouteProp<MainTabsParamList, 'Map'>;
+
+/**
+ * The screen component containing the Map and all related functionality. Uses [react-native-maps]{@link https://github.com/react-native-maps/react-native-maps}
+ * @category Map
+ * @component
+ */
+
+// TODO: replace this with buildings api
+const cameronPolyProps: MapPolygonProps = {
+    coordinates: [
+        { latitude: 53.527190, longitude: -113.524205 },
+        { latitude: 53.526510, longitude: -113.524205 },
+        { latitude: 53.526510, longitude: -113.523452 },
+        { latitude: 53.527190, longitude: -113.523452 },
+    ],
+    fillColor: `rgba(100,100,200,0.3)`,
+    strokeWidth: 2.5,
+    tappable: true,
+};
+
+const busStationToCameronRouteProps: MapPolylineProps = {
+    coordinates: [
+        {latitude: 53.527192, longitude: -113.523583},
+        {latitude: 53.527189, longitude: -113.5233285},
+        {latitude: 53.526942, longitude: -113.523338},
+        {latitude: 53.526934, longitude: -113.523263},
+        {latitude: 53.5264874, longitude: -113.5232654},
+        {latitude: 53.526492, longitude: -113.522827},
+        {latitude: 53.52620959999999,longitude: -113.5228282},
+        {latitude: 53.5259882, longitude: -113.5222835},
+        {latitude: 53.5254566, longitude: -113.5222824},
+        {latitude: 53.525456, longitude: -113.522155},
+        {latitude: 53.5251288, longitude: -113.5216083}
+    ],
+    strokeColor: 'black',
+    strokeWidth: 3,
+    onPress:() => Alert.alert("This is a route from University Station to Cameron Library"),
+    tappable: true
+}
+
+const coordinates = [
+        { latitude: 53.527086340019856, longitude: -113.52358410971608, }, // Cameron library
+        { latitude: 53.52516024715472, longitude: -113.52154139033108, }, // University station
+    ];
+
+const RefreshPanel: React.FC<{loading: boolean}> = ({loading}) => {
+    return (
+        <Modal transparent={true} animationType="fade" visible={loading}>
+            <View style={{ flex: 1, justifyContent: 'center', alignItems: 'center', backgroundColor: 'rgba(0,0,0,0.3)' }}>
+                <View style={{ width: '60%', height: '30%', backgroundColor: colors.red, justifyContent: 'center', alignItems: 'center', borderRadius: 20 }}>
+                    <ActivityIndicator size="large" color="white" style={{ marginBottom: 20 }} />
+                    <Text style={{ fontSize: 15, color: 'white' }}>Refreshing</Text>
+                </View>
+            </View>
+        </Modal>
+    )
+}
+
+const LandmarkPins: React.FC = () => {
+    const {data: landmarks} = useLandmarks();
+
+    return (
+        <>
+        {landmarks?.map((landmark) => {
+            if (landmark.floor == null) {
+                return (
+                    <Marker
+                        tracksViewChanges={landmark?.id == store.selectedLm.selectedLandmarkId}
+                        onPress={() => store.selectedLm.selectLandmark(landmark.id)}
+                        key={landmark.id}
+                        coordinate={{ latitude: landmark.latitude as number, longitude: landmark.longitude as number }} >
+                        {landmark.landmark_type ? <Image style={{ height: 35, width: 25 }} source={lmTypes[landmark.landmark_type]?.image} /> : null}
+                    </Marker>
+                )
+            }
+        })}
+        </>
+    )
+}
+
+const OutdoorMap: React.FC = (props) => {
+    const {
+        loading, 
+        mapRef, 
+        followUser, 
+        addLandmark,
+        flyToUser,
+        goIndoors, 
+        getInitialRegion, 
+        onLocationUpdate,
+    } = useOutdoorMap();
+        
+    const {locationPermissionsGranted, voicePermissionsGranted} = usePermissions();
+    const navigation = useNavigation();
+    const [mapSize, setMapSize] = useState<number>();
+
+    const map = () => {
+        return (
+            <MapView
+                toolbarEnabled={false}
+                onPress={() => Keyboard.dismiss()}
+                testID="mapView"
+                ref={mapRef}
+                style={{ width: '100%', height: '100%' }}
+                initialRegion={getInitialRegion()}
+                onLongPress={e => store.addLm.stageNewLandmarkWithLocation(e.nativeEvent.coordinate)}
+                showsUserLocation={locationPermissionsGranted}
+                onUserLocationChange={e => onLocationUpdate(e.nativeEvent.coordinate)}
+                followsUserLocation={followUser}
+                showsMyLocationButton={false}
+                onRegionChangeComplete={(region) => setMapSize(region.latitudeDelta)}>
+                <Polygon {...cameronPolyProps} onPress={goIndoors}/>
+                <LandmarkPins/>
+                <Polyline {...busStationToCameronRouteProps}/>
+                <Marker coordinate={coordinates[1]} pinColor={colors.red}/>
+                <Marker coordinate={{  latitude: 53.527189,longitude: -113.5233285, }} pinColor={colors.red}>
+                    <Text style={{ fontSize: mapSize > 0.00327 ? 0 : 0.05 / mapSize, maxWidth: 200 }}>Route from University Station to Cameron Library</Text>
+                </Marker>
+            </MapView>
+        )
+    }
+        
+    return (
+        <TouchableWithoutFeedback>
+            <>
+                {/*Main map component*/}
+                <RefreshPanel loading={loading}/>
+                {map()}
+                <BurgerMenu 
+                    styles={{top: 60, right: 20, position: 'absolute'}}
+                    menuItems={[
+                        {onPress: () => {}, text: 'Indoor buildings', disabled: true, extraProps: {disabledTextColor: 'black', style: styles.burgerMenuHeaderItem}},
+                        {onPress: () => navigation.navigate("Indoor" as any), text: 'Cameron'},
+                        {onPress: () => {}, text: 'More maps on the way!', disabled: true}
+                    ]}/>
+                {/*Map buttons*/}
+
+                {/*Near landmark button*/}
+                {store.nearbyLm.nearbyLandmarks?.length > 0 &&
+                <IconButton size={20} color="white" style={[mapStyles.lowerMapButton, mapStyles.alertButton]} icon="exclamation-triangle" onPress={() => store.nearbyLm.focusNearbyLandmarks()}>
+                    <Badge positioning={{ bottom: 7, right: 4 }} value={store.nearbyLm.nearbyLandmarks.length} />
+                </IconButton>}
+                {/*Voice button*/}
+                {locationPermissionsGranted && voicePermissionsGranted &&
+                <IconButton size={20} color='white' style={[mapStyles.lowerMapButton, mapStyles.voiceButton]} icon="microphone" onPress={async () => await store.voice.startSpeech()} />}
+                {/*Add button*/}
+                <IconButton size={20} color='white' style={[mapStyles.lowerMapButton, mapStyles.addLandmarkButton]} icon="plus" onPress={addLandmark} />
+                {/*Center map on user button*/}
+                <IconButton size={20} color='white' style={[mapStyles.lowerMapButton, mapStyles.userLocationButton]} icon="location-arrow" onPress={flyToUser} />
+
+                {/*Map Panels*/}
+                <NearbyLandmarksPanel/>
+                {locationPermissionsGranted && voicePermissionsGranted && <VoicePanel/>}
+            </>
+        </TouchableWithoutFeedback>)
+}
+
+const styles = StyleSheet.create({
+    burgerMenuHeaderItem: { alignItems: 'center', borderColor: 'black', borderBottomWidth: 1, marginHorizontal: 10, opacity: .7 }
+})
+
+export default observer(OutdoorMap);

+ 0 - 0
src/presentation/maps/panels/RoutingPanel.tsx → src/components/maps/panels/RoutingPanel.tsx


+ 64 - 0
src/components/maps/panels/add-landmark-panel/add-landmark-panel.api.ts

@@ -0,0 +1,64 @@
+import { ImageInfo } from "expo-image-picker";
+import { useEffect, useRef, useState } from "react";
+import ViewShot, { captureRef } from "react-native-view-shot";
+import { useAddLandmark } from "../../../../api/landmarks/landmark.add";
+import { store } from "../../../../main-store";
+import { LMPhoto } from "../../../../types";
+import { lmTypes, lmTypesIndoor } from "../../../../utils/GlobalUtils";
+
+export const useAddLandmarkApi = () => {
+    const [currentRoute, setCurrentRoute] = useState<string>();
+    const addLandmarkMutation = useAddLandmark();
+    const lmLocationScreenCap = useRef<ViewShot>()
+    
+    let currentLmTypes = lmTypes;
+    if (currentRoute=="Indoor") {
+        currentLmTypes = lmTypesIndoor;
+    }
+
+    useEffect(() => {
+        /**
+         * Resets the {@link addLandmarkAsync} mutation on successful add.
+         * Embedded in a useEffect that listens to the {@link addLandmarkStatus} value from the {@link useLandmarks} hook.
+         * @memberOf AddLandmark
+         */
+        const resetAddMutationOnSuccess = () => {
+            if (addLandmarkMutation.isSuccess) {
+                addLandmarkMutation.reset()
+            }
+        }
+        resetAddMutationOnSuccess();
+    }, [addLandmarkMutation.status]);
+
+    useEffect(() => {
+        addLandmarkMutation.reset();
+    }, [store.addLm.panelVisible]);
+
+    /**
+     * Calls {@link addLandmarkAsync} from {@link useLandmarks} to initate the process of adding a landmark, then closes the modal.
+     */
+    const submit = async () => {
+        if (typeof store.addLm.pendingLandmark.floor === 'number') {
+            try {
+                const uri = await captureRef(lmLocationScreenCap, {
+                    format: "jpg",
+                    quality: 1,
+                    result: 'base64'
+                })
+
+                await addLandmarkMutation.mutateAsync({ landmark: store.addLm.pendingLandmark, photos: store.addLm.photos, indoorMapSnapshotBase64: uri }); // pass it in here
+
+
+            } catch (error) {
+                console.error("Oops, snapshot failed", error);
+            }
+        }
+        else {
+            await addLandmarkMutation.mutateAsync({ landmark: store.addLm.pendingLandmark, photos: store.addLm.photos });
+        }
+
+        store.addLm.close();
+    }
+    
+    return {addLandmarkMutation, submit, lmLocationScreenCap}
+}

+ 9 - 8
src/presentation/maps/panels/add-landmark-panel/add-landmark-panel.store.ts → src/components/maps/panels/add-landmark-panel/add-landmark-panel.store.ts

@@ -1,19 +1,18 @@
 import { ImageInfo } from "expo-image-picker";
 import { makeAutoObservable } from "mobx";
 import { LatLng } from "react-native-maps";
-import { Landmark, LMPhoto } from "../../../../state/external/landmarks";
+import { Landmark, LMPhoto } from "../../../../types";
 
-class AddLandmarkFlowStore {
+export class AddLandmarkStore {
     pendingLandmark: Landmark = {};
     photos: LMPhoto[] = [];
     panelVisible: boolean = false;
+    photoSourceMenuVisible: boolean = false;
 
     constructor() {
         makeAutoObservable(this)
     }
 
-
-
     stageNewLandmarkWithLocation(latlng: LatLng, floor?: number) {
         this.pendingLandmark = { latitude: latlng.latitude, longitude: latlng.longitude, floor: floor }
         this.panelVisible = true
@@ -24,13 +23,17 @@ class AddLandmarkFlowStore {
         console.log(this.pendingLandmark)
     }
 
+    togglePhotoSourceMenu(val: boolean) {
+        this.photoSourceMenuVisible = val;
+    }
+
     addPhoto(result: ImageInfo) {
         const photo: LMPhoto = { id: '', image_b64: 'data:image/png;base64,' + result.base64, height: result.height, width: result.width, landmark: '' }
         this.photos.push(photo)
     }
 
     deletePhoto = (index: number) => {
-        this.photos = addLandmarkStore.photos.filter((photo, i) => i != index)
+        this.photos = this.photos.filter((photo, i) => i != index)
     }
 
     close() {
@@ -42,6 +45,4 @@ class AddLandmarkFlowStore {
     get landmarkReady() {
         return !!(this.pendingLandmark.title, this.pendingLandmark.description)
     }
-}
-
-export const addLandmarkStore = new AddLandmarkFlowStore()
+}

+ 86 - 58
src/presentation/maps/panels/add-landmark-panel/add-landmark-panel.view.tsx → src/components/maps/panels/add-landmark-panel/add-landmark-panel.view.tsx

@@ -7,66 +7,94 @@
 
 import FontAwesome from "@expo/vector-icons/build/FontAwesome";
 import { observer } from "mobx-react-lite";
-import React, { memo } from "react";
-import { ActivityIndicator, Image, ImageSourcePropType, Keyboard, KeyboardAvoidingView, Platform, SafeAreaView, StyleSheet, Text, TextInput, TouchableOpacity, View } from 'react-native';
+import React, { memo, useEffect, useRef, useState } from "react";
+import { ActivityIndicator, Dimensions, Image, ImageSourcePropType, Keyboard, KeyboardAvoidingView, KeyboardEventName, Platform, SafeAreaView, StyleSheet, Text, TextInput, TouchableOpacity, View } from 'react-native';
 import { ScrollView } from "react-native-gesture-handler";
 import Modal from 'react-native-modal';
 import { Image as ImageSVG, Svg } from 'react-native-svg';
 import ViewShot from "react-native-view-shot";
+import { store } from "../../../../main-store";
 import { colors, lmTypes } from "../../../../utils/GlobalUtils";
 import { IconButton, SecondaryButton } from "../../../Buttons";
 import LandmarkTypePicker from "../../../LandmarkTypePicker";
 import { PhotoPicker } from "../../../PhotoPicker";
 import { Separator } from "../../../Separator";
 import IndoorFloor from "../../indoor/indoor-floor";
-import TouchOpaq from "../LandmarkDetailsPanel/TouchOpaq";
-import { useAddLandmarkPanel } from "./add-landmark-panel.logic";
-import { addLandmarkStore } from "./add-landmark-panel.store";
+import TouchOpaq from "../selected-landmark-panel/TouchOpaq";
+import { useAddLandmarkApi } from "./add-landmark-panel.api";
 
 const imgWidth = 346;
 const imgHeight = 448;
 const imageDim = 25;
 
-/**
- * Props for the {@link AddLandmarkPanel} component.
- */
-export interface AddLandmarkProps {
-    /**
-     * Whether the landmark is being added at the current users location
-     */
-    landmarkAtCurrentLocation?: boolean;
-    /**
-     * A call back that toggles the visibility state of the {@link AddLandmarkPanel} modal. Passed down from {@link AddLandmarkPanel}.
-     */
-    setVisible: (state: boolean) => void;
-    visible: boolean;
-}
-
 /**
  * Component that renders a form for adding a new {@link Landmark}. Contained within a [react-native-modal]{@link https://github.com/react-native-modal/react-native-modal}.
  * @component
  * @category Map
  */
-const AddLandmarkPanel: React.FC<AddLandmarkProps> = observer(() => {
-    const state = useAddLandmarkPanel()
+const AddLandmarkPanel: React.FC = observer(() => {
+    const {lmLocationScreenCap, addLandmarkMutation, submit} = useAddLandmarkApi();
+    const [keyboardOpened, setKeyboardOpened] = useState<boolean>(false);
+    
+    useEffect(() => {
+        let eventString = Platform.OS == "android" ? 'keyboardDidShow' : Platform.OS == "ios" ? 'keyboardWillShow' : null;
+        if (eventString) {
+            const validEventString: KeyboardEventName = eventString as KeyboardEventName;
+
+            const keyboardDidShowListener = Keyboard.addListener(
+                validEventString,
+                () => {
+                    console.log('keyboard open')
+                    setKeyboardOpened(true); // or some other action
+                }
+            );
+            const keyboardDidHideListener = Keyboard.addListener(
+                'keyboardDidHide',
+                () => {
+                    console.log('keyboard close')
+                    setKeyboardOpened(false); // or some other action
+                }
+            );
+
+            return () => {
+                keyboardDidHideListener.remove();
+                keyboardDidShowListener.remove();
+            };
+        }
+    }, []);
+
+    /**
+     * Returns a height for the modal depending on if an image is maximzed, if the keyboard is opened, and if the current landmark has photos associated with it
+     */
+     const determineModalHeight = () => {
+        if (keyboardOpened) {
+            return Dimensions.get("window").height * .45
+        }
+        else if (store.addLm.photos?.length > 0) {
+            return Dimensions.get("window").height * .9
+        }
+        else {
+            return Dimensions.get("window").height * .8
+        }
+    }
 
     return (
         <Modal
             useNativeDriver={true}
             useNativeDriverForBackdrop={true}
             testID="addLMModal"
-            avoidKeyboard={addLandmarkStore.photos?.length > 0}
-            onBackdropPress={close}
+            avoidKeyboard={store.addLm.photos?.length > 0}
+            onBackdropPress={store.addLm.close}
             style={{ flex: 1, justifyContent: "flex-end", height: '100%', margin: 0}}
-            isVisible={addLandmarkStore.panelVisible} >
+            isVisible={store.addLm.panelVisible} >
 
             <KeyboardAvoidingView
                 behavior={Platform.OS === "ios" ? "padding" : "height"}
-                enabled={addLandmarkStore.photos?.length > 0}
+                enabled={store.addLm.photos?.length > 0}
                 style={{ flex: 1, justifyContent: "flex-end"}}
             >
-                <SafeAreaView style={{ backgroundColor: colors.red, height: state.determineModalHeight(), justifyContent: 'flex-end'}}>
-                    {state.addLandmarkMutation.isIdle ?
+                <SafeAreaView style={{ backgroundColor: colors.red, height: determineModalHeight(), justifyContent: 'flex-end'}}>
+                    {addLandmarkMutation.isIdle ?
                         <>
                             <View style={{
                                 justifyContent: 'space-between',
@@ -82,7 +110,7 @@ const AddLandmarkPanel: React.FC<AddLandmarkProps> = observer(() => {
                             >
                                 <Text style={{ color: 'white', fontSize: 15 }}>Add landmark here?</Text>
                                 <TouchOpaq
-                                    func={close}
+                                    func={store.addLm.close}
                                     name={"close"}
                                     size={25}
                                     col={"white"}
@@ -97,19 +125,19 @@ const AddLandmarkPanel: React.FC<AddLandmarkProps> = observer(() => {
                                         multiline={true}
                                         style={{ backgroundColor: 'white', textAlignVertical: 'top', paddingHorizontal: 10, paddingTop: 10, paddingBottom: 10, marginBottom: 20, height: 150 }}
                                         placeholder="Description"
-                                        onChangeText={value => addLandmarkStore.updatePendingLandmark({ ...addLandmarkStore.pendingLandmark, description: value })}>
-                                        {addLandmarkStore.pendingLandmark?.description}
+                                        onChangeText={value => store.addLm.updatePendingLandmark({ ...store.addLm.pendingLandmark, description: value })}>
+                                        {store.addLm.pendingLandmark?.description}
                                     </TextInput>
                                     <View style={{ flexDirection: 'row' }}>
                                         <LandmarkTypePicker 
                                             placeholder={{ label: "Select a landmark type...", value: 0 }}
-                                            value={addLandmarkStore.pendingLandmark?.landmark_type}
+                                            value={store.addLm.pendingLandmark?.landmark_type}
                                             onValueChange={(value) => {
                                                 if (value) {
-                                                    addLandmarkStore.updatePendingLandmark({ ...addLandmarkStore.pendingLandmark, landmark_type: value, title: lmTypes[value].label })
+                                                    store.addLm.updatePendingLandmark({ ...store.addLm.pendingLandmark, landmark_type: value, title: lmTypes[value].label })
                                                 }
                                                 else {
-                                                    addLandmarkStore.updatePendingLandmark({ ...addLandmarkStore.pendingLandmark, landmark_type: undefined, title: 'no title' })
+                                                    store.addLm.updatePendingLandmark({ ...store.addLm.pendingLandmark, landmark_type: undefined, title: 'no title' })
                                                 }
                                             }}
                                             items={Object.keys(lmTypes)?.map(icon => {
@@ -117,60 +145,60 @@ const AddLandmarkPanel: React.FC<AddLandmarkProps> = observer(() => {
                                                     { label: lmTypes[parseInt(icon)]?.label.toUpperCase(), value: icon, key: icon }
                                                 )
                                             })}/>
-                                        {addLandmarkStore.pendingLandmark?.landmark_type ? <Image source={lmTypes[addLandmarkStore.pendingLandmark.landmark_type].image} />
+                                        {store.addLm.pendingLandmark?.landmark_type ? <Image source={lmTypes[store.addLm.pendingLandmark.landmark_type].image} />
                                             : null}
                                     </View>
                                 </View>
                                 <View style={{ justifyContent: 'flex-end', flexDirection: 'row', paddingHorizontal: 20, marginTop: 5 }}> 
                                     <View style={{ flexDirection: 'row' }}>
-                                        <TouchableOpacity onPress={async () => await state.submit()}><Text style={{ color: 'white', marginRight: 25, opacity: addLandmarkStore.landmarkReady ? 1 : .5 }}>Add</Text></TouchableOpacity>
-                                        <TouchableOpacity onPress={close}><Text style={{ color: 'white', marginRight: 25 }}>Cancel</Text></TouchableOpacity>
+                                        <TouchableOpacity onPress={async () => await submit()}><Text style={{ color: 'white', marginRight: 25, opacity: store.addLm.landmarkReady ? 1 : .5 }}>Add</Text></TouchableOpacity>
+                                        <TouchableOpacity onPress={store.addLm.close}><Text style={{ color: 'white', marginRight: 25 }}>Cancel</Text></TouchableOpacity>
                                     </View>
                                 </View>
-                                {addLandmarkStore.photos?.length > 0 && !state.keyboardOpened ?
+                                {store.addLm.photos?.length > 0 && !keyboardOpened ?
                                     <>
                                         <ScrollView style={{ borderTopWidth: 1, borderColor: 'lightgray', paddingTop: 20, marginHorizontal: 20, flexDirection: 'row', marginBottom: 5, marginTop: 30 }} horizontal={true}>
-                                            {addLandmarkStore.photos.map((photo, i) => {
+                                            {store.addLm.photos.map((photo, i) => {
                                                 return (
                                                     <View key={i} style={{ marginHorizontal: 1, padding: 15 }}>
-                                                        <IconButton style={{ position: 'absolute', top: 0, right: 0, zIndex: 10, }} icon="times-circle" color="lightgray" size={20} onPress={() => addLandmarkStore.deletePhoto(i)} />
+                                                        <IconButton style={{ position: 'absolute', top: 0, right: 0, zIndex: 10, }} icon="times-circle" color="lightgray" size={20} onPress={() => store.addLm.deletePhoto(i)} />
                                                         <Image style={{ borderWidth: 1, alignSelf: 'center', height: 200, width: 200 * photo.width / photo.height }} source={{ uri: photo.image_b64 }} />
                                                     </View>
                                                 )
                                             })}
-                                            {addLandmarkStore.photos.length < 5 ? <IconButton style={{ alignSelf: 'center', padding: 10, opacity: .5, marginLeft: 10 }} color='white' size={30} icon="plus" onPress={() => state.togglePhotoSourceMenu(true)} /> : null}
+                                            {store.addLm.photos.length < 5 ? <IconButton style={{ alignSelf: 'center', padding: 10, opacity: .5, marginLeft: 10 }} color='white' size={30} icon="plus" onPress={() => store.addLm.togglePhotoSourceMenu(true)} /> : null}
                                         </ScrollView>
                                     </> : null}
+                                    {store.addLm.photos?.length == 0 && !keyboardOpened ?
+                                    <>
+                                        <Separator color="white" style={{marginHorizontal: 20, marginTop: 20}} />
+                                        <TouchableOpacity style={{height: '30%', alignItems: 'center', justifyContent: 'center', marginBottom: 20}} onPress={() => {store.addLm.togglePhotoSourceMenu(true)}}>
+                                            <Text style={{fontSize: 20, marginBottom: 10, color: 'white'}}>Add photo of landmark</Text>
+                                            <FontAwesome name="plus" size={30} color='white' />
+                                        </TouchableOpacity> 
+                                    </>: null}
                             </ScrollView>
-                            {addLandmarkStore.photos?.length == 0 && !state.keyboardOpened ?
-                            <>
-                                <Separator color="white" style={{marginHorizontal: 20}} />
-                                <TouchableOpacity style={{height: '30%', alignItems: 'center', justifyContent: 'center', marginBottom: 20}} onPress={() => {state.togglePhotoSourceMenu(true)}}>
-                                    <Text style={{fontSize: 20, marginBottom: 10, color: 'white'}}>Add photo of landmark</Text>
-                                    <FontAwesome name="plus" size={30} color='white' />
-                                </TouchableOpacity> 
-                            </>: null}
                         </> :
                         <View style={{ height: '100%', justifyContent: "space-evenly", alignItems: "center" }}>
                             <Text style={{ color: 'white', fontSize: 20 }}>{
-                                state.addLandmarkMutation.isLoading ? 'Uploading landmark...' :
-                                state.addLandmarkMutation.isError ? 'Something went wrong when trying to upload the landmark.' : null}
+                                addLandmarkMutation.isLoading ? 'Uploading landmark...' :
+                                addLandmarkMutation.isError ? 'Something went wrong when trying to upload the landmark.' : null}
                             </Text>
                             {
-                                state.addLandmarkMutation.isLoading ? <ActivityIndicator color='white' size="large" /> :
-                                state.addLandmarkMutation.isError ? <SecondaryButton text="Okay" onPress={close} /> : null
+                                addLandmarkMutation.isLoading ? <ActivityIndicator color='white' size="large" /> :
+                                addLandmarkMutation.isError ? <SecondaryButton text="Okay" onPress={store.addLm.close} /> : null
                             }
                         </View>}
                 </SafeAreaView>
-                <PhotoPicker multiple={true} menuType='alert' photoSourceMenuOpened={state.photoSourceMenuOpened} onReceivedPhotoResult={result => addLandmarkStore.addPhoto(result)} cancel={() => state.togglePhotoSourceMenu(false)} />
+                <PhotoPicker multiple={true} menuType='alert' photoSourceMenuOpened={store.addLm.photoSourceMenuVisible} onReceivedPhotoResult={result => store.addLm.addPhoto(result)} cancel={() => store.addLm.togglePhotoSourceMenu(false)} />
 
-                <ViewShot style={{ width: imgWidth + 20, height: imgHeight + 20, position: 'absolute', right: -2000 }} ref={state.capture} >
+                <ViewShot style={{ width: imgWidth + 20, height: imgHeight + 20, position: 'absolute', right: -2000 }} ref={lmLocationScreenCap} >
                     {/* {console.log("newLandmark is " + newLandmark)} */}
-                    {addLandmarkStore.pendingLandmark == null || addLandmarkStore.pendingLandmark.floor == null || addLandmarkStore.pendingLandmark.landmark_type == null ? <></> :
+                    {store.addLm.pendingLandmark == null || store.addLm.pendingLandmark.floor == null || store.addLm.pendingLandmark.landmark_type == null ? <></> :
                         <View style={styles.container}>
                             <Svg>
-                                <IndoorFloor floorNum={addLandmarkStore.pendingLandmark.floor} />
-                                <ImageSVG x={addLandmarkStore.pendingLandmark.longitude * imgWidth - 3} y={addLandmarkStore.pendingLandmark.latitude * imgHeight - 3} width={imageDim} height={imageDim} href={lmTypes[addLandmarkStore.pendingLandmark.landmark_type]['image'] as ImageSourcePropType} />
+                                <IndoorFloor floorNum={store.addLm.pendingLandmark.floor} />
+                                <ImageSVG x={store.addLm.pendingLandmark.longitude * imgWidth - 3} y={store.addLm.pendingLandmark.latitude * imgHeight - 3} width={imageDim} height={imageDim} href={lmTypes[store.addLm.pendingLandmark.landmark_type]['image'] as ImageSourcePropType} />
                             </Svg>
                         </View>
                     }

+ 0 - 0
src/presentation/maps/panels/filter-panel/FilterLmTypes.tsx → src/components/maps/panels/filter-panel/filter-lm-types.tsx


+ 0 - 0
src/presentation/maps/panels/filter-panel/FilterMinRating.tsx → src/components/maps/panels/filter-panel/filter-min-rating.tsx


+ 29 - 0
src/components/maps/panels/filter-panel/filter-panel.store.ts

@@ -0,0 +1,29 @@
+import { makeAutoObservable } from "mobx";
+
+export type FilterType = 'min-rating' | 'only-owned' | 'landmark-types'
+export type FilterValueType = number | number[] | boolean
+export type FilterMap = Record<FilterType, FilterValueType>
+
+export class FilterStore {
+    // use a dictionary for filters to allow for easier batched updates
+    current: FilterMap = {
+        'min-rating': 0,
+        'only-owned': false,
+        'landmark-types': [] 
+    }
+
+    panelVisible = false;
+
+    constructor() {
+        makeAutoObservable(this);
+    }
+
+    openPanel() {this.panelVisible = true}
+    closePanel() {this.panelVisible = false}
+
+    setFilters(changes: {type: FilterType, value: FilterValueType}[]) {
+        changes.forEach(change => {
+            this.current[change.type] = change.value;
+        })
+    }
+}

+ 5 - 2
src/presentation/maps/panels/filter-panel/FilterPanel.tsx → src/components/maps/panels/filter-panel/filter-panel.tsx

@@ -11,8 +11,8 @@ import { Keyboard, Text, TouchableOpacity, View } from "react-native"
 import Modal from 'react-native-modal'
 import { SafeAreaView } from 'react-native-safe-area-context'
 import { Separator } from "../../../Separator"
-import { FilterLmTypes } from './FilterLmTypes'
-import { FilterMinRating } from './FilterMinRating'
+import { FilterLmTypes } from './filter-lm-types'
+import { FilterMinRating } from './filter-min-rating'
 
 interface FilterPanelProps {
     setMinLmRating: (min: number) => void,
@@ -25,6 +25,8 @@ interface FilterPanelProps {
     toggleOnlyOwned: (state: boolean) => void
 }
 
+
+// TODO: change filters to server side processing
 /**
  * Modal panel that displays filter controls for the landmarks
  */
@@ -35,6 +37,7 @@ export const FilterPanel: React.FC<FilterPanelProps> = (props) => {
      * These are used locally instead of the mapState values in order to prevent the entire Map component from re-rendering every time one of these values is changed, 
      * greatly improving performance of this component
     */ 
+
     const [localMinRating, setLocalMinRating] = useState<number>(props.minLmRating)
     const [localFilterTypes, setLocalFilterTypes] = useState<number[]>(props.lmFilteredTypes)
     const [localOwned, toggleLocalOwned] = useState<boolean>(props.onlyOwned)

+ 29 - 0
src/components/maps/panels/nearby-landmarks-panel/nearby-landmarks-panel.store.ts

@@ -0,0 +1,29 @@
+import { makeAutoObservable } from "mobx"
+import { store } from "../../../../main-store";
+import { Landmark } from "../../../../types";
+
+export class NearbyLandmarkPanelStore {
+    panelVisible = false;
+    nearbyLandmarks: Landmark[] = [];
+
+    constructor() {
+        makeAutoObservable(this);
+    }
+
+    setNearbyLandmarks(landmarks: Landmark[]) {
+        this.nearbyLandmarks = landmarks;
+    }
+
+    focusNearbyLandmarks() {
+        if (this.nearbyLandmarks?.length > 1) {
+            this.panelVisible = true;
+        }
+        else if (this.nearbyLandmarks?.length === 1) { 
+            store.selectedLm.selectLandmark(this.nearbyLandmarks[0].id)
+        }
+    }
+
+    closePanel() {
+        this.panelVisible = false;
+    }
+}

+ 8 - 30
src/presentation/maps/panels/NearbyLandmarksPanel.tsx → src/components/maps/panels/nearby-landmarks-panel/neary-landmarks-panel.tsx

@@ -10,51 +10,29 @@ import React, { memo, useState } from "react";
 import { Dimensions, Image, SafeAreaView, Text, TouchableOpacity, View } from 'react-native';
 import { ScrollView } from "react-native-gesture-handler";
 import Modal from 'react-native-modal';
-import { Landmark } from "../../../state/external/landmarks";
-import { colors, lmTypes } from "../../../utils/GlobalUtils";
-
-/**
- * Props for the {@link AddLandmarkPanel} component.
- */
-export interface NearbyLandmarksPanelProps {
-    toggleAlertedLmPanel: (state: boolean) => void
-    nearbyLmPanelVisible: boolean
-    nearbyLandmarks?: Landmark[];
-    focusLandmark: (landmark: Landmark) => void
-}
+import { store } from "../../../../main-store";
+import { Landmark } from "../../../../types";
+import { colors, lmTypes } from "../../../../utils/GlobalUtils";
 
 /**
  * Component that renders a form for adding a new {@link Landmark}. Contained within a [react-native-modal]{@link https://github.com/react-native-modal/react-native-modal}.
  * @component
  * @category Map
  */
-const NearbyLandmarksPanel: React.FC<NearbyLandmarksPanelProps> = ({nearbyLandmarks: alertedLandmarks, toggleAlertedLmPanel: toggleNearbyLmPanel, nearbyLmPanelVisible: alertedLmPanelVisible, focusLandmark}) => {
-    const [selectedLm, setSelectedlm] = useState<Landmark>()
-    const switchToLandmarkDetails = () => {
-        if (selectedLm) {
-            focusLandmark(selectedLm)   
-            setSelectedlm(undefined) 
-        }
-    }
-
-    const selectLm = (lm: Landmark) => {
-        setSelectedlm(lm)
-        toggleNearbyLmPanel(false)
-    }
 
+const NearbyLandmarksPanel: React.FC = ({}) => {
     return (
         <Modal
             useNativeDriver={true}
             useNativeDriverForBackdrop={true}
-            onModalHide={() => switchToLandmarkDetails()}
-            onBackdropPress={() => toggleNearbyLmPanel(false)}
+            onBackdropPress={() => store.nearbyLm.closePanel()}
             style={{justifyContent: "flex-end", height: '100%', margin: 0}}
-            isVisible={alertedLmPanelVisible} >
+            isVisible={store.nearbyLm.panelVisible} >
             <SafeAreaView style={{backgroundColor: colors.red, height: Dimensions.get('window').height * .6}}>
                 <ScrollView>
-                    {alertedLandmarks?.map((lm, i) => {
+                    {store.nearbyLm.nearbyLandmarks?.map((lm, i) => {
                         return (
-                            <TouchableOpacity onPress={() => selectLm(lm)} key={i} style={{flexDirection: 'row', alignItems: 'center', paddingVertical: 10, marginHorizontal: 15, justifyContent: 'space-between', borderBottomWidth: 1, borderColor: 'lightgray'}}>
+                            <TouchableOpacity onPress={() => store.selectedLm.selectLandmark(lm.id)} key={i} style={{flexDirection: 'row', alignItems: 'center', paddingVertical: 10, marginHorizontal: 15, justifyContent: 'space-between', borderBottomWidth: 1, borderColor: 'lightgray'}}>
                                 <Image source={lmTypes[lm.landmark_type].image}/>
                                 <Text style={{fontSize: 20, color: 'white'}}>{lmTypes[lm.landmark_type].label.toUpperCase()}</Text>
                                 <View style={{flexDirection: 'row', alignItems: 'center'}}>

+ 0 - 0
src/presentation/maps/panels/LandmarkDetailsPanel/CommentView.tsx → src/components/maps/panels/selected-landmark-panel/CommentView.tsx


+ 2 - 2
src/presentation/maps/panels/LandmarkDetailsPanel/CommentsContainer.tsx → src/components/maps/panels/selected-landmark-panel/CommentsContainer.tsx

@@ -10,8 +10,8 @@ import React, { MutableRefObject } from "react";
 import { FlatList, Keyboard, ListRenderItem, StyleSheet, Text, TextInput, View, ScrollView } from "react-native";
 import { useAuth } from "../../../../state/external/auth-provider";
 import { LMComment } from "../../../../state/external/comments";
-import { MainTabsNavigationProp } from "../../../../navigation/main-tabs-navigator";
-import { navigate } from "../../../../navigation/root-navigator";
+import { MainTabsNavigationProp } from "../../../navigation/main-tabs-navigator";
+import { navigate } from "../../../navigation/root-navigator";
 import { PrimaryButton, SecondaryButton } from "../../../Buttons";
 import { CommentView } from "./CommentView";
 

+ 1 - 1
src/presentation/maps/panels/LandmarkDetailsPanel/DetailsBody.tsx → src/components/maps/panels/selected-landmark-panel/DetailsBody.tsx

@@ -13,7 +13,7 @@ import Picker from "react-native-picker-select";
 import { QueryStatus } from "react-query";
 import { LMComment } from "../../../../state/external/comments";
 import { Landmark, LMPhoto } from "../../../../state/external/landmarks";
-import { MainTabsNavigationProp } from "../../../../navigation/main-tabs-navigator";
+import { MainTabsNavigationProp } from "../../../navigation/main-tabs-navigator";
 import { lmTypes as allLmTypes, lmTypesIndoor } from "../../../../utils/GlobalUtils";
 import LandmarkTypePicker from "../../../LandmarkTypePicker";
 import { Separator } from "../../../Separator";

+ 1 - 1
src/presentation/maps/panels/LandmarkDetailsPanel/DetailsHeader.tsx → src/components/maps/panels/selected-landmark-panel/DetailsHeader.tsx

@@ -12,7 +12,7 @@ import { QueryStatus } from "react-query";
 import { useAuth } from "../../../../state/external/auth-provider";
 import { Landmark } from "../../../../state/external/landmarks";
 import { UserProfile } from "../../../../state/external/profiles";
-import { MainTabsNavigationProp } from "../../../../navigation/main-tabs-navigator";
+import { MainTabsNavigationProp } from "../../../navigation/main-tabs-navigator";
 import TouchOpaq from './TouchOpaq';
 
 interface DetailsHeaderProps {

+ 0 - 0
src/presentation/maps/panels/LandmarkDetailsPanel/LandmarkPhotos.tsx → src/components/maps/panels/selected-landmark-panel/LandmarkPhotos.tsx


+ 0 - 0
src/presentation/maps/panels/LandmarkDetailsPanel/TouchOpaq.tsx → src/components/maps/panels/selected-landmark-panel/TouchOpaq.tsx


+ 0 - 0
src/presentation/maps/outdoor/outdoor-map.logic.ts → src/components/maps/panels/selected-landmark-panel/select-landmark-panel.query.ts


+ 163 - 0
src/components/maps/panels/selected-landmark-panel/select-landmark-panel.view.tsx

@@ -0,0 +1,163 @@
+/* Copyright (C) Click & Push Accessibility, Inc - All Rights Reserved
+ * Unauthorized copying of this file, via any medium is strictly prohibited
+ * Proprietary and confidential
+ * Written and maintained by the Click & Push Development team 
+ * <dev@clicknpush.ca>, January 2022
+ */
+
+import { FontAwesome } from "@expo/vector-icons";
+import { observer } from "mobx-react";
+import React from "react";
+import { ActivityIndicator, Dimensions, Image, Keyboard, SafeAreaView, ScrollView, StyleSheet, Text, TouchableOpacity, View } from "react-native";
+import Modal from 'react-native-modal';
+import { useLandmark } from "../../../../api/landmarks";
+import { store } from "../../../../main-store";
+import { colors } from "../../../../utils/GlobalUtils";
+import { IconButton, PrimaryButton } from "../../../Buttons";
+import { DetailsBody } from "./DetailsBody";
+import { DetailsHeader } from "./DetailsHeader";
+import { useSelectedLandmarkApi } from './selected-landmark-panel.api';
+
+/**
+ * Component that renders the details of a selected {@link Landmark} and allows the user to edit those details. Contained within a [react-native-modal]{@link https://github.com/react-native-modal/react-native-modal}.
+ * @component
+ * @category Map
+ */
+export const LandmarkDetails: React.FC = observer(({}) => {
+    const {determineModalHeight, tryDeleteSelectedPhoto, mainScrollRef} = useSelectedLandmarkApi();
+    const landmarkQuery = useLandmark(store.selectedLm.selectedLandmarkId);
+
+    return (
+        <Modal 
+            useNativeDriver={true}
+            useNativeDriverForBackdrop={true}
+            avoidKeyboard={true}
+            onBackdropPress={() => {
+                if (store.selectedLm.editing) {
+                    Keyboard.dismiss();
+                } else {
+                    store.selectedLm.close();
+                }
+            }}
+            style={{justifyContent: "flex-end", height: '100%', margin: 0}}
+            isVisible={store.selectedLm.panelVisible}>
+            <SafeAreaView 
+                style={[styles.container, {height: determineModalHeight()}]}>
+
+                {store.selectedLm.selectedImageIndex > -1 ?
+                <View style={{ padding: 14}}>
+                    <View style={{justifyContent: 'space-between', flexDirection: 'row'}}>
+                        <TouchableOpacity onPress={() => store.selectedLm.selectImage(-1)}>
+                            <>
+                                <Text style={{color: 'white', fontSize:20}}><FontAwesome name="arrow-left" color="white" size={20}/> Back</Text>
+                            </>
+                        </TouchableOpacity>
+                        <IconButton 
+                            style={{alignSelf: 'flex-end', marginBottom: 20}} 
+                            icon="trash" 
+                            color="white" 
+                            size={25} 
+                            onPress={() => tryDeleteSelectedPhoto()}/>
+                    </View>
+                    <ScrollView style={{width: '100%'}} ref={mainScrollRef}>
+                        <Image style={{resizeMode: 'contain', alignSelf: 'center', height: Dimensions.get('window').height * .9, width: Dimensions.get('window').width}} source={{uri: 'data:image/png;base64,' + landmarkQuery?.data?.landmark?.photos[selectedImage].image_b64}}/> 
+                    </ScrollView>
+                </View>
+                :
+                (landmarkQuery.isSuccess || landmarkQuery.isIdle) && 
+                (editLandmarkMutation.isIdle || editLandmarkMutation.isSuccess) && 
+                (deleteLandmarkMutation.isIdle || deleteLandmarkMutation.isSuccess) ?
+                <>
+                <DetailsHeader
+                    authNavigation={authNavigation}
+                    toggleLmDetails={toggleLmDetails}
+                    ratedByUser={landmarkQuery?.data?.ratedByUser}
+                    processingPhoto={processingPhoto}
+                    addPhotoStatus={addLandmarkPhotoMutation.status}
+                    deletePhotoStatus={deleteLandmarkPhotoMutation.status}
+                    toggleDetailsPanel={toggleDetailsPanel}
+                    landmark={landmarkQuery?.data?.landmark}
+                    editLandmark={editLandmark}
+                    editingEnabled={editingEnabled}
+                    toggleEditing={setEditing}
+                    rateLandmark={rateLandmark}
+                    removeLandmark={removeLandmark}
+                    updatedLandmark={updatedLandmark}
+                    profile={profile} />
+                <DetailsBody
+                    authNavigation={authNavigation} 
+                    setProcessingPhoto={setProcessingPhoto}
+                    processingPhoto={processingPhoto}
+                    profileId={profile?.id}
+                    setKeyboardOpened={setKeyboardOpened}
+                    keyboardOpened={keyboardOpened}
+                    setSelectedImage={setSelectedImage}
+                    landmark={landmarkQuery?.data?.landmark}
+                    updatedLandmark={updatedLandmark}
+                    commentListRef={commentListRef}
+                    commentTextInputRef={commentTextInputRef}
+                    commentBeingEdited={commentBeingEdited}
+                    comments={commentsQuery?.data}
+                    addComment={addComment}
+                    setCommentBeingEdited={setCommentBeingEdited}
+                    newCommentId={newCommentId}
+                    editComment={editComment}
+                    focusComment={focusComment}
+                    deleteComment={deleteComment}
+                    setNewComment={setNewComment}
+                    focusedCommentId={focusedCommentId}
+                    startEditingComment={startEditingComment}
+                    editingEnabled={editingEnabled}
+                    setUpdatedLandmark={setUpdatedLandmark}
+                    tryDeletePhoto={tryDeleteSelectedPhoto}
+                    deletePhotoStatus={deleteLandmarkPhotoMutation.status}
+                    toggleLmDetails={toggleLmDetails}
+                    addPhoto={addLandmarkPhotoMutation.mutateAsync}
+                    addPhotoStatus={addLandmarkPhotoMutation.status}/>
+                </> :
+                <View style={{height: '100%', justifyContent: "space-evenly", alignItems: "center", marginHorizontal: 20}}>
+                    <Text style={{color: 'white', fontSize: 20}}>{
+                        landmarkQuery.isLoading ? "Loading landmark..." : 
+                        landmarkQuery.isError ? "Something went wrong trying to load the landmark" :
+                        deleteLandmarkMutation.isLoading ? "Deleting landmark..." : 
+                        deleteLandmarkMutation.isError ? "Something went wrong trying to delete the landmark" :
+                        editLandmarkMutation.isLoading ? "Updating landmark..." : 
+                        editLandmarkMutation.isError ? "Something went wrong trying to update the landmark" :
+                        addCommentMutation.isLoading ? "Adding comment..." : 
+                        addCommentMutation.isError ? "Something went wrong trying to add the comment" : 
+                        editCommentMutation.isLoading ? "Updating comment..." : 
+                        editCommentMutation.isError ? "Something went wrong trying to update the comment" :  
+                        deleteCommentMutation.isLoading ? "Deleting comment..." : 
+                        deleteCommentMutation.error ? "Something went wrong trying to delete the comment" : null} 
+                    </Text>
+                    {
+                        deleteLandmarkMutation.isLoading || editLandmarkMutation.isLoading || landmarkQuery.isLoading ? <ActivityIndicator color='white' size="large"/> :
+                        deleteLandmarkMutation.isError || editCommentMutation.isLoading || landmarkQuery.isError ? <PrimaryButton text="Okay" style={{borderColor: 'white', borderWidth: 1}} onPress={async () => await landmarkQuery.refetch()} /> : null
+                    }
+                </View>}
+            </SafeAreaView>
+        </Modal>
+    )
+})
+
+const styles = StyleSheet.create({
+    container: {
+        backgroundColor: colors.red,
+    },
+    detailsContainer: {
+        flex: 1,
+        marginHorizontal: 20
+    },
+
+    commentContainer: {
+        flex: 5,
+        marginBottom: 40,
+    },
+    title: {
+
+    },
+    input: {
+        padding: 5,
+        color: 'black'
+    }
+})

+ 300 - 0
src/components/maps/panels/selected-landmark-panel/selected-landmark-panel.api.ts

@@ -0,0 +1,300 @@
+import { useEffect, useRef, useState } from "react";
+import { Alert, Dimensions, FlatList, Keyboard, ScrollView, TextInput } from "react-native";
+import { useAddComment, useDeleteComment, useEditComment, useLandmarkComments } from "../../../../api/comments";
+import { useDeleteLandmark, useEditLandmark, useLandmark, useRateLandmark } from "../../../../api/landmarks";
+import { useDeleteLandmarkPhoto } from "../../../../api/lm-photos.ts";
+import { useOwnedProfile } from "../../../../api/profile/profiles.get-own";
+import { store } from "../../../../main-store";
+import { useAuth } from "../../../../state/external/auth-provider";
+import { Landmark, LMComment } from "../../../../types";
+
+export const useSelectedLandmarkApi = () => {
+    const landmarkQuery = useLandmark(store.selectedLm.selectedLandmarkId)
+    const editLandmarkMutation = useEditLandmark();
+    const rateLandmarkMutation = useRateLandmark();
+    const deleteLandmarkMutation = useDeleteLandmark();
+    const commentsQuery = useLandmarkComments(store.selectedLm.selectedLandmarkId);
+    const addCommentMutation = useAddComment();
+    const editCommentMutation = useEditComment();
+    const deleteCommentMutation = useDeleteComment();
+    const deleteLandmarkPhotoMutation = useDeleteLandmarkPhoto();
+    
+    const {userId, landmarkOwnedByUser} = useAuth()
+
+    // /**
+    //  * Holds the state of the {@link Landmark} being displayed.
+    //  */
+    // const selectedLandmarkState = undefined;
+    // const [selectedLandmark, setLandmark] = useState<Landmark | undefined>(selectedLandmarkState);
+    /**
+     * Holds state of a {@link Landmark} object parallel to {@linkcode selectedLandmarkState} that is manipulated when {@linkcode editingEnabled} is true.
+     */
+    const [updatedLandmark, setUpdatedLandmark] = useState<Landmark | undefined>(undefined);
+    /**
+     * Holds text of the current {@link LMComment} being added to this {@link Landmark}.
+     */
+     const [newCommentId, setNewComment] = useState<string>("");
+    /**
+     * Holds id of the focused {@link LMComment} in the comment list.
+     */
+    const [focusedCommentId, setFocusedComment] = useState<string>("");
+    /**
+     * Holds state of the {@link LMComment} being currently edited.
+     */
+     const [commentBeingEdited, setCommentBeingEdited] = useState<LMComment | undefined>(undefined);
+
+    /**
+     * Flag that tracks if the keyboard is open
+     */
+    const [keyboardOpened, setKeyboardOpened] = useState<boolean>(false);  
+
+    const [photosBusy, setPhotosBusy] = useState<boolean>(false)
+    const [processingPhoto, setProcessingPhoto] = useState<boolean>(false)    
+    const { profile } = useOwnedProfile()
+
+    /**
+     * Holds a reference to the Flatlist containing the comments.
+     */
+    const commentListRef = useRef<FlatList>();
+    const mainScrollRef = useRef<ScrollView>();
+    /**
+     * Holds a reference to the text input for posting a new comment
+     */
+    const commentTextInputRef = useRef<TextInput>();
+
+    useEffect(() => {
+    const keyboardDidShowListener = Keyboard.addListener(
+      'keyboardWillShow',
+      () => {
+        setKeyboardOpened(true); // or some other action
+      }
+    );
+    const keyboardDidHideListener = Keyboard.addListener(
+      'keyboardWillHide',
+      () => {
+        setKeyboardOpened(false); // or some other action
+      }
+    );
+
+    return () => {
+      keyboardDidHideListener.remove();
+      keyboardDidShowListener.remove();
+    };
+  }, []);
+
+    useEffect(() => {
+        /**
+         * Resets the {@linkcode rateLandmark} mutation on successful update.
+         * Embedded in a useEffect that listens to the {@linkcode rateLandmarkStatus} value from the {@link useLandmarks} hook.
+         * @memberOf LandmarkDetails
+         */
+         const resetUpdateLandmark = async () => {
+            if (rateLandmarkMutation.isSuccess) {
+                rateLandmarkMutation.reset()
+                await landmarkQuery.refetch()
+            }
+
+            if (editLandmarkMutation.isSuccess) {
+                editLandmarkMutation.reset()
+                await landmarkQuery.refetch()
+            }
+        }
+        resetUpdateLandmark();
+    }, [rateLandmarkMutation.status, editLandmarkMutation.status]);
+
+    useEffect(() => {
+        /**
+         * Resets the {@linkcode updateComment} mutation on successful update.
+         * Embedded in a useEffect that listens to the {@linkcode updateCommentStatus} value from the {@link useComments} hook.
+         * @memberOf LandmarkDetails
+         */
+         const resetUpdateComment = () => {
+            if (editCommentMutation.isSuccess) {
+                commentListRef.current?.scrollToItem({animated: true, item: commentBeingEdited});
+                setCommentBeingEdited(undefined)
+                Keyboard.dismiss();
+            }
+        }
+        resetUpdateComment();
+    }, [editCommentMutation.status]);
+
+    useEffect(() => {
+        /**
+         * Resets the {@linkcode deleteLandmark} mutation on successful delete.
+         * Embedded in a useEffect that listens to the {@linkcode deleteLandmarkStatus} value from the {@link useLandmarks} hook.
+         * @memberOf LandmarkDetails
+         */
+        const resetDeleteLMOnSuccess = () => {
+            if (deleteLandmarkMutation.isSuccess) {
+                deleteLandmarkMutation.reset()
+            }
+
+            console.log(deleteLandmarkMutation.status)
+        }
+        resetDeleteLMOnSuccess();
+    }, [deleteLandmarkMutation.status]);
+
+    useEffect(() => {
+        /**
+         * Resets the {@link addCommentAsync} mutation on successful add.
+         * Embedded in a useEffect that listens to the {@link addCommentAsync} value from the {@link useComment} hook.
+         * @memberOf LandmarkDetailsLandmark
+         */
+        const resetAddCommentOnSuccess = () => {
+            if (addCommentMutation.isSuccess) {
+                addCommentMutation.reset()
+            }
+            setNewComment('')
+            commentListRef.current?.scrollToIndex({animated: true, index: 0});
+            Keyboard.dismiss();
+        }
+        resetAddCommentOnSuccess();
+    }, [addCommentMutation.status]);
+
+    useEffect(() => {
+        /**
+         * Clears selected comment when {@linkcode newCommentId} state changes.
+         * @memberOf LandmarkDetails
+         */
+        const clearSelectedOnNewCommentChange = () => {
+            setFocusedComment('')
+        }
+        clearSelectedOnNewCommentChange();
+    }, [newCommentId]);
+
+    /**
+     * Calls the {@linkcode updateLandmark} mutation from the {@link useLandmarks} hook and closes the modal once finished.
+     */
+    const editLandmark = async () => {
+        if (updatedLandmark) {
+            await editLandmarkMutation.mutateAsync(updatedLandmark) 
+        }
+        
+        store.selectedLm.setEditing(false);
+    }
+
+    /**
+     * Calls the {@linkcode rateLandmarkAsunc} mutation from the {@link useLandmarks} hook. If 1, the landmark will be upvoted. If -1, it will be downvoted
+     */
+     const rateLandmark = async (rating: 1 | -1) => {
+        if (landmarkQuery?.data?.landmark) {
+            await rateLandmarkMutation.mutateAsync({id: store.selectedLm.selectedLandmarkId, rating: rating});
+        }
+    }
+
+    /**
+     * Calls the {@linkcode deleteLandmark} mutation from the {@link useLandmarks} hook and closes the modal once finished.
+     */
+    const removeLandmark = async () => {
+        Alert.alert("Are you sure you want to delete this landmark?", undefined,
+      [{ text: "Cancel", }
+        ,
+      {
+        text: "Confirm", onPress: async () => {
+            await deleteLandmarkMutation.mutateAsync(store.selectedLm.selectedLandmarkId);   
+            store.selectedLm.close();
+            Alert.alert("Landmark Deleted", "This landmark has been deleted.");
+        }
+      }])
+    }
+
+    /**
+     * Calls the {@linkcode addComment} mutation from the {@link useComments} hook.
+     */
+    const addComment = async () => {
+        if (newCommentId) {
+            await addCommentMutation.mutateAsync({
+                edited: false,
+                content: newCommentId,
+                landmark: store.selectedLm.selectedLandmarkId,
+                poster: userId,
+                poster_name: profile.username,
+                id: ''
+            });
+        }
+    }
+
+    /**
+     * Set a comment to be focused in the comment box. It will expand and show delete and edit controls for that comment. 
+     * If the comment id given is already selected, the comment will be unselected
+     */
+    const focusComment = (id: string) => {
+        if (focusedCommentId == id) {
+            setFocusedComment("");
+        }
+        else {
+            setFocusedComment(id);
+        }
+    }
+
+    /**
+     * Set a comment to be the comment currently edited. 
+     */
+    const startEditingComment = (comment: LMComment) => {
+        setCommentBeingEdited(comment);
+        commentTextInputRef.current.focus();
+    }
+
+    /**
+     * Calls the {@linkcode editComment} mutation from the {@link useComments} hook.
+     */
+    const editComment = async (comment: LMComment) => {
+        await editCommentMutation.mutateAsync(comment);
+    }
+
+    /**
+     * Calls the {@linkcode deleteComment} mutation from the {@link useComments} hook.
+     */
+    const deleteComment = async (id: string) => {
+        await deleteCommentMutation.mutateAsync(id);
+    }
+
+    const getWindowWidth = () => {
+        return Dimensions.get('window').width
+    }
+
+    /**
+     * Prompts user for a confirmation to delete the photo that they just tried to delete
+     */
+    const tryDeleteSelectedPhoto = () => {
+        const photoId = landmarkQuery?.data?.landmark?.photos[store.selectedLm.selectedImageIndex].id
+        Alert.alert(
+            'Confirm photo removal', 
+            'Are you sure you want to delete this photo?', 
+            [
+                {text: 'Yes', 
+                onPress: async () => {
+                    setPhotosBusy(true)
+                    await deleteLandmarkPhotoMutation.mutateAsync(photoId)
+                    store.selectedLm.selectImage(-1)
+                    await landmarkQuery.refetch()
+                }},
+                {text: 'No'} 
+            ]
+        )  
+    }
+
+    /**
+     * Returns a height for the modal depending on if an image is maximzed, if the keyboard is opened, and if the current landmark has photos associated with it
+     */
+    const determineModalHeight = () => {
+        if (store.selectedLm.selectedImageIndex > -1) 
+            return Dimensions.get("window").height 
+        else if (keyboardOpened || store.selectedLm.editing || (!landmarkOwnedByUser(landmarkQuery?.data?.landmark) && landmarkQuery?.data?.landmark?.photos?.length == 0)) {
+            return Dimensions.get("window").height * .4
+        }
+        else if (landmarkQuery?.data?.landmark?.photos?.length > 0) 
+            return Dimensions.get("window").height * .9 
+        else
+            return Dimensions.get("window").height * .6
+    }
+
+    return {
+        determineModalHeight, tryDeleteSelectedPhoto, mainScrollRef,
+        landmarkQuery, editLandmarkMutation, deleteLandmarkMutation,
+        addCommentMutation, editCommentMutation, deleteCommentMutation,
+    };
+    // (landmarkQuery.isSuccess || landmarkQuery.isIdle) && 
+    //             (editLandmarkMutation.isIdle || editLandmarkMutation.isSuccess) && 
+    //             (deleteLandmarkMutation.isIdle || deleteLandmarkMutation.isSuccess)
+}

+ 33 - 0
src/components/maps/panels/selected-landmark-panel/selected-landmark-panel.store.ts

@@ -0,0 +1,33 @@
+import { makeAutoObservable } from "mobx";
+import { Landmark } from "../../../../types";
+
+export class SelectedLandmarkStore {
+    selectedLandmarkId: string = "";
+    pendingLandmarkData?: Landmark;
+    panelVisible: boolean = false;
+    editing: boolean = false;
+    selectedImageIndex: number = -1;
+
+    constructor() {
+        makeAutoObservable(this);
+    }
+
+    selectLandmark(id: string) {
+        this.selectedLandmarkId = id;
+        this.panelVisible = true;
+    } 
+
+    close() {
+        this.selectedLandmarkId = "";
+        this.pendingLandmarkData = undefined;
+        this.panelVisible = false;
+    }
+
+    setEditing(editing: boolean) {
+        this.editing = editing;
+    }
+
+    selectImage(index: number) {
+        this.selectedImageIndex = index;
+    }
+}

+ 41 - 0
src/components/maps/panels/voice-panel/voice-panel.api.tsx

@@ -0,0 +1,41 @@
+import { useEffect } from "react"
+import { useAddLandmark } from "../../../../api/landmarks";
+import { store } from "../../../../main-store";
+import { useAuth } from "../../../../state/external/auth-provider";
+
+export const useVoicePanelApi = () => {
+    const addLandmarkMutation = useAddLandmark();
+    const {setAlert} = useAuth();
+    useEffect(() => {(async () => {
+        if (store.voice.action.step == 3 && store.voice.lastSaid.includes("yes")) {   
+            await addLandmarkMutation.mutateAsync({landmark: store.addLm.pendingLandmark});
+        }
+        
+        store.voice.action = undefined;                
+    })()}, [store.voice.action])
+
+    useEffect(() => {
+        /**
+         * Resets the {@link addLandmarkAsync} mutation, and reports to the user on successful add. Reports error on failure.
+         * Embedded in a useEffect that listens to the {@link addLandmarkStatus} value from the {@link useLandmarks} hook.
+         * @memberOf AddLandmark
+         */
+        const finishAddAttempt = async () => {
+            if (addLandmarkMutation.isSuccess) {
+                addLandmarkMutation.reset();
+                setAlert({
+                    title: "Added landmark", 
+                    message: "Landmark added successfully", 
+                    type: "success",
+                })
+            }
+            else if (addLandmarkMutation.isError) {
+                // TODO: actually handle error here
+                
+            }
+        }
+        finishAddAttempt();
+    }, [addLandmarkMutation.status]);
+
+    return {addLandmarkMutation};
+}

+ 208 - 0
src/components/maps/panels/voice-panel/voice-panel.store.tsx

@@ -0,0 +1,208 @@
+import { makeAutoObservable } from "mobx"
+import Spokestack, { PipelineProfile } from 'react-native-spokestack'
+import { MainStore } from "../../../../main-store";
+import { lmTypes } from "../../../../utils/GlobalUtils";
+import { AddLandmarkStore } from "../add-landmark-panel/add-landmark-panel.store";
+
+const STOP_RESPONSES = ["cancel", "stop", "goodbye", "bye"]
+
+type ActionVerb = "add" | "view" | "near";
+type ActionVerbMap = Record<ActionVerb, string[]>
+const ACTION_VERB_MAP: ActionVerbMap = {
+    'add': ["add", "create", "make"],
+    'view': ["view", "look", 'show'],
+    'near': ["near", "close"]
+}
+
+type ActionStepHandlerMap = Record<number, () => void>;
+type ActionHandlerMap = Record<ActionVerb, ActionStepHandlerMap>;
+
+export interface VoiceAction {
+    verb: ActionVerb
+    step: number
+}
+
+export class VoicePanelStore {
+    panelVisible = false;
+    listening = false;
+    partialSpeechResults?: string;
+    lastSaid?: string;
+    previousResult?: string;
+    action?: VoiceAction;
+    feedback?: string;
+    addLmStore: AddLandmarkStore;
+
+    private actionHandlerMap: ActionHandlerMap = {
+        'add': {
+            1: async () => {
+                this.addLmStore.updatePendingLandmark({...this.addLmStore.pendingLandmark, description: this.lastSaid})
+                this.action.step++;
+            },
+            2: async () => {
+                // get labels of icons from Icons dictionary
+                const typeLabels = Object.values(lmTypes).map(icon => icon.label);
+                // check if response matches any labels
+                const chosenType = typeLabels.filter(label => label.includes(this.lastSaid.toLowerCase()))[0];
+                
+                if (chosenType?.length > 0) {
+                    const chosenTypeKey = parseInt(Object.keys(lmTypes).find(key => lmTypes[key].label == chosenType));
+                    // get corrected icon id by getting index of chosen icon type in labels array created above
+                    this.addLmStore.updatePendingLandmark({...this.addLmStore.pendingLandmark, landmark_type: chosenTypeKey, title: chosenType[0], voice: true})
+                    this.action.step++;
+                }
+            },
+        },
+        'view': {},
+        'near': {},
+    } 
+
+    constructor(store: MainStore) {
+        this.addLmStore = store.addLm;
+        makeAutoObservable(this);
+    }
+
+    private async processCurrentAction() {
+        await this.actionHandlerMap[this.action.verb][this.action.step]();
+        this.action = {...this.action, step: this.action.step + 1};   
+    }
+
+    startSpeech() {
+        if (!this.panelVisible)
+            this.panelVisible = true;
+
+        this.setupSpokestack();
+    }
+
+    close = async () => {
+        await Spokestack.deactivate();
+        this.lastSaid = undefined
+        this.partialSpeechResults = undefined
+        this.feedback = undefined
+        this.listening = false
+        this.action = undefined;
+        this.panelVisible = false
+    }
+
+    listen() {this.listening = true}
+    stopListening() {this.listening = true}
+
+    showPanel() {this.panelVisible = true};
+    hidePanel() {this.panelVisible = false};
+
+    toggleVisible(val: boolean) {
+        this.panelVisible = val;
+    }
+
+    private setupSpokestack() {
+        // Adds handler that will open the app, navigate to the Map screen and open the panel if necessary when Spokestack.activate() is called.
+        // Also, listening will be set to true, and speechResult will be reset
+        Spokestack.addEventListener('activate', async () => {
+            console.log('[Voice]: Spokestack activated')
+            this.listening = true
+        });
+        // Adds handler that will set listening to false when Spokestack.deactivate() is called
+        Spokestack.addEventListener('deactivate', () => {
+            if (this.action) 
+                Spokestack.activate();
+            else 
+                this.listening = false
+        })
+        // Adds handler that will be called when Spokestack finishes recognizing some speech (once the user stops speaking).
+        // Sets speechResult and previousSpeechResult to the processed result 
+        Spokestack.addEventListener('recognize', ({transcript}) => {
+            console.log('[Voice]: Spokestack finished recognizing')
+            this.partialSpeechResults = undefined;
+            this.lastSaid = transcript;
+            this.previousResult = transcript;
+            this.processSpeechTranscript()
+        })
+        // Adds handler that will be called when Spokestack recognizes an update to the speech it is currently listening to.
+        // Sets partialSpeechResult to the processed result
+        Spokestack.addEventListener('partial_recognize', ({transcript}) => {
+            console.log('[Voice]: Spokestack recognizing')
+            this.partialSpeechResults = transcript;
+        })  
+
+        Spokestack.addEventListener('error', ({error}) => {
+            console.log('[Voice]: Spokestack error: ' + error)
+        })
+
+        // TODO: make these env variables
+        Spokestack.initialize(
+            'c361ff3a-70c3-42e6-b0ee-0207edd03b18', // account id
+            '1A8196594C401EB93035CC6D7D6328CF1855C2B359744E720953AC34B6F658CA', // api token
+            {
+                pipeline: {
+                    profile: PipelineProfile.PTT_NATIVE_ASR
+                },
+                // wakeword: {
+                //     detect: 'https://s.spokestack.io/u/kHNUa/detect.tflite',
+                //     filter: 'https://s.spokestack.io/u/kHNUa/filter.tflite',
+                //     encode: 'https://s.spokestack.io/u/kHNUa/encode.tflite',
+                // },
+            }
+        ).then(Spokestack.start).catch(error => console.log("[Voice]: Something went wrong when starting spokestack: " + error))
+
+        return () => {
+            Spokestack.removeAllListeners();
+        };
+    }
+
+    async processSpeechTranscript () {
+        // stop voice if speech result is one of the stop cases - highest priority
+        if (this.shouldCloseVoice()) 
+            await this.close();
+        
+        // when an action hasn't been initialized yet - discern which action is being requested
+        else if (!this.action) {
+            this.discernAction();
+        }
+
+        // when an action has been initialized and user says back - go back a step
+        if (this.lastSaid.includes('back')) 
+            this.action.step--
+
+        // when an action has been initialized - perform the associated action flow
+        else 
+            await this.processCurrentAction();
+    }
+
+    /**
+     * method that initates an appropiate action for the detected action request. it executes when there isn't an action set yet.
+     * each initate<ActionName>Action() method will set the action, then setResponse to the user accordingly.
+     * once this method has been called, all subsequent transcript will be piped into performActionFlow
+     */
+    private async discernAction() {
+        // this branch will initiate whichever action is detected
+        if (this.resultIncludesVerb('add') && this.lastSaid.includes("landmark")) {
+            this.action = {verb: 'add', step: 1};
+            return
+        }
+        // else if (this.resultIncludesVerb('near')) {
+        //     this.action = {verb: 'near', step: 1}
+        //     return
+        // }
+        // else if (speechResult.includes("filter landmarks")) {
+        //     // example action
+        // }
+        // other possible actions
+
+        // handle unrecognized result
+        else {
+            this.feedback = "Did not recognize command, please try again";
+        }
+
+        await Spokestack.activate();
+    }
+
+    private shouldCloseVoice() {
+        return (
+            STOP_RESPONSES.some(response => this.lastSaid.includes(response)) || 
+            (this.lastSaid.includes('no') || this.lastSaid.includes('back')) && !this.action
+        )
+    }
+
+    private resultIncludesVerb(verb: ActionVerb) { 
+        return ACTION_VERB_MAP[verb].some(word => this.lastSaid.includes(word)); 
+    }
+}

+ 192 - 0
src/components/maps/panels/voice-panel/voice-panel.view.tsx

@@ -0,0 +1,192 @@
+/* Copyright (C) Click & Push Accessibility, Inc - All Rights Reserved
+ * Unauthorized copying of this file, via any medium is strictly prohibited
+ * Proprietary and confidential
+ * Written and maintained by the Click & Push Development team 
+ * <dev@clicknpush.ca>, January 2022
+ */
+
+import { FontAwesome } from '@expo/vector-icons';
+import { useNavigation } from '@react-navigation/native';
+import React, { useEffect, useMemo, useState } from 'react';
+import { ActivityIndicator, AppState, FlatList, ImageRequireSource, KeyboardAvoidingView, Linking, Text, TouchableOpacity, View, ViewStyle } from 'react-native';
+import { Pulse, Wave } from 'react-native-animated-spinkit';
+import FastImage from 'react-native-fast-image';
+import Modal from "react-native-modal";
+import { SafeAreaView } from 'react-native-safe-area-context';
+import { MutationStatus } from 'react-query';
+import { useAddLandmark } from '../../../../api/landmarks/landmark.add';
+import { store } from '../../../../main-store';
+import { colors, GlobalStyles, lmTypes } from '../../../../utils/GlobalUtils';
+import { Separator } from '../../../Separator';
+import { useVoicePanelApi } from './voice-panel.api';
+
+// An array of words that will cause the app to stop listening once heard
+
+
+
+
+/**
+ * Panel that provides UI flows and backend logic for voice activated features. Relies on [Spokestack](https://www.npmjs.com/package/react-native-spokestack).
+ * Check the documentation for more information about the Spokestack.initalize(), Spokestack.start(), and Spokestack.activate() and deactivate() methods.
+ * A quick summary is as follows:
+ * - Spokestack.initialize() -> Prepares the Spokestack service with a given configuration
+ * - Spokestack.start() -> Starts the Spokestack service with the configuration given in initialize(). 
+ *   After this method, Spokestack is ready to start recognizing speech, and can now be triggered by wakewords
+ * - Spokestack.activate() -> Causes Spokestack to start listening and processing all speech and output the result. 
+ *   This can be triggered manually or triggered by a wakeword 
+ * - Spokestack.deactivate() -> Causes Spokestack to stop listening and processing all speech
+ * - Spokestack.stop() -> After this method triggers, Spokestack no longer is able to listen for wakewords, or be activated
+ * - Spokestack.destroy() -> The Spokestack service is torn down, and must be initialized with initialize() again for Spokestack to be used
+ * @param 
+ * @returns 
+ */
+
+ const ActionOptions: React.FC<{addLmStatus: MutationStatus}> = ({addLmStatus}) => {
+    if (store.voice.action) {
+        switch (store.voice.action.verb) {
+            case 'add':
+                switch (store.voice.action.step) {
+                    case 1: 
+                        return <Text style={{fontSize: 20, margin: 20, color: 'white'}}>Describe the landmark</Text> 
+                    case 2:
+                        return (
+                            <View>
+                                <Text style={{fontSize: 17, marginHorizontal: 20, marginBottom: 5, color: 'white'}}>Your description:</Text>
+                                <Text style={{fontSize: 15, marginHorizontal: 20, marginBottom: 10, color:'white'}}>{store.addLm.pendingLandmark.description}</Text> 
+                                <Text style={{fontSize: 17, margin: 20, color: 'white'}}>Choose from the available types: </Text> 
+                                <FlatList 
+                                    style={{marginBottom: 20, marginHorizontal:20}}
+                                    data={Object.values(lmTypes).map(value => {return {label: value.label, image: value.image}})}
+                                    numColumns={2}
+                                    keyExtractor={(item) => item.label}
+                                    renderItem={((item) => {
+                                        return (
+                                            <LmTypeDisplay key={item.item.label} lmType={item.item} style={{flexBasis: '50%'}} />
+                                        )})} />
+                            </View>
+                        )
+                    case 3:
+                        return (
+                            <View>
+                                <Text style={{fontSize: 20, margin: 20, marginTop: 10, color: 'white'}}>Your landmark: </Text> 
+                                <View style={{marginLeft: 40, marginBottom: 20}}>
+                                    <Text style={{fontSize: 17, marginBottom: 10, color: 'white'}}>Description: </Text> 
+                                    <Text style={{fontSize: 15, marginBottom: 20, marginLeft: 20, color: 'white'}}>{store.addLm.pendingLandmark.description}</Text> 
+                                    <Text style={{fontSize: 17, marginRight: 10, marginBottom: 10, color: 'white'}}>Type: </Text>
+                                    <LmTypeDisplay lmType={lmTypes[store.addLm.pendingLandmark.landmark_type]} style={{marginLeft: 20}} />
+                                </View>
+                                <Text style={{fontSize: 17, marginHorizontal: 20, marginBottom:10, color: 'white'}}>Are you sure you want to add this landmark?</Text> 
+                                <Text style={{fontSize: 15, marginHorizontal: 40, marginBottom: 10, color: 'white'}}>- Say "Yes" to confirm</Text>
+                                <Text style={{fontSize: 15, marginHorizontal: 40, marginBottom: 20, color: 'white'}}>- Say "No" to cancel</Text>
+                            </View>
+                        )
+                    case 4:
+                        return (
+                            <View>
+                                <Text style={{fontSize: 20, margin: 20, color: 'white'}}>Done. Anything else?</Text> 
+                            </View>
+                        )
+                }
+        }   
+    }
+    
+    if (addLmStatus == 'loading') {
+        return <ActivityIndicator color='white' size="large"/>
+    }
+    else if (addLmStatus == 'error') {
+        return <Text style={{fontSize: 20, margin: 20, color: 'white'}}>Something went wrong when trying to upload the landmark.</Text>
+    }
+}
+
+const LmTypeDisplay: React.FC<{lmType: {image: ImageRequireSource, label:string}, style?: ViewStyle}> = ({lmType, style}) => {
+    return useMemo(() => (
+        <View style={[{marginVertical: 5, flexDirection: 'row'}, style]}>  
+            <FastImage style={{height: 25, width: 18}} source={lmType.image} />
+            <Text style={{fontSize: 15, marginLeft: 10, textAlign: 'center', color: 'white'}}>{lmType.label}</Text>
+        </View>
+    ), [lmType])
+}
+
+const ActionProcessingIndicator: React.FC = () => {
+    return (
+        <View style={{margin: 20}}>
+            <Text style={{fontSize: 20, margin: 20, color: 'white'}}>Adding landmark...</Text>
+            <ActivityIndicator color="white" size="large" />
+        </View>
+    )
+}
+
+const SpeechActivity: React.FC = () => {
+    return (
+        <View style={{justifyContent: 'center', margin: 20, padding: 10, backgroundColor: 'white'}}>
+            {store.voice.previousResult ? <Text style={{fontSize: 15, marginHorizontal: 20, marginBottom: 10, color: "black", alignSelf: 'center'}}>Last result: {store.voice.previousResult}</Text> : null }
+            {store.voice.partialSpeechResults ?                         
+            <Wave color={colors.red} hidesWhenStopped={false} animating={true} style={{alignSelf: 'center', marginBottom: 10}} /> : 
+            <Pulse color={colors.red} hidesWhenStopped={false} animating={true} style={{alignSelf: 'center', marginBottom: 10}} />}
+            <Text style={{fontSize: 15, marginHorizontal: 20, color: "black", alignSelf: 'center'}}>{
+                store.voice.lastSaid ? store.voice.lastSaid :
+                store.voice.partialSpeechResults ? store.voice.partialSpeechResults : ""}
+            </Text>
+        </View>
+    )
+}
+
+const VoicePanelHeader: React.FC = () => {
+    return (
+        <View style={[GlobalStyles.itemRowContainer, {width: '100%'}]} >
+            <Text style={{fontSize: 20, margin: 20, color: 'white', flexBasis: "70%"}}>{store.voice.action ? store.voice.action.verb : "Waiting for command..."}</Text>
+            <TouchableOpacity style={{margin: 20}} onPress={async () => await store.voice.close()}><FontAwesome size={20} color='white' name='times'/></TouchableOpacity>
+        </View>
+    )
+}
+
+export const VoicePanel: React.FC = () => {
+    const navigation = useNavigation();
+
+    const {addLandmarkMutation} = useVoicePanelApi();
+
+    // A string that represents a response that app gives to the user once it executes some voice recognition
+    const [response, setResponse] = useState<string>();
+    
+    // Sets up and starts the Spokestack process the first time this component is rendered
+    useEffect(() => {
+        if (!navigation.isFocused()) {
+            navigation.navigate('Map' as any)
+        }
+        if (AppState.currentState != 'active') {
+            Linking.openURL("cnp.mobile://")
+        }
+    }, [store.voice.panelVisible])
+
+    return (
+    <Modal
+        swipeDirection='down'
+        useNativeDriver={true}
+        useNativeDriverForBackdrop={true}
+        avoidKeyboard={true}
+        onBackdropPress={async () => await store.voice.close()}
+        style={{justifyContent: "flex-end", height: '100%', margin: 0}}
+        isVisible={store.voice.panelVisible} >
+        <KeyboardAvoidingView>
+            <SafeAreaView style={{backgroundColor: colors.red}}>
+                {addLandmarkMutation.isLoading ?
+                <ActionProcessingIndicator /> : 
+                <View>
+                    <VoicePanelHeader />
+                    <Separator color="lightgray" style={{marginHorizontal: 20, marginBottom: 20, opacity: .5}} />
+                    {store.voice.action ? 
+                    <ActionOptions addLmStatus={addLandmarkMutation.status}/> : 
+                    <View style={{marginBottom: 20}}>
+                        <Text style={{fontSize: 15, marginHorizontal: 40, marginBottom: 10, color: 'white'}}>- Say "Add landmark" to add a landmark</Text>
+                        <Text style={{fontSize: 15, marginHorizontal: 40, marginBottom: 10, color: 'white'}}>- Say "Near" to view nearby landmarks</Text>
+                    </View>}
+                    {response? <Text style={{fontSize: 13, marginHorizontal: 40, marginBottom: 20, color: 'white'}}>{response}</Text> : null}
+                    <SpeechActivity />
+                    <Text style={{fontSize: 13, marginHorizontal: 40, marginBottom: 20, color: 'white'}}>Say "cancel", "bye", "goodbye", or "stop" to exit.</Text>
+                    <Text style={{fontSize: 13, marginHorizontal: 40, marginBottom: 20, color: 'white'}}>Say "back" to go back a step.</Text>
+                </View>}
+            </SafeAreaView>
+        </KeyboardAvoidingView>
+    </Modal>
+    )
+}

+ 2 - 2
src/navigation/base-stack-navigator.tsx → src/components/navigation/base-stack-navigator.tsx

@@ -7,7 +7,7 @@
 
 import { createStackNavigator, StackNavigationProp } from '@react-navigation/stack';
 import React from "react";
-import { RegisterMain } from '../presentation/profile/Registration/RegisterMain';
+import { RegisterMain } from '../profile/Registration/RegisterMain';
 import MainTabsNavigator from './main-tabs-navigator';
 
 /**
@@ -36,7 +36,7 @@ const BaseStackNavigator : React.FC = () => {
             headerTintColor: 'white',
             headerTitle: ""}}>
             <BaseStack.Screen name="MainTabs" >
-                {({navigation}) => <MainTabsNavigator navigation={navigation} />}
+                {({navigation}) => <MainTabsNavigator />}
             </BaseStack.Screen>
             <BaseStack.Screen name="Register" component={RegisterMain} />
         </BaseStack.Navigator>

+ 19 - 21
src/navigation/main-tabs-navigator.tsx → src/components/navigation/main-tabs-navigator.tsx

@@ -11,15 +11,17 @@ import * as Notifications from 'expo-notifications';
 import { observer } from "mobx-react";
 import React, { useEffect } from "react";
 import { Alert, SafeAreaView, View } from 'react-native';
-import { useAuth } from '../state/external/auth-provider';
-import { useNotifications, useRegisterNotifications } from '../state/external/notifications';
-import { usePermissions } from '../state/external/PermissionsContext';
-import { useOwnedProfile, useToggleTips } from '../state/external/profiles';
-import Badge from '../presentation/Badge';
-import { Feed } from '../presentation/feed/feed';
-import MapNavigator from '../presentation/maps/MapNavigator';
-import ProfileTemplate from '../presentation/profile/ProfileTemplate';
-import { colors } from "../utils/GlobalUtils";
+import { useAuth } from '../../state/external/auth-provider';
+import { useNotifications, useRegisterNotifications } from '../../api/notifications';
+import { usePermissions } from '../../permissions-context';
+import Badge from '../Badge';
+import { Feed } from '../feed/feed';
+import MapNavigator from '../maps/map-navigator';
+import ProfileTemplate from '../profile/ProfileTemplate';
+import { colors } from "../../utils/GlobalUtils";
+import { RouteProp } from '@react-navigation/native';
+import { useOwnedProfile } from '../../api/profile/profiles.get-own';
+import { useToggleTips } from '../../api/profile/profiles.toggle-tips';
 
 Notifications.setNotificationHandler({
     handleNotification: async () => ({
@@ -41,14 +43,17 @@ const tabBarOptions: BottomTabNavigationOptions = {
     tabBarIconStyle: {marginBottom: 7}
 } 
 
+export type MainTabsRouteProp = RouteProp<MainTabsParamList, 'Map'>;
+
 /**
  * Permitted screens for the Auth tabs navigator. 
  * @category Navigation
  * @typedef
  */
  export type MainTabsParamList = {
-    Map: {selectedLandmark: string, nearbyLandmarks: string[]},
-        Account: React.FC,
+    Map: {selectedLandmark: string, nearbyLandmarks: string[]};
+    Feed: {};
+    Account: {};
 }
 
 export type MainTabsNavigationProp = BottomTabNavigationProp<MainTabsParamList>
@@ -57,7 +62,7 @@ export type MainTabsNavigationProp = BottomTabNavigationProp<MainTabsParamList>
  * @category Navigation
  * @component
  */
-const MainTabsNavigator: React.FC<{navigation}> = ({navigation}) => {
+const MainTabsNavigator: React.FC = () => {
     const {profile} = useOwnedProfile()
     const {userId} = useAuth()
     const {notificationPermissionsGranted} = usePermissions()
@@ -89,14 +94,7 @@ const MainTabsNavigator: React.FC<{navigation}> = ({navigation}) => {
         subscribeToNotifications()
     }, [userId, notificationPermissionsGranted]);
 
-    const getIconSize = (focused: boolean): number => {
-        if (focused) {
-            return 20
-        }
-        else {
-            return 17
-        } 
-    }
+    const getIconSize = (focused: boolean): number => focused ? 20 : 17
 
     const renderFeedBadge = () => {
         const newNotifAmount = notificationsQuery.data?.filter(notif => !notif.read).length
@@ -121,7 +119,7 @@ const MainTabsNavigator: React.FC<{navigation}> = ({navigation}) => {
                     {() => <Feed notifications={notificationsQuery.data} handleNotifInteraction={handleNotificationInteraction} />}
                 </MainTabs.Screen>
                 <MainTabs.Screen name="Account" options={{tabBarIcon: ({color, focused}) => (<FontAwesome name={focused ? 'user' : 'user-o'} size={getIconSize(focused)} color={color} style={{position: 'absolute', top: 8}}/>)}}>
-                    {({navigation}) => <ProfileTemplate navigation={navigation}/>}
+                    {({navigation}) => <ProfileTemplate/>}
                 </MainTabs.Screen>
             </MainTabs.Navigator>
         </SafeAreaView>

+ 12 - 0
src/components/navigation/navigation-store.ts

@@ -0,0 +1,12 @@
+import { makeAutoObservable } from "mobx";
+import { MapStackNavigationProp } from "../maps/map-navigator";
+import { MainTabsNavigationProp } from "./main-tabs-navigator";
+
+class NavigationStore {
+    mapNavigation: MapStackNavigationProp;
+    mainNavigation: MainTabsNavigationProp;
+
+    constructor() {
+        makeAutoObservable(this);
+    }
+}

+ 0 - 0
src/navigation/root-navigator.tsx → src/components/navigation/root-navigator.tsx


+ 0 - 0
src/presentation/profile/AuthLayout.tsx → src/components/profile/AuthLayout.tsx


+ 6 - 8
src/presentation/profile/LoginView.tsx → src/components/profile/LoginView.tsx

@@ -14,12 +14,13 @@ import React, { useState } from "react";
 import { ActivityIndicator, Image, Platform, StyleSheet, Text, TouchableOpacity, View } from "react-native";
 import Collapsible from "react-native-collapsible";
 import { useAuth } from "../../state/external/auth-provider";
-import { BaseStackNavigationProp } from "../../navigation/base-stack-navigator";
+import { BaseStackNavigationProp } from "../navigation/base-stack-navigator";
 import { API_URL } from "../../utils/RequestUtils";
 import { GenericButton, PrimaryButton } from "../Buttons";
 import { Separator } from "../Separator";
 import UnauthorizedLayout from "./AuthLayout";
 import { BrowserLink } from "./ProfileSections/ProfileLegal";
+import { useNavigation } from "@react-navigation/native";
 
 /**
  * Props used by the {@link Intro} screen.
@@ -39,8 +40,9 @@ maybeCompleteAuthSession();
  * @component
  * @category Unauthorized
  */
-const LoginView : React.FC<{navigation: BaseStackNavigationProp}> = ({navigation}) => {
-    const {login} = useAuth()
+const LoginView : React.FC = () => {
+    const navigation = useNavigation();
+    const {login} = useAuth();
 
     /**
      * @type {string} 
@@ -57,16 +59,12 @@ const LoginView : React.FC<{navigation: BaseStackNavigationProp}> = ({navigation
     const errorState = false;
     const [error, setError] = useState<boolean>(errorState);
 
-    const redirectUri = makeRedirectUri({
-        path: 'callback'
-    });
-
     /**
      * Navigates to {@link RegisterMain}.
      */
     const goToRegistration = (): void => {
         console.log('[Navigation]: Navigating to registration page.')
-        navigation.navigate("Register");
+        navigation.navigate("Register" as any);
     }
 
     /**

+ 2 - 2
src/presentation/profile/Profile.tsx → src/components/profile/Profile.tsx

@@ -11,8 +11,8 @@ import { observer } from "mobx-react";
 import React, { useEffect, useState } from "react";
 import { ActivityIndicator, Alert, ImageBackground, ScrollView, Text, TouchableOpacity, View } from 'react-native';
 import { renderers } from 'react-native-popup-menu';
+import { useChangePassword, useDeleteProfile, useEditProfile, useOwnedProfile, useToggleTips } from '../../api/profile';
 import { useAuth } from '../../state/external/auth-provider';
-import { useChangePassword, useDeleteProfile, useEditProfile, useOwnedProfile, useToggleTips } from '../../state/external/profiles';
 import { API_URL } from '../../utils/RequestUtils';
 import { PhotoPicker } from '../PhotoPicker';
 import { PrivacyLink } from '../PrivacyLink';
@@ -79,7 +79,7 @@ const Profile: React.FC = () => {
             }
             
         } catch (error) {
-            reportAxiosError("[ProfileData]: Something went wrong when changing a profile picture", error, true)
+            // reportAxiosError("[ProfileData]: Something went wrong when changing a profile picture", error, true)
             Alert.alert("Something went wrong when changing your profile picture.")
         }
     }

+ 0 - 0
src/presentation/profile/ProfileHeader.tsx → src/components/profile/ProfileHeader.tsx


+ 0 - 0
src/presentation/profile/ProfileSections.tsx → src/components/profile/ProfileSections.tsx


+ 0 - 0
src/presentation/profile/ProfileSections/ProfileAbout.tsx → src/components/profile/ProfileSections/ProfileAbout.tsx


+ 0 - 0
src/presentation/profile/ProfileSections/ProfileInformation.tsx → src/components/profile/ProfileSections/ProfileInformation.tsx


+ 0 - 0
src/presentation/profile/ProfileSections/ProfileLegal.tsx → src/components/profile/ProfileSections/ProfileLegal.tsx


+ 0 - 0
src/presentation/profile/ProfileSections/ProfilePrefs.tsx → src/components/profile/ProfileSections/ProfilePrefs.tsx


+ 0 - 0
src/presentation/profile/ProfileSections/ProfileSection.tsx → src/components/profile/ProfileSections/ProfileSection.tsx


+ 0 - 0
src/presentation/profile/ProfileSections/ProfileSectionHeader.tsx → src/components/profile/ProfileSections/ProfileSectionHeader.tsx


+ 0 - 0
src/presentation/profile/ProfileSections/ProfileSkills.tsx → src/components/profile/ProfileSections/ProfileSkills.tsx


+ 0 - 0
src/presentation/profile/ProfileSections/ProfileSubscription.tsx → src/components/profile/ProfileSections/ProfileSubscription.tsx


+ 3 - 7
src/presentation/profile/ProfileTemplate.tsx → src/components/profile/ProfileTemplate.tsx

@@ -9,7 +9,7 @@ import { observer } from "mobx-react"
 import React from "react"
 import { ImageBackground, Text } from "react-native"
 import { useAuth } from "../../state/external/auth-provider"
-import { BaseStackNavigationProp } from "../../navigation/base-stack-navigator"
+import { BaseStackNavigationProp } from "../navigation/base-stack-navigator"
 import LoginView from "./LoginView"
 import Profile from "./Profile"
 import { ProfileSectionHeader } from "./ProfileSections/ProfileSectionHeader"
@@ -20,16 +20,12 @@ import * as Linking from 'expo-linking'
  * The screen component that displays the user's profile. Gets user information from the {@link useProfile} hook.
  * @component
  */
-const ProfileTemplate: React.FC<{navigation: BaseStackNavigationProp}> = ({navigation}) => {
+const ProfileTemplate: React.FC = () => {
     const {accessToken} = useAuth()
 
-    const openFeedback = () => {
-        Linking.openURL('mailto:dev@clicknpush.ca')
-    }
-
     return (
         <ImageBackground source={require('../../../assets/cover.jpg')} style={ProfileMainStyles.profileMainContainer}>
-            {accessToken ? <Profile /> : <LoginView navigation={navigation} />}
+            {accessToken ? <Profile /> : <LoginView/>}
         </ImageBackground>
     )
 }

+ 1 - 1
src/presentation/profile/Registration/RegisterMain.tsx → src/components/profile/Registration/RegisterMain.tsx

@@ -9,7 +9,7 @@ import React, { useEffect, useState } from 'react';
 import { Alert, Dimensions, StyleSheet, View } from 'react-native';
 import 'react-native-get-random-values';
 import { Text } from 'react-native-paper';
-import { BaseStackNavigationProp } from '../../../navigation/base-stack-navigator';
+import { BaseStackNavigationProp } from '../../navigation/base-stack-navigator';
 import { PrimaryButton } from '../../Buttons';
 import UnauthorizedLayout from '../AuthLayout';
 import RegisterCredentials from './RegistrationSteps/RegisterCredential';

+ 0 - 0
src/presentation/profile/Registration/RegistrationSteps/RegisterCredential.tsx → src/components/profile/Registration/RegistrationSteps/RegisterCredential.tsx


+ 0 - 0
src/presentation/profile/Registration/RegistrationSteps/RegisterImage.tsx → src/components/profile/Registration/RegistrationSteps/RegisterImage.tsx


+ 0 - 0
src/presentation/profile/Registration/RegistrationSteps/RegisterMeasurements.tsx → src/components/profile/Registration/RegistrationSteps/RegisterMeasurements.tsx


+ 0 - 0
src/presentation/profile/Registration/RegistrationSteps/RegisterPassword.tsx → src/components/profile/Registration/RegistrationSteps/RegisterPassword.tsx


+ 0 - 0
src/presentation/profile/Styles/Profile.styles.tsx → src/components/profile/Styles/Profile.styles.tsx


+ 0 - 0
src/presentation/profile/Styles/ProfileSections.styles.tsx → src/components/profile/Styles/ProfileSections.styles.tsx


+ 30 - 0
src/main-store.ts

@@ -0,0 +1,30 @@
+import { IndoorMapStore } from "./components/maps/indoor/indoor-map.store";
+import { MapStore as RootMapStore } from "./components/maps/map-store";
+import { AddLandmarkStore } from "./components/maps/panels/add-landmark-panel/add-landmark-panel.store";
+import { FilterStore } from "./components/maps/panels/filter-panel/filter-panel.store";
+import { NearbyLandmarkPanelStore } from "./components/maps/panels/nearby-landmarks-panel/nearby-landmarks-panel.store";
+import { SelectedLandmarkStore } from "./components/maps/panels/selected-landmark-panel/selected-landmark-panel.store"
+import { VoicePanelStore } from "./components/maps/panels/voice-panel/voice-panel.store";
+
+export class MainStore {
+    mapRoot: RootMapStore;
+    mapIndoor: IndoorMapStore;
+    selectedLm: SelectedLandmarkStore;
+    addLm: AddLandmarkStore;
+    nearbyLm: NearbyLandmarkPanelStore;
+    voice: VoicePanelStore;
+    filters: FilterStore;
+    api: ApiStore;
+
+    constructor() {
+        this.selectedLm = new SelectedLandmarkStore();
+        this.addLm = new AddLandmarkStore();
+        this.nearbyLm = new NearbyLandmarkPanelStore();
+        this.voice = new VoicePanelStore(this);
+        this.mapRoot = new RootMapStore();
+        this.mapIndoor = new IndoorMapStore();
+        this.filters = new FilterStore();
+    }
+}
+
+export const store = new MainStore();

+ 2 - 3
src/state/external/PermissionsContext.tsx → src/permissions-context.tsx

@@ -3,8 +3,8 @@ import React, { createContext, useContext, useState, useMemo, useEffect } from "
 import { Platform } from "react-native"
 import { checkMultiple, openSettings, Permission, PERMISSIONS, request, requestMultiple, RESULTS } from "react-native-permissions"
 import * as Notifications from 'expo-notifications'
-import { useAuth } from "./auth-provider"
-import { useNotifications, useRegisterNotifications } from "./notifications"
+import { useAuth } from "./state/external/auth-provider"
+import { useNotifications, useRegisterNotifications } from "./api/notifications"
 
 interface PermissionsState {
     voicePermissionsGranted: boolean,
@@ -54,7 +54,6 @@ export const PermissionsContextProvider: React.FC = ({children}) => {
     const [mediaPermissionsGranted, setMediaPermissions] = useState(false)
     const [notificationPermissionsGranted, setNotificiationPermissions] = useState(false)
     const [permissionsLoading, setPermissionsLoading] = useState(false)
-    const [permissionsFinishedLoading, setPermissionsFinisedLoading] = useState(false)
 
     const getPermissions = async (permission: PermissionType, setPermission: (state: boolean) => void) => {
         try {

+ 0 - 0
src/permissions-store.ts


Някои файлове не бяха показани, защото твърде много файлове са промени