Viktoriia пре 1 месец
родитељ
комит
1c3a7bd2d8

+ 15 - 0
Route.tsx

@@ -104,7 +104,10 @@ import CreateEventScreen from 'src/screens/InAppScreens/TravelsScreen/CreateEven
 import MembersListScreen from 'src/screens/InAppScreens/MessagesScreen/MembersListScreen';
 import AllEventPhotosScreen from 'src/screens/InAppScreens/TravelsScreen/AllEventPhotosScreen';
 import ParticipantsListScreen from 'src/screens/InAppScreens/TravelsScreen/ParticipantsListScreen';
+import OfflineMapsScreen from 'src/screens/OfflineMapsScreen';
 import EventsNotificationsScreen from 'src/screens/NotificationsScreen/EventsNotificationsScreen';
+import SelectOwnMapScreen from 'src/screens/OfflineMapsScreen/SelectOwnMapScreen';
+import { SelectRegionScreen } from 'src/screens/OfflineMapsScreen/SelectRegionsScreen';
 
 enableScreens();
 
@@ -503,10 +506,22 @@ const Route = () => {
               name={NAVIGATION_PAGES.LOCATION_SHARING}
               component={LocationSharingScreen}
             />
+            <ScreenStack.Screen
+              name={NAVIGATION_PAGES.OFFLINE_MAPS}
+              component={OfflineMapsScreen}
+            />
             <ScreenStack.Screen
               name={NAVIGATION_PAGES.EVENTS_NOTIFICATIONS}
               component={EventsNotificationsScreen}
             />
+            <ScreenStack.Screen
+              name={NAVIGATION_PAGES.OFFLINE_SELECT_MAP}
+              component={SelectOwnMapScreen}
+            />
+            <ScreenStack.Screen
+              name={NAVIGATION_PAGES.OFFLINE_SELECT_REGIONS}
+              component={SelectRegionScreen}
+            />
           </ScreenStack.Navigator>
         )}
       </BottomTab.Screen>

Разлика између датотеке није приказан због своје велике величине
+ 3 - 0
assets/icons/map-offline.svg


+ 4 - 1
src/components/Input/index.tsx

@@ -32,6 +32,7 @@ type Props = {
   backgroundColor?: string;
   clearIcon?: ReactNode;
   setValue?: (value: string) => void;
+  autoFocus?: boolean;
 };
 
 const parseTextWithLinks = (text?: string): React.ReactNode => {
@@ -116,7 +117,8 @@ export const Input: FC<Props> = ({
   height,
   backgroundColor = Colors.FILL_LIGHT,
   clearIcon,
-  setValue
+  setValue,
+  autoFocus
 }) => {
   const [focused, setFocused] = useState(false);
 
@@ -189,6 +191,7 @@ export const Input: FC<Props> = ({
               }
             }}
             style={[{ height: '100%', width: '100%', flex: 1 }, !icon ? { padding: 10 } : null]}
+            autoFocus={autoFocus}
           />
         )}
         {/* <TextInput

+ 12 - 0
src/components/MenuDrawer/index.tsx

@@ -18,6 +18,7 @@ import InfoIcon from 'assets/icons/info-solid.svg';
 import BellIcon from 'assets/icons/notifications/bell-solid.svg';
 import SharingIcon from 'assets/icons/location-sharing.svg';
 import BagIcon from 'assets/icons/bag.svg';
+import OfflineIcon from 'assets/icons/map-offline.svg';
 
 import { APP_VERSION } from 'src/constants';
 import { useMessagesStore } from 'src/stores/unreadMessagesStore';
@@ -128,6 +129,17 @@ export const MenuDrawer = (props: any) => {
             red={false}
             buttonFn={() => Linking.openURL('https://nomadmania.com/product-category/merchandise/')}
           />
+          <MenuButton
+            label="Offline maps"
+            icon={<OfflineIcon fill={Colors.DARK_BLUE} width={20} height={20} />}
+            red={false}
+            buttonFn={() =>
+              // @ts-ignore
+              navigation.navigate(NAVIGATION_PAGES.MENU_DRAWER, {
+                screen: NAVIGATION_PAGES.OFFLINE_MAPS
+              })
+            }
+          />
         </View>
 
         <View style={styles.bottomMenu}>

+ 7 - 1
src/modules/api/app/app-api.ts

@@ -6,10 +6,16 @@ export interface PostGetLastUpdate extends ResponseType {
   date: string;
 }
 
+export interface PostGetPremiumStatus extends ResponseType {
+  'premium-status': number;
+}
+
 export const appApi = {
   deleteUser: (token: string) => request.postForm(API.DELETE_USER, { token }),
   getLastRegionsUpdate: (date: string) =>
     request.postForm<PostGetLastUpdate>(API.GET_LAST_REGIONS_DB_UPDATE, { date }),
   getLastDareUpdate: (date: string) =>
-    request.postForm<PostGetLastUpdate>(API.GET_LAST_DARE_DB_UPDATE, { date })
+    request.postForm<PostGetLastUpdate>(API.GET_LAST_DARE_DB_UPDATE, { date }),
+  premiumStatus: (token: string) =>
+    request.postForm<PostGetPremiumStatus>(API.GET_PREMIUM_STATUS, { token })
 };

+ 1 - 0
src/modules/api/app/app-query-keys.tsx

@@ -2,4 +2,5 @@ export const appQueryKeys = {
   deleteUser: () => ['deleteUser'] as const,
   getLastRegionsUpdate: () => ['getLastRegionsUpdate'] as const,
   getLastDareUpdate: () => ['getLastDareUpdate'] as const,
+  premiumStatus: (token: string) => ['premiumStatus', token] as const
 };

+ 1 - 0
src/modules/api/app/queries/index.ts

@@ -1,3 +1,4 @@
 export * from './use-post-delete-user';
 export * from './use-post-last-regions-db-update';
 export * from './use-post-last-dare-db-update';
+export * from './use-post-premium-status';

+ 21 - 0
src/modules/api/app/queries/use-post-premium-status.tsx

@@ -0,0 +1,21 @@
+import { appQueryKeys } from '../app-query-keys';
+import { type PostGetPremiumStatus, appApi } from '../app-api';
+import { queryClient } from 'src/utils/queryClient';
+
+export const fetchPremiumStatus = async (token: string) => {
+  try {
+    const data: PostGetPremiumStatus = await queryClient.fetchQuery({
+      queryKey: appQueryKeys.premiumStatus(token),
+      queryFn: async () => {
+        const response = await appApi.premiumStatus(token);
+        return response.data;
+      },
+      gcTime: 0,
+      staleTime: 0
+    });
+
+    return data;
+  } catch (error) {
+    console.error('Failed to fetch premium status:', error);
+  }
+};

+ 26 - 1
src/modules/api/maps/maps-api.ts

@@ -6,6 +6,22 @@ export interface PostGetVisitedIds extends ResponseType {
   ids: number[];
 }
 
+export interface PostGetMapDataForRegion extends ResponseType {
+  size: number;
+  size_humanreadable: string;
+  bbox: number[];
+  name: string;
+}
+
+export interface PostGetLastMapUpdateDate extends ResponseType {
+  last_update: string;
+}
+
+export interface PostGetSizeForBoundingBox extends ResponseType {
+  size: number;
+  size_humanreadable: string;
+}
+
 export const mapsApi = {
   getVisitedRegionsIds: (token: string, type: 'in' | 'by', year: number, uid: number) =>
     request.postForm<PostGetVisitedIds>(API.GET_VISITED_REGIONS_IDS, { token, type, year, uid }),
@@ -14,5 +30,14 @@ export const mapsApi = {
   getVisitedDareIds: (token: string, uid: number) =>
     request.postForm<PostGetVisitedIds>(API.GET_VISITED_DARE_IDS, { token, uid }),
   getVisitedSeriesIds: (token: string) =>
-    request.postForm<PostGetVisitedIds>(API.GET_VISITED_SERIES_IDS, { token })
+    request.postForm<PostGetVisitedIds>(API.GET_VISITED_SERIES_IDS, { token }),
+  getMapDataForRegion: (token: string, region_id: number) =>
+    request.postForm<PostGetMapDataForRegion>(API.GET_MAP_DATA_FOR_REGION, { token, region_id }),
+  getLastMapUpdateDate: (token: string) =>
+    request.postForm<PostGetLastMapUpdateDate>(API.GET_LAST_MAP_UPDATE_DATE, { token }),
+  getSizeForBoundingBox: (token: string, bbox_array: number[]) =>
+    request.postForm<PostGetSizeForBoundingBox>(API.GET_SIZE_FOR_BOUNDING_BOX, {
+      token,
+      bbox_array
+    })
 };

+ 8 - 1
src/modules/api/maps/maps-query-keys.tsx

@@ -14,5 +14,12 @@ export const mapsQueryKeys = {
     uid
   ],
   getVisitedDareIds: (token: string, uid: number) => ['getVisitedDareIds', token, uid],
-  getVisitedSeriesIds: (token: string) => ['getVisitedSeriesIds', token]
+  getVisitedSeriesIds: (token: string) => ['getVisitedSeriesIds', token],
+  getMapDataForRegion: () => ['getMapDataForRegion'],
+  getLastMapUpdateDate: (token: string) => ['getLastMapUpdateDate', token],
+  getSizeForBoundingBox: (token: string, bbox_array: number[]) => [
+    'getSizeForBoundingBox',
+    token,
+    bbox_array
+  ]
 };

+ 3 - 0
src/modules/api/maps/queries/index.ts

@@ -2,3 +2,6 @@ export * from './use-post-get-visited-regions-ids';
 export * from './use-post-get-visited-countries-ids';
 export * from './use-post-get-visited-dare-ids';
 export * from './use-post-get-visited-series-ids';
+export * from './use-post-get-map-data-for-region';
+export * from './use-post-get-last-map-update-date';
+export * from './use-post-get-size-for-bounding-box';

+ 17 - 0
src/modules/api/maps/queries/use-post-get-last-map-update-date.tsx

@@ -0,0 +1,17 @@
+import { useQuery } from '@tanstack/react-query';
+
+import { mapsQueryKeys } from '../maps-query-keys';
+import { mapsApi, type PostGetLastMapUpdateDate } from '../maps-api';
+
+import type { BaseAxiosError } from '../../../../types';
+
+export const usePostGetLastMapUpdateDateQuery = (token: string, enabled: boolean) => {
+  return useQuery<PostGetLastMapUpdateDate, BaseAxiosError>({
+    queryKey: mapsQueryKeys.getLastMapUpdateDate(token),
+    queryFn: async () => {
+      const response = await mapsApi.getLastMapUpdateDate(token);
+      return response.data;
+    },
+    enabled
+  });
+};

+ 21 - 0
src/modules/api/maps/queries/use-post-get-map-data-for-region.tsx

@@ -0,0 +1,21 @@
+import { useMutation } from '@tanstack/react-query';
+
+import { mapsQueryKeys } from '../maps-query-keys';
+import { mapsApi, type PostGetMapDataForRegion } from '../maps-api';
+
+import type { BaseAxiosError } from '../../../../types';
+
+export const usePostGetMapDataForRegionMutation = () => {
+  return useMutation<
+    PostGetMapDataForRegion,
+    BaseAxiosError,
+    { token: string; region_id: number },
+    PostGetMapDataForRegion
+  >({
+    mutationKey: mapsQueryKeys.getMapDataForRegion(),
+    mutationFn: async (data) => {
+      const response = await mapsApi.getMapDataForRegion(data.token, data.region_id);
+      return response.data;
+    }
+  });
+};

+ 21 - 0
src/modules/api/maps/queries/use-post-get-size-for-bounding-box.tsx

@@ -0,0 +1,21 @@
+import { useQuery } from '@tanstack/react-query';
+
+import { mapsQueryKeys } from '../maps-query-keys';
+import { mapsApi, type PostGetSizeForBoundingBox } from '../maps-api';
+
+import type { BaseAxiosError } from '../../../../types';
+
+export const usePostGetSizeForBoundingBoxQuery = (
+  token: string,
+  bbox_array: number[], // [minLon, minLat, maxLon, maxLat] JSON string
+  enabled: boolean
+) => {
+  return useQuery<PostGetSizeForBoundingBox, BaseAxiosError>({
+    queryKey: mapsQueryKeys.getSizeForBoundingBox(token, bbox_array),
+    queryFn: async () => {
+      const response = await mapsApi.getSizeForBoundingBox(token, bbox_array);
+      return response.data;
+    },
+    enabled
+  });
+};

+ 91 - 35
src/screens/InAppScreens/MapScreen/index.tsx

@@ -53,6 +53,7 @@ import { useGetUniversalSearch } from '@api/search';
 import { fetchCountryUserData, useGetListCountriesQuery } from '@api/countries';
 import SearchModal from './UniversalSearch';
 import EditModal from '../TravelsScreen/Components/EditSlowModal';
+import * as FileSystem from 'expo-file-system';
 
 import CheckSvg from 'assets/icons/mark.svg';
 import moment from 'moment';
@@ -282,6 +283,8 @@ const INITIAL_REGION = {
   longitudeDelta: 180
 };
 
+const ICONS_DIR = FileSystem.documentDirectory + 'series_icons/';
+
 const MapScreen: any = ({ navigation, route }: { navigation: any; route: any }) => {
   const tabBarHeight = useBottomTabBarHeight();
   const userId = storage.get('uid', StoreType.STRING) as string;
@@ -449,47 +452,84 @@ const MapScreen: any = ({ navigation, route }: { navigation: any; route: any })
   }, [usersLocation, showNomads]);
 
   useEffect(() => {
-    if (seriesIcons) {
-      const loadImages = async () => {
-        const loadedSeriesImages: Record<string, { uri: string }> = {};
-        const prefetchUrls: string[] = [];
-
-        const promises = seriesIcons.data.map(async (icon) => {
-          const id = icon.id?.toString();
-          const img = `${API_HOST}/static/img/series_new2_small/${icon.new_icon_png}`;
-          const imgVisited = `${API_HOST}/static/img/series_new2_small/${icon.new_icon_visited_png}`;
-
-          if (
-            icon.new_icon_png &&
-            icon.new_icon_visited_png &&
-            !processedImages.current.has(id) &&
-            !processedImages.current.has(`${id}v`)
-          ) {
-            processedImages.current.add(id);
-            processedImages.current.add(`${id}v`);
-
-            loadedSeriesImages[id] = { uri: img };
-            loadedSeriesImages[`${id}v`] = { uri: imgVisited };
-
-            const cachedUrl = await ExpoImage.getCachePathAsync(img);
-            const cachedUrlVisited = await ExpoImage.getCachePathAsync(imgVisited);
-
-            if (!cachedUrl) prefetchUrls.push(img);
-            if (!cachedUrlVisited) prefetchUrls.push(imgVisited);
-          }
+    const loadCachedIcons = async () => {
+      try {
+        const dirInfo = await FileSystem.getInfoAsync(ICONS_DIR);
+        if (!dirInfo.exists) return;
+
+        const files = await FileSystem.readDirectoryAsync(ICONS_DIR);
+        const cachedImages: Record<string, { uri: string }> = {};
+
+        files.forEach((fileName) => {
+          if (!fileName.endsWith('.png')) return;
+
+          const key = fileName.replace('.png', '');
+          cachedImages[key] = {
+            uri: ICONS_DIR + fileName
+          };
+
+          processedImages.current.add(key);
         });
 
-        await Promise.all(promises);
+        setImages((prev: any) => ({ ...prev, ...cachedImages }));
+      } catch (e) {
+        console.warn('Error loading cached icons:', e);
+      }
+    };
+
+    loadCachedIcons();
+  }, []);
+
+  useEffect(() => {
+    if (!seriesIcons) return;
+
+    const updateCacheFromAPI = async () => {
+      const loadedImages: Record<string, { uri: string }> = {};
+
+      const dirInfo = await FileSystem.getInfoAsync(ICONS_DIR);
+      if (!dirInfo.exists) {
+        await FileSystem.makeDirectoryAsync(ICONS_DIR, { intermediates: true });
+      }
+
+      const promises = seriesIcons.data.map(async (icon) => {
+        const id = icon.id?.toString();
+        if (!id || processedImages.current.has(id)) return;
 
-        setImages((prevImages: any) => ({ ...prevImages, ...loadedSeriesImages }));
+        const imgUrl = `${API_HOST}/static/img/series_new2_small/${icon.new_icon_png}`;
+        const imgVisitedUrl = `${API_HOST}/static/img/series_new2_small/${icon.new_icon_visited_png}`;
 
-        if (prefetchUrls.length > 0) {
-          ExpoImage.prefetch(prefetchUrls);
+        const localPath = `${ICONS_DIR}${id}.png`;
+        const localPathVisited = `${ICONS_DIR}${id}v.png`;
+
+        const [imgInfo, visitedInfo] = await Promise.all([
+          FileSystem.getInfoAsync(localPath),
+          FileSystem.getInfoAsync(localPathVisited)
+        ]);
+
+        try {
+          if (!imgInfo.exists) {
+            await FileSystem.downloadAsync(imgUrl, localPath);
+          }
+          if (!visitedInfo.exists) {
+            await FileSystem.downloadAsync(imgVisitedUrl, localPathVisited);
+          }
+        } catch (e) {
+          console.warn(`Download failed for ${id}:`, e);
+          return;
         }
-      };
 
-      loadImages();
-    }
+        processedImages.current.add(id);
+        processedImages.current.add(`${id}v`);
+
+        loadedImages[id] = { uri: localPath };
+        loadedImages[`${id}v`] = { uri: localPathVisited };
+      });
+
+      await Promise.all(promises);
+      setImages((prev: any) => ({ ...prev, ...loadedImages }));
+    };
+
+    updateCacheFromAPI();
   }, [seriesIcons]);
 
   useEffect(() => {
@@ -539,24 +579,40 @@ const MapScreen: any = ({ navigation, route }: { navigation: any; route: any })
   useEffect(() => {
     if (visitedRegionIds) {
       setRegionsVisited(visitedRegionIds.ids);
+      storage.set('visitedRegions', JSON.stringify(visitedRegionIds.ids));
+    } else {
+      const storedVisited = storage.get('visitedRegions', StoreType.STRING) as string;
+      setRegionsVisited(storedVisited ? JSON.parse(storedVisited) : []);
     }
   }, [visitedRegionIds]);
 
   useEffect(() => {
     if (visitedCountryIds) {
       setCountriesVisited(visitedCountryIds.ids);
+      storage.set('visitedCountries', JSON.stringify(visitedCountryIds.ids));
+    } else {
+      const storedVisited = storage.get('visitedCountries', StoreType.STRING) as string;
+      setCountriesVisited(storedVisited ? JSON.parse(storedVisited) : []);
     }
   }, [visitedCountryIds]);
 
   useEffect(() => {
     if (visitedDareIds) {
       setDareVisited(visitedDareIds.ids);
+      storage.set('visitedDares', JSON.stringify(visitedDareIds.ids));
+    } else {
+      const storedVisited = storage.get('visitedDares', StoreType.STRING) as string;
+      setDareVisited(storedVisited ? JSON.parse(storedVisited) : []);
     }
   }, [visitedDareIds]);
 
   useEffect(() => {
     if (visitedSeriesIds && token) {
       setSeriesVisited(visitedSeriesIds.ids);
+      storage.set('visitedSeries', JSON.stringify(visitedSeriesIds.ids));
+    } else {
+      const storedVisited = storage.get('visitedSeries', StoreType.STRING) as string;
+      setSeriesVisited(storedVisited ? JSON.parse(storedVisited) : []);
     }
   }, [visitedSeriesIds]);
 

+ 464 - 0
src/screens/OfflineMapsScreen/OfflineMapManager.ts

@@ -0,0 +1,464 @@
+import * as MapLibreGL from '@maplibre/maplibre-react-native';
+import { AppState, Platform } from 'react-native';
+import NetInfo from '@react-native-community/netinfo';
+import { MMKV } from 'react-native-mmkv';
+import { VECTOR_MAP_HOST } from 'src/constants';
+
+const storage = new MMKV();
+const OFFLINE_MAPS_KEY = 'offline_maps';
+const PENDING_DOWNLOADS_KEY = 'pending_offline_map_downloads';
+
+const progressCallbacks = {};
+
+let pendingDownloads: any = [];
+try {
+  const pendingDownloadsString = storage.getString(PENDING_DOWNLOADS_KEY);
+  pendingDownloads = pendingDownloadsString ? JSON.parse(pendingDownloadsString) : [];
+} catch (error) {
+  console.error('Error loading pending downloads:', error);
+  pendingDownloads = [];
+}
+
+const savePendingDownloads = () => {
+  storage.set(PENDING_DOWNLOADS_KEY, JSON.stringify(pendingDownloads));
+};
+
+const markPackAsCompleted = (packName) => {
+  const mapsString = storage.getString(OFFLINE_MAPS_KEY);
+  if (!mapsString) return;
+
+  const maps = JSON.parse(mapsString);
+  const mapIndex = maps.findIndex((map) => map.id === packName);
+
+  if (mapIndex >= 0) {
+    maps[mapIndex].status = 'valid';
+    maps[mapIndex].progress = 100;
+    maps[mapIndex].isPaused = false;
+    maps[mapIndex].updatedAt = Date.now();
+
+    storage.set(OFFLINE_MAPS_KEY, JSON.stringify(maps));
+
+    pendingDownloads = pendingDownloads.filter((name) => name !== packName);
+    savePendingDownloads();
+
+    console.log(`Pack ${packName} marked as completed`);
+  }
+};
+
+let appStateSubscription: any = null;
+
+const setupAppStateListener = () => {
+  if (appStateSubscription) {
+    appStateSubscription.remove();
+  }
+
+  appStateSubscription = AppState.addEventListener('change', async (nextAppState) => {
+    if (nextAppState === 'active') {
+      try {
+        const netInfo = await NetInfo.fetch();
+        const isWifi = netInfo.type === 'wifi';
+
+        if (isWifi || Platform.OS === 'android') {
+          console.log('App coming to foreground, resuming downloads');
+          resumeAllPendingDownloads();
+        } else {
+          console.log('App coming to foreground, but not on WiFi, not resuming downloads');
+        }
+      } catch (error) {
+        console.error('Error checking network when app came to foreground:', error);
+        resumeAllPendingDownloads();
+      }
+    }
+  });
+};
+
+setupAppStateListener();
+
+const init = () => {
+  MapLibreGL.OfflineManager.setProgressEventThrottle(800);
+
+  resumeAllPendingDownloads();
+  cleanupCompletedMaps();
+};
+
+const cleanupCompletedMaps = async () => {
+  try {
+    const mapsString = storage.getString(OFFLINE_MAPS_KEY);
+    if (!mapsString) return;
+
+    const maps = JSON.parse(mapsString);
+    const inProgressMaps = maps.filter((map) => map.status === 'downloading');
+
+    for (const map of inProgressMaps) {
+      const pack = await getPack(map.id);
+
+      if (pack) {
+        const status = await pack.status();
+
+        if (
+          status &&
+          (status.percentage === 100 ||
+            status.completedResourceCount === status.requiredResourceCount)
+        ) {
+          console.log(`Found completed pack ${map.id} that was still marked as downloading`);
+          markPackAsCompleted(map.id);
+        }
+      }
+    }
+  } catch (error) {
+    console.error('Error cleaning up completed maps:', error);
+  }
+};
+
+const subscribeToPackProgress = (packName, progressCallback, errorCallback) => {
+  if (progressCallback) {
+    progressCallbacks[packName] = progressCallback;
+  }
+
+  MapLibreGL.OfflineManager.subscribe(
+    packName,
+    (pack, status) => {
+      if (status.completedResourceCount === status.requiredResourceCount) {
+        console.log(`Pack ${packName} download completed`);
+        markPackAsCompleted(packName);
+      }
+
+      const percentage =
+        status.requiredResourceCount > 0
+          ? (status.completedResourceCount / status.requiredResourceCount) * 100
+          : 0;
+
+      updateMapMetadata(packName, {
+        progress: percentage,
+        size: status.completedResourceSize || 0
+      });
+
+      if (progressCallbacks[packName]) {
+        progressCallbacks[packName]({
+          name: packName,
+          percentage,
+          completedSize: status.completedResourceSize,
+          completedResourceCount: status.completedResourceCount,
+          requiredResourceCount: status.requiredResourceCount
+        });
+      }
+    },
+    (error) => {
+      console.error(`Error in progress listener for ${packName}:`, error);
+
+      if (errorCallback) {
+        errorCallback(error);
+      }
+    }
+  );
+};
+
+const createPack = async (options, progressCallback, errorCallback) => {
+  try {
+    subscribeToPackProgress(options.name, progressCallback, errorCallback);
+
+    if (!pendingDownloads.includes(options.name)) {
+      pendingDownloads.push(options.name);
+      savePendingDownloads();
+    }
+
+    const mapsString = storage.getString(OFFLINE_MAPS_KEY);
+    const maps = mapsString ? JSON.parse(mapsString) : [];
+    const mapIndex = maps.findIndex((map) => map.id === options.name);
+
+    if (mapIndex >= 0) {
+      maps[mapIndex].styleURL = options.styleURL;
+      maps[mapIndex].minZoom = options.minZoom;
+      maps[mapIndex].maxZoom = options.maxZoom;
+
+      const [[west, south], [east, north]] = options.bounds;
+      const bounds = { north, south, east, west };
+      maps[mapIndex].bounds = JSON.stringify(bounds);
+
+      storage.set(OFFLINE_MAPS_KEY, JSON.stringify(maps));
+    }
+
+    return await MapLibreGL.OfflineManager.createPack(options);
+  } catch (error) {
+    console.error('Error creating offline pack:', error);
+
+    pendingDownloads = pendingDownloads.filter((name) => name !== options.name);
+    savePendingDownloads();
+
+    updateMapMetadata(options.name, { status: 'invalid' });
+
+    throw error;
+  }
+};
+
+const getPacks = async () => {
+  try {
+    return await MapLibreGL.OfflineManager.getPacks();
+  } catch (error) {
+    console.error('Error getting offline packs:', error);
+    return [];
+  }
+};
+
+const getPack = async (name) => {
+  try {
+    return await MapLibreGL.OfflineManager.getPack(name);
+  } catch (error) {
+    console.error(`Error getting offline pack ${name}:`, error);
+    return null;
+  }
+};
+
+const deletePack = async (name) => {
+  try {
+    const pack = await getPack(name);
+    if (pack) {
+      await MapLibreGL.OfflineManager.unsubscribe(name);
+
+      delete progressCallbacks[name];
+
+      // Delete the pack
+      await MapLibreGL.OfflineManager.deletePack(name);
+    }
+
+    pendingDownloads = pendingDownloads.filter((downloadName) => downloadName !== name);
+    savePendingDownloads();
+  } catch (error) {
+    console.error(`Error deleting pack ${name}:`, error);
+    throw error;
+  }
+};
+
+const invalidatePack = async (name) => {
+  try {
+    await MapLibreGL.OfflineManager.invalidatePack(name);
+
+    updateMapMetadata(name, { status: 'invalid' });
+  } catch (error) {
+    console.error(`Error invalidating pack ${name}:`, error);
+    throw error;
+  }
+};
+
+const resumePackDownload = async (name: string, progressCallback?: any, errorCallback?: any) => {
+  try {
+    let pack = await getPack(name);
+
+    if (progressCallback) {
+      progressCallbacks[name] = progressCallback;
+    }
+
+    if (pack) {
+      const status = await pack.status();
+      console.log(`Pack ${name} status:`, status);
+
+      if (
+        status &&
+        (status.percentage === 100 ||
+          status.completedResourceCount === status.requiredResourceCount)
+      ) {
+        console.log(`Pack ${name} is already complete`);
+        markPackAsCompleted(name);
+        return;
+      }
+
+      subscribeToPackProgress(name, progressCallbacks[name], errorCallback);
+
+      await pack.resume();
+
+      updateMapMetadata(name, {
+        status: 'downloading',
+        isPaused: false
+      });
+
+      console.log(`Resumed download for pack ${name}`);
+      return;
+    }
+
+    console.log(`Pack ${name} not found, recreating...`);
+
+    const mapsString = storage.getString(OFFLINE_MAPS_KEY);
+    if (!mapsString) {
+      throw new Error('No maps data in storage');
+    }
+
+    const maps = JSON.parse(mapsString);
+    const mapData = maps.find((m) => m.id === name);
+
+    if (!mapData) {
+      throw new Error(`Map ${name} not found in storage`);
+    }
+
+    const bounds = mapData.bounds ? JSON.parse(mapData.bounds) : null;
+    if (!bounds) {
+      throw new Error(`Invalid bounds for pack ${name}`);
+    }
+
+    const options = {
+      name,
+      styleURL: mapData.styleURL || `${VECTOR_MAP_HOST}/nomadmania-maps.json`,
+      minZoom: mapData.minZoom || 10,
+      maxZoom: mapData.maxZoom || 20,
+      bounds: [
+        [bounds.west, bounds.south],
+        [bounds.east, bounds.north]
+      ]
+    };
+
+    updateMapMetadata(name, {
+      status: 'downloading',
+      isPaused: false
+    });
+
+    await createPack(options, progressCallbacks[name], errorCallback);
+
+    if (!pendingDownloads.includes(name)) {
+      pendingDownloads.push(name);
+      savePendingDownloads();
+    }
+
+    console.log(`Created new pack for ${name}`);
+  } catch (error) {
+    console.error(`Error resuming pack ${name}:`, error);
+
+    updateMapMetadata(name, {
+      status: 'invalid',
+      error: error.message
+    });
+  }
+};
+
+const resumeAllPendingDownloads = async () => {
+  const mapsString = storage.getString(OFFLINE_MAPS_KEY);
+  if (!mapsString) return;
+
+  const maps = JSON.parse(mapsString);
+
+  const downloadingMaps = maps.filter((map) => map.status === 'downloading' && !map.isPaused);
+
+  console.log(`Found ${downloadingMaps.length} maps to resume`);
+
+  for (const map of downloadingMaps) {
+    try {
+      await resumePackDownload(map.id);
+    } catch (error) {
+      console.error(`Error resuming download for map ${map.id}:`, error);
+    }
+  }
+};
+
+const updateMapMetadata = (id, updates) => {
+  try {
+    const mapsString = storage.getString(OFFLINE_MAPS_KEY);
+    const maps = mapsString ? JSON.parse(mapsString) : [];
+
+    const mapIndex = maps.findIndex((map) => map.id === id);
+    if (mapIndex >= 0) {
+      maps[mapIndex] = { ...maps[mapIndex], ...updates };
+      storage.set(OFFLINE_MAPS_KEY, JSON.stringify(maps));
+    }
+  } catch (error) {
+    console.error(`Error updating map metadata for ${id}:`, error);
+  }
+};
+
+const cancelPackDownload = async (name) => {
+  try {
+    await MapLibreGL.OfflineManager.unsubscribe(name);
+
+    delete progressCallbacks[name];
+
+    try {
+      await MapLibreGL.OfflineManager.deletePack(name);
+    } catch (e) {}
+
+    pendingDownloads = pendingDownloads.filter((downloadName) => downloadName !== name);
+    savePendingDownloads();
+
+    updateMapMetadata(name, {
+      status: 'invalid',
+      progress: 0,
+      isPaused: false
+    });
+
+    console.log(`Pack ${name} download canceled`);
+  } catch (error) {
+    console.error(`Error canceling pack ${name}:`, error);
+    throw error;
+  }
+};
+
+const updatePack = async (name, progressCallback, errorCallback) => {
+  try {
+    const mapsString = storage.getString(OFFLINE_MAPS_KEY);
+    if (!mapsString) {
+      throw new Error('No maps data found in storage');
+    }
+
+    const maps = JSON.parse(mapsString);
+    const mapData = maps.find((m) => m.id === name);
+
+    if (!mapData) {
+      throw new Error(`Map ${name} not found in MMKV`);
+    }
+
+    const bounds = mapData.bounds ? JSON.parse(mapData.bounds) : null;
+    if (!bounds) {
+      throw new Error(`Invalid bounds for map ${name}`);
+    }
+
+    try {
+      await deletePack(name);
+    } catch (error) {
+      console.log(`Error deleting pack ${name} before update:`, error);
+    }
+
+    const options = {
+      name,
+      styleURL: mapData.styleURL || `${VECTOR_MAP_HOST}/nomadmania-maps.json`,
+      minZoom: mapData.minZoom || 10,
+      maxZoom: mapData.maxZoom || 20,
+      bounds: [
+        [bounds.west, bounds.south],
+        [bounds.east, bounds.north]
+      ]
+    };
+
+    if (progressCallback) {
+      progressCallbacks[name] = progressCallback;
+    }
+
+    updateMapMetadata(name, {
+      status: 'downloading',
+      progress: 0,
+      isPaused: false,
+      updatedAt: Date.now()
+    });
+
+    await createPack(options, progressCallback, errorCallback);
+
+    console.log(`Pack ${name} update started`);
+  } catch (error) {
+    console.error(`Error updating pack ${name}:`, error);
+
+    updateMapMetadata(name, {
+      status: 'invalid',
+      error: error.message
+    });
+
+    throw error;
+  }
+};
+
+export const offlineMapManager = {
+  init,
+  createPack,
+  getPacks,
+  getPack,
+  deletePack,
+  invalidatePack,
+  resumePackDownload,
+  resumeAllPendingDownloads,
+  cancelPackDownload,
+  updatePack
+};

+ 559 - 0
src/screens/OfflineMapsScreen/SelectOwnMapScreen/index.tsx

@@ -0,0 +1,559 @@
+import React, { useState, useRef, useEffect } from 'react';
+import {
+  View,
+  Text,
+  StyleSheet,
+  TouchableOpacity,
+  Alert,
+  TextInput,
+  Dimensions,
+  ActivityIndicator,
+  KeyboardAvoidingView,
+  Platform
+} from 'react-native';
+import * as MapLibreGL from '@maplibre/maplibre-react-native';
+import { Ionicons } from '@expo/vector-icons';
+import { MMKV } from 'react-native-mmkv';
+import NetInfo from '@react-native-community/netinfo';
+import { VECTOR_MAP_HOST } from 'src/constants';
+import { offlineMapManager } from '../OfflineMapManager';
+import { useSubscription } from '../useSubscription';
+import { formatBytes } from '../formatters';
+import { Header, PageWrapper } from 'src/components';
+import { SafeAreaView } from 'react-native-safe-area-context';
+
+const storage = new MMKV();
+const OFFLINE_MAPS_KEY = 'offline_maps';
+const { width, height } = Dimensions.get('window');
+
+const MAX_SELECTOR_WIDTH_RATIO = 0.08;
+const MAX_SELECTOR_HEIGHT_RATIO = 0.06;
+const MIN_SELECTOR_WIDTH_RATIO = 0.9;
+const MIN_SELECTOR_HEIGHT_RATIO = 0.6;
+const ZOOM_THRESHOLD_START = 3;
+const ZOOM_THRESHOLD_END = 7;
+
+const AVERAGE_VECTOR_TILE_SIZE_BYTES = 500;
+const TILE_SIZE_MULTIPLIER_BY_ZOOM = {
+  10: 0.5,
+  11: 0.7,
+  12: 0.9,
+  13: 1.1,
+  14: 1.3,
+  15: 1.6,
+  16: 1.9,
+  17: 2.2,
+  18: 2.2,
+  19: 2.2,
+  20: 2.2
+};
+
+export default function SelectOwnMapScreen({ navigation, route }) {
+  const map = useRef(null);
+  const [mapLoaded, setMapLoaded] = useState(false);
+  const [currentZoom, setCurrentZoom] = useState(1);
+  const [selectorBounds, setSelectorBounds] = useState({
+    north: 0,
+    south: 0,
+    east: 0,
+    west: 0
+  });
+  const [name, setName] = useState('');
+  const [estimatedSize, setEstimatedSize] = useState(0);
+  const [isConnectedToWifi, setIsConnectedToWifi] = useState(true);
+  const { isPremium } = useSubscription();
+  const [selectorSize, setSelectorSize] = useState({
+    width: width * MAX_SELECTOR_WIDTH_RATIO,
+    height: height * MAX_SELECTOR_HEIGHT_RATIO
+  });
+  const [mapCenter, setMapCenter] = useState([0, 0]);
+  const [tilesCount, setTilesCount] = useState(0);
+
+  const updateMap = route.params?.updateMap;
+
+  useEffect(() => {
+    if (updateMap) {
+      setName(updateMap.name);
+    } else {
+      const now = new Date();
+      setName(`Offline Map ${now.toLocaleDateString()}`);
+    }
+
+    checkConnectionType();
+
+    MapLibreGL.setAccessToken(null);
+  }, [updateMap]);
+
+  useEffect(() => {
+    setSelectorSize(getSelectorSize());
+  }, [currentZoom]);
+
+  const getSelectorSize = () => {
+    if (currentZoom <= ZOOM_THRESHOLD_START) {
+      return {
+        width: width * MAX_SELECTOR_WIDTH_RATIO,
+        height: height * MAX_SELECTOR_HEIGHT_RATIO
+      };
+    } else if (currentZoom >= ZOOM_THRESHOLD_END) {
+      return {
+        width: width * MIN_SELECTOR_WIDTH_RATIO,
+        height: height * MIN_SELECTOR_HEIGHT_RATIO
+      };
+    } else {
+      const zoomRatio =
+        (currentZoom - ZOOM_THRESHOLD_START) / (ZOOM_THRESHOLD_END - ZOOM_THRESHOLD_START);
+      const widthRatio =
+        MAX_SELECTOR_WIDTH_RATIO -
+        zoomRatio * (MAX_SELECTOR_WIDTH_RATIO - MIN_SELECTOR_WIDTH_RATIO);
+      const heightRatio =
+        MAX_SELECTOR_HEIGHT_RATIO -
+        zoomRatio * (MAX_SELECTOR_HEIGHT_RATIO - MIN_SELECTOR_HEIGHT_RATIO);
+
+      return {
+        width: width * widthRatio,
+        height: height * heightRatio
+      };
+    }
+  };
+
+  const checkConnectionType = async () => {
+    try {
+      const connectionInfo = await NetInfo.fetch();
+      setIsConnectedToWifi(connectionInfo.type === 'wifi');
+    } catch (error) {
+      console.error('Error checking network connection:', error);
+      setIsConnectedToWifi(true);
+    }
+  };
+
+  const onMapLoad = () => {
+    setMapLoaded(true);
+
+    if (updateMap && updateMap.bounds) {
+      try {
+        const bounds = JSON.parse(updateMap.bounds);
+
+        setSelectorBounds({
+          north: bounds.north,
+          south: bounds.south,
+          east: bounds.east,
+          west: bounds.west
+        });
+
+        map.current.fitBounds([bounds.west, bounds.south], [bounds.east, bounds.north], 50, 500);
+
+        calculateEstimatedSizeFromSelector(bounds);
+      } catch (error) {
+        console.error('Error centering map on bounds:', error);
+      }
+    }
+  };
+
+  const onRegionDidChange = async (feature) => {
+    if (!map.current) return;
+    try {
+      const zoom = feature.properties.zoomLevel;
+      setCurrentZoom(zoom || 1);
+
+      if (feature.geometry && feature.geometry.coordinates) {
+        setMapCenter(feature.geometry.coordinates);
+      }
+
+      const newSelectorBounds = calculateSelectorBounds(
+        feature.geometry.coordinates,
+        selectorSize,
+        zoom
+      );
+      setSelectorBounds(newSelectorBounds);
+
+      calculateEstimatedSizeFromSelector(newSelectorBounds);
+    } catch (error) {
+      console.error('Error updating selector bounds:', error);
+    }
+  };
+
+  const calculateSelectorBounds = (center, size, zoom) => {
+    const EARTH_CIRCUMFERENCE = 40075016.686;
+    const TILE_SIZE = 512;
+
+    const metersPerPixel =
+      (EARTH_CIRCUMFERENCE * Math.cos((center[1] * Math.PI) / 180)) /
+      Math.pow(2, zoom + 1) /
+      TILE_SIZE;
+
+    const widthMeters = size.width * metersPerPixel;
+    const heightMeters = size.height * metersPerPixel;
+
+    const lonOffsetDegrees = widthMeters / 2 / (EARTH_CIRCUMFERENCE / 360);
+    const latOffsetDegrees = heightMeters / 2 / (EARTH_CIRCUMFERENCE / 360);
+
+    return {
+      west: center[0] - lonOffsetDegrees,
+      east: center[0] + lonOffsetDegrees,
+      south: center[1] - latOffsetDegrees,
+      north: center[1] + latOffsetDegrees
+    };
+  };
+
+  const calculateEstimatedSizeFromSelector = async (bounds) => {
+    try {
+      const options = {
+        styleURL: `${VECTOR_MAP_HOST}/nomadmania-maps.json`,
+        bounds: [
+          [bounds.west, bounds.south],
+          [bounds.east, bounds.north]
+        ],
+        minZoom: 10,
+        maxZoom: 20
+      };
+
+      let totalTilesCount = 0;
+      let totalSizeBytes = 0;
+
+      for (let zoom = 10; zoom <= 20; zoom++) {
+        const tilesAtZoom = calculateTilesInBounds(
+          bounds.west,
+          bounds.south,
+          bounds.east,
+          bounds.north,
+          zoom
+        );
+
+        totalTilesCount += tilesAtZoom;
+
+        const tileSizeBytes =
+          AVERAGE_VECTOR_TILE_SIZE_BYTES * (TILE_SIZE_MULTIPLIER_BY_ZOOM[zoom] || 1.0);
+        const zoomLevelSizeBytes = tilesAtZoom * tileSizeBytes;
+
+        totalSizeBytes += zoomLevelSizeBytes;
+      }
+
+      setTilesCount(totalTilesCount);
+
+      totalSizeBytes = totalSizeBytes * 1.1;
+
+      setEstimatedSize(totalSizeBytes);
+    } catch (error) {
+      console.error('Error estimating tile size:', error);
+      const area = Math.abs((bounds.north - bounds.south) * (bounds.east - bounds.west));
+      const estimatedBytes = Math.round(area * 100000);
+      setEstimatedSize(estimatedBytes);
+    }
+  };
+
+  const calculateTilesInBounds = (west, south, east, north, zoom) => {
+    const nwTile = latLonToTile(north, west, zoom);
+    const seTile = latLonToTile(south, east, zoom);
+
+    const xTiles = Math.abs(seTile.x - nwTile.x) + 1;
+    const yTiles = Math.abs(seTile.y - nwTile.y) + 1;
+
+    return xTiles * yTiles;
+  };
+
+  const latLonToTile = (lat, lon, zoom) => {
+    const n = 2.0 ** zoom;
+    const x = Math.floor(((lon + 180.0) / 360.0) * n);
+    const y = Math.floor(
+      ((1.0 -
+        Math.log(Math.tan((lat * Math.PI) / 180.0) + 1.0 / Math.cos((lat * Math.PI) / 180.0)) /
+          Math.PI) /
+        2.0) *
+        n
+    );
+
+    return { x, y };
+  };
+
+  const downloadMap = async () => {
+    if (!isPremium) {
+      Alert.alert('Premium Required', 'Offline maps are available only with premium subscription.');
+      return;
+    }
+
+    if (!name.trim()) {
+      Alert.alert('Error', 'Please enter a name for this offline map');
+      return;
+    }
+
+    if (tilesCount > 5000) {
+      Alert.alert(
+        'Large Area Selected',
+        `You've selected an area with ${tilesCount} tiles. This may take a long time to download and use a lot of storage. Would you like to continue?`,
+        [
+          { text: 'Cancel', style: 'cancel' },
+          { text: 'Continue', onPress: () => checkConnectionAndDownload() }
+        ]
+      );
+    } else {
+      checkConnectionAndDownload();
+    }
+  };
+
+  const checkConnectionAndDownload = () => {
+    if (!isConnectedToWifi) {
+      Alert.alert(
+        'Mobile Data Connection',
+        'You are not connected to WiFi. Downloading map tiles may use a significant amount of data. Do you want to continue?',
+        [
+          { text: 'Cancel', style: 'cancel' },
+          { text: 'Download', onPress: () => startDownload() }
+        ]
+      );
+    } else {
+      startDownload();
+    }
+  };
+
+  const startDownload = async () => {
+    try {
+      const packId = updateMap ? updateMap.id : `map_${Date.now()}`;
+
+      const offlinePackOptions = {
+        name: packId,
+        styleURL: `${VECTOR_MAP_HOST}/nomadmania-maps.json`,
+        bounds: [
+          [selectorBounds.west, selectorBounds.south],
+          [selectorBounds.east, selectorBounds.north]
+        ],
+        minZoom: 10,
+        maxZoom: 20
+      };
+
+      if (updateMap) {
+        try {
+          await offlineMapManager.deletePack(packId);
+        } catch (error) {
+          console.log('Error deleting existing pack:', error);
+        }
+      }
+
+      const savedMapsString = storage.getString(OFFLINE_MAPS_KEY);
+      const savedMaps = savedMapsString ? JSON.parse(savedMapsString) : [];
+
+      const mapIndex = savedMaps.findIndex((map) => map.id === packId);
+
+      const mapData = {
+        id: packId,
+        name: name,
+        bounds: JSON.stringify(selectorBounds),
+        size: estimatedSize,
+        updatedAt: Date.now(),
+        status: 'downloading',
+        progress: 0,
+        styleURL: `${VECTOR_MAP_HOST}/nomadmania-maps.json`,
+        minZoom: 10,
+        maxZoom: 20,
+        tilesCount: tilesCount
+      };
+
+      if (mapIndex !== -1) {
+        savedMaps[mapIndex] = { ...savedMaps[mapIndex], ...mapData };
+      } else {
+        savedMaps.push(mapData);
+      }
+
+      storage.set(OFFLINE_MAPS_KEY, JSON.stringify(savedMaps));
+
+      offlineMapManager.createPack(
+        offlinePackOptions,
+        (progress) => {
+          const mapsString = storage.getString(OFFLINE_MAPS_KEY);
+          const maps = mapsString ? JSON.parse(mapsString) : [];
+          const mapIndex = maps.findIndex((map) => map.id === packId);
+          console.log('Download progress:', progress);
+
+          if (mapIndex >= 0) {
+            maps[mapIndex].progress = progress.percentage;
+
+            if (progress.percentage === 100) {
+              maps[mapIndex].status = 'valid';
+              maps[mapIndex].size = progress.completedSize || maps[mapIndex].size;
+              maps[mapIndex].updatedAt = Date.now();
+            }
+
+            storage.set(OFFLINE_MAPS_KEY, JSON.stringify(maps));
+          }
+        },
+        (error) => {
+          console.error('Download error:', error);
+
+          const mapsString = storage.getString(OFFLINE_MAPS_KEY);
+          const maps = mapsString ? JSON.parse(mapsString) : [];
+          const mapIndex = maps.findIndex((map) => map.id === packId);
+
+          if (mapIndex >= 0) {
+            maps[mapIndex].status = 'invalid';
+            storage.set(OFFLINE_MAPS_KEY, JSON.stringify(maps));
+          }
+        }
+      );
+
+      navigation.goBack();
+    } catch (error) {
+      console.error('Error starting download:', error);
+      Alert.alert('Error', 'Failed to start download');
+    }
+  };
+
+  return (
+    <KeyboardAvoidingView
+      style={styles.container}
+      behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
+    >
+      <SafeAreaView style={{ height: '100%' }} edges={['top']}>
+        <View style={styles.header}>
+          <Header label="Select Area to Download" />
+        </View>
+
+        <View style={styles.mapContainer}>
+          <MapLibreGL.MapView
+            ref={map}
+            style={styles.map}
+            mapStyle={`${VECTOR_MAP_HOST}/nomadmania-maps.json`}
+            onDidFinishLoadingMap={onMapLoad}
+            onRegionDidChange={(feature) => onRegionDidChange(feature)}
+          >
+            <MapLibreGL.Camera
+              defaultSettings={{
+                centerCoordinate: [0, 0],
+                zoomLevel: 1
+              }}
+            />
+
+            {mapLoaded && (
+              <View style={styles.selectorOverlay}>
+                <View
+                  style={[
+                    styles.selector,
+                    {
+                      width: selectorSize.width,
+                      height: selectorSize.height
+                    }
+                  ]}
+                >
+                  <Text style={styles.selectorText}>Selected Area</Text>
+                </View>
+              </View>
+            )}
+          </MapLibreGL.MapView>
+        </View>
+
+        <View style={styles.infoContainer}>
+          <TextInput
+            style={styles.nameInput}
+            placeholder="Enter map name"
+            value={name}
+            onChangeText={setName}
+          />
+
+          {estimatedSize > 0 && (
+            <View style={styles.infoRow}>
+              <Text style={styles.sizeText}>Estimated size: {formatBytes(estimatedSize)}</Text>
+              <Text style={styles.tilesText}>Tiles: {tilesCount}</Text>
+            </View>
+          )}
+
+          <TouchableOpacity
+            style={[styles.downloadButton, !isPremium && styles.disabledButton]}
+            onPress={downloadMap}
+            disabled={!isPremium}
+          >
+            <Text style={styles.downloadButtonText}>
+              {updateMap ? 'Update Offline Map' : 'Download Offline Map'}
+            </Text>
+          </TouchableOpacity>
+        </View>
+      </SafeAreaView>
+    </KeyboardAvoidingView>
+  );
+}
+
+const styles = StyleSheet.create({
+  container: {
+    flex: 1,
+    backgroundColor: '#fff'
+  },
+  header: {
+    marginHorizontal: '5%'
+  },
+  title: {
+    fontSize: 20,
+    fontWeight: 'bold'
+  },
+  mapContainer: {
+    flex: 1,
+    position: 'relative'
+  },
+  map: {
+    flex: 1
+  },
+  selectorOverlay: {
+    position: 'absolute',
+    top: 0,
+    left: 0,
+    right: 0,
+    bottom: 0,
+    justifyContent: 'center',
+    alignItems: 'center',
+    pointerEvents: 'none'
+  },
+  selector: {
+    borderWidth: 2,
+    borderColor: '#0F67FE',
+    borderRadius: 8,
+    backgroundColor: 'rgba(15, 103, 254, 0.1)',
+    justifyContent: 'center',
+    alignItems: 'center'
+  },
+  selectorText: {
+    color: '#0F67FE',
+    fontWeight: '600',
+    textAlign: 'center',
+    padding: 8,
+    backgroundColor: 'rgba(255,255,255,0.8)',
+    borderRadius: 4,
+    fontSize: 12
+  },
+  infoContainer: {
+    marginHorizontal: '5%',
+    borderTopWidth: 1,
+    borderTopColor: '#eee'
+  },
+  infoRow: {
+    flexDirection: 'row',
+    justifyContent: 'space-between',
+    alignItems: 'center',
+    marginBottom: 16
+  },
+  nameInput: {
+    borderWidth: 1,
+    borderColor: '#ddd',
+    borderRadius: 8,
+    padding: 12,
+    fontSize: 16,
+    marginBottom: 12
+  },
+  sizeText: {
+    fontSize: 14,
+    color: '#555'
+  },
+  tilesText: {
+    fontSize: 14,
+    color: '#555'
+  },
+  downloadButton: {
+    backgroundColor: '#0F67FE',
+    paddingVertical: 12,
+    borderRadius: 8,
+    alignItems: 'center'
+  },
+  disabledButton: {
+    backgroundColor: '#aaa'
+  },
+  downloadButtonText: {
+    color: '#fff',
+    fontSize: 16,
+    fontWeight: '600'
+  }
+});

+ 24 - 0
src/screens/OfflineMapsScreen/SelectOwnMapScreen/styles.tsx

@@ -0,0 +1,24 @@
+import { StyleSheet } from 'react-native';
+
+export const styles = StyleSheet.create({
+  container: {
+    flex: 1
+  },
+  map: {
+    flex: 1
+  },
+  controls: {
+    position: 'absolute',
+    bottom: 20,
+    left: 16,
+    right: 16,
+    backgroundColor: '#fff',
+    borderRadius: 8,
+    padding: 12,
+    elevation: 4
+  },
+  info: {
+    marginBottom: 8,
+    textAlign: 'center'
+  }
+});

+ 563 - 0
src/screens/OfflineMapsScreen/SelectRegionsScreen/index.tsx

@@ -0,0 +1,563 @@
+import React, { useCallback, useEffect, useRef, useState } from 'react';
+import { View, StyleSheet, TouchableOpacity, Text, ScrollView, Alert } from 'react-native';
+import { SafeAreaView } from 'react-native-safe-area-context';
+import { Modal, FlatList as List, Header } from 'src/components';
+import * as turf from '@turf/turf';
+import * as MapLibreRN from '@maplibre/maplibre-react-native';
+import NetInfo from '@react-native-community/netinfo';
+
+import { getFontSize } from 'src/utils';
+import { useGetRegionsForTripsQuery } from '@api/trips';
+import { useGetListRegionsQuery } from '@api/regions';
+import { Colors } from 'src/theme';
+
+import SearchSvg from 'assets/icons/search.svg';
+import SaveSvg from 'assets/icons/travels-screens/save.svg';
+import { VECTOR_MAP_HOST } from 'src/constants';
+import { storage, StoreType } from 'src/storage';
+import { usePostGetMapDataForRegionMutation } from '@api/maps';
+import { formatBytes } from '../formatters';
+import { ActivityIndicator } from 'react-native-paper';
+import { offlineMapManager } from '../OfflineMapManager';
+
+const generateFilter = (ids: number[]) => {
+  return ids?.length ? ['any', ...ids.map((id) => ['==', 'id', id])] : ['==', 'id', -1];
+};
+
+let nm_regions = {
+  id: 'regions',
+  type: 'fill',
+  source: 'regions',
+  'source-layer': 'regions',
+  style: {
+    fillColor: 'rgba(15, 63, 79, 0)'
+  },
+  filter: ['all'],
+  maxzoom: 16
+};
+
+let selected_region = {
+  id: 'selected_region',
+  type: 'fill',
+  source: 'regions',
+  'source-layer': 'regions',
+  style: {
+    fillColor: 'rgba(237, 147, 52, 0.7)'
+  },
+  maxzoom: 12
+};
+
+const OFFLINE_MAPS_KEY = 'offline_maps';
+
+export const SelectRegionScreen = ({ navigation }: { navigation: any }) => {
+  const token = storage.get('token', StoreType.STRING) as string;
+  const { data } = useGetRegionsForTripsQuery(true);
+  const { data: regionsList } = useGetListRegionsQuery(true);
+
+  const [regions, setRegions] = useState<any[] | null>(null);
+  const [isModalVisible, setIsModalVisible] = useState(false);
+  const [selectedRegions, setSelectedRegions] = useState<any[]>([]);
+  const [regionsToSave, setRegionsToSave] = useState<any[]>([]);
+  const [regionData, setRegionData] = useState<any | null>(null);
+  const [isConnectedToWifi, setIsConnectedToWifi] = useState(true);
+
+  const { mutateAsync: getMapDataForRegion } = usePostGetMapDataForRegionMutation();
+  const [loadingSize, setLoadingSize] = useState(false);
+  const [estimatedSize, setEstimatedSize] = useState(0);
+
+  const [regionPopupVisible, setRegionPopupVisible] = useState(false);
+  const mapRef = useRef<MapLibreRN.MapViewRef>(null);
+  const cameraRef = useRef<MapLibreRN.CameraRef>(null);
+
+  const [filterSelectedRegions, setFilterSelectedRegions] = useState<any[]>(generateFilter([]));
+
+  useEffect(() => {
+    if (data && data.regions) {
+      setRegions(data.regions);
+    }
+  }, [data]);
+
+  useEffect(() => {
+    const ids = selectedRegions.map((region) => region.id);
+    setFilterSelectedRegions(generateFilter(ids));
+  }, [selectedRegions]);
+
+  useEffect(() => {
+    const checkConnectionType = async () => {
+      try {
+        const connectionInfo = await NetInfo.fetch();
+        setIsConnectedToWifi(connectionInfo.type === 'wifi');
+      } catch (error) {
+        console.error('Error checking network connection:', error);
+        setIsConnectedToWifi(true);
+      }
+    };
+
+    checkConnectionType();
+  }, []);
+
+  const addRegionFromSearch = async (searchRegion: any) => {
+    setLoadingSize(true);
+    const regionIndex = selectedRegions.findIndex((region) => region.id === searchRegion.id);
+    const regionFromApi = regions?.find((region) => region.id === searchRegion.id);
+
+    if (regionIndex < 0 && regionFromApi) {
+      const newRegion = {
+        id: searchRegion.id,
+        name: searchRegion.name
+      };
+      setSelectedRegions([...selectedRegions, newRegion] as any);
+
+      await getMapDataForRegion(
+        { token, region_id: searchRegion.id },
+        {
+          onSuccess: (res) => {
+            console.log('Map data for region:', res);
+            if (res.size) {
+              setRegionsToSave((prevRegions) => [...prevRegions, { ...res, id: searchRegion.id }]);
+              setEstimatedSize((prevSize) => prevSize + res.size);
+            }
+
+            setLoadingSize(false);
+          },
+          onError: (error) => {
+            console.error('Error fetching map data for region:', error);
+            setLoadingSize(false);
+          }
+        }
+      );
+
+      setRegionPopupVisible(true);
+
+      if (regionsList) {
+        const region = regionsList.data.find((region) => region.id === searchRegion.id);
+        if (region) {
+          const bounds = turf.bbox(region.bbox);
+          cameraRef.current?.fitBounds(
+            [bounds[2], bounds[3]],
+            [bounds[0], bounds[1]],
+            [50, 50, 50, 50],
+            600
+          );
+        }
+      }
+    } else {
+      setLoadingSize(false);
+    }
+  };
+
+  const handleSavePress = () => {
+    console.log('Selected regions to save:', regionsToSave);
+    if (!isConnectedToWifi) {
+      Alert.alert(
+        'Mobile Data Connection',
+        'You are not connected to WiFi. Downloading map tiles may use a significant amount of data. Do you want to continue?',
+        [
+          { text: 'Cancel', style: 'cancel' },
+          { text: 'Download', onPress: () => startDownload() }
+        ]
+      );
+    } else {
+      startDownload();
+    }
+  };
+
+  const startDownload = async () => {
+    try {
+      const results = {
+        success: [],
+        failed: []
+      };
+
+      for (const region of regionsToSave) {
+        try {
+          const [west, south, east, north] = region.bbox;
+
+          const packId = `region_${region.id}_${Date.now()}`;
+
+          const offlinePackOptions = {
+            name: packId,
+            styleURL: `${VECTOR_MAP_HOST}/nomadmania-maps.json`,
+            bounds: [
+              [west, south],
+              [east, north]
+            ],
+            minZoom: 10,
+            maxZoom: 14
+          };
+
+          const bounds = {
+            north,
+            south,
+            east,
+            west
+          };
+
+          const savedMapsString = storage.get(OFFLINE_MAPS_KEY, StoreType.STRING) as string;
+          const savedMaps = savedMapsString ? JSON.parse(savedMapsString) : [];
+
+          const mapData = {
+            id: packId,
+            regionId: region.id,
+            name: region.name || `Region ${region.id}`,
+            bounds: JSON.stringify(bounds),
+            size: region.size || 0,
+            updatedAt: Date.now(),
+            status: 'downloading',
+            progress: 0,
+            styleURL: `${VECTOR_MAP_HOST}/nomadmania-maps.json`,
+            minZoom: 10,
+            maxZoom: 14
+          };
+
+          savedMaps.unshift(mapData);
+
+          storage.set(OFFLINE_MAPS_KEY, JSON.stringify(savedMaps));
+
+          offlineMapManager.createPack(
+            offlinePackOptions,
+            (progress: any) => {
+              const mapsString = storage.get(OFFLINE_MAPS_KEY, StoreType.STRING) as string;
+              const maps = mapsString ? JSON.parse(mapsString) : [];
+              const mapIndex = maps.findIndex((map: any) => map.id === packId);
+
+              if (mapIndex >= 0) {
+                maps[mapIndex].progress = progress.percentage;
+
+                if (progress.percentage === 100) {
+                  maps[mapIndex].status = 'valid';
+                  maps[mapIndex].size = progress.completedSize || maps[mapIndex].size;
+                  maps[mapIndex].updatedAt = Date.now();
+                }
+
+                storage.set(OFFLINE_MAPS_KEY, JSON.stringify(maps));
+              }
+            },
+            (error: any) => {
+              console.error(`Download error for region ${region.id}:`, error);
+
+              const mapsString = storage.get(OFFLINE_MAPS_KEY, StoreType.STRING) as string;
+              const maps = mapsString ? JSON.parse(mapsString) : [];
+              const mapIndex = maps.findIndex((map: any) => map.id === packId);
+
+              if (mapIndex >= 0) {
+                maps[mapIndex].status = 'invalid';
+                maps[mapIndex].error = error.message || 'Unknown error';
+                storage.set(OFFLINE_MAPS_KEY, JSON.stringify(maps));
+              }
+            }
+          );
+        } catch (error) {
+          console.error(`Error processing region ${region.id}:`, error);
+        }
+      }
+
+      navigation.goBack();
+    } catch (error) {
+      console.error('Error starting download:', error);
+    }
+  };
+
+  const handleSetRegionData = (regionId: number) => {
+    const foundRegion = regions?.find((region) => region.id === regionId);
+
+    if (foundRegion) {
+      setRegionData(foundRegion);
+      // setRegionsToSave((prevRegions) => [...prevRegions, foundRegion]);
+    }
+  };
+
+  const handleMapPress = useCallback(
+    async (event: any) => {
+      if (!mapRef.current) return;
+      setLoadingSize(true);
+
+      try {
+        const { screenPointX, screenPointY } = event.properties;
+
+        const { features } = await mapRef.current.queryRenderedFeaturesAtPoint(
+          [screenPointX, screenPointY],
+          undefined,
+          ['regions']
+        );
+
+        if (features?.length) {
+          const selectedRegion = features[0];
+
+          if (selectedRegion.properties) {
+            const id = selectedRegion.properties.id;
+
+            const regionIndex = selectedRegions.findIndex((region) => region.id === id);
+
+            if (regionIndex >= 0) {
+              const regionToRemove = regionsToSave.find((region) => region.id == id);
+
+              const newSelectedRegions = [...selectedRegions];
+              newSelectedRegions.splice(regionIndex, 1);
+              setSelectedRegions(newSelectedRegions);
+              console.log('regionsToSave:', regionsToSave);
+
+              setEstimatedSize((prevSize) => {
+                console.log('Region to remove:', regionToRemove, id);
+                console.log('Region to remove2:', regionsToSave);
+
+                return regionToRemove ? prevSize - regionToRemove.size : prevSize;
+              });
+              setRegionsToSave(regionsToSave.filter((region) => region.id !== id));
+              setRegionPopupVisible(false);
+              setLoadingSize(false);
+              return;
+            } else {
+              setSelectedRegions([...selectedRegions, selectedRegion.properties] as any);
+
+              await getMapDataForRegion(
+                { token, region_id: id },
+                {
+                  onSuccess: (res) => {
+                    console.log('Map data for region:', res);
+                    if (res.size) {
+                      setRegionsToSave((prevRegions) => [...prevRegions, { ...res, id }]);
+                      setEstimatedSize((prevSize) => prevSize + res.size);
+
+                      setLoadingSize(false);
+                    }
+                  },
+                  onError: (error) => {
+                    console.error('Error fetching map data for region:', error);
+                    setLoadingSize(false);
+                  }
+                }
+              );
+            }
+
+            handleSetRegionData(id);
+            setRegionPopupVisible(true);
+
+            if (regionsList) {
+              const region = regionsList.data.find((region) => region.id === id);
+              if (region) {
+                const bounds = turf.bbox(region.bbox);
+                cameraRef.current?.fitBounds(
+                  [bounds[2], bounds[3]],
+                  [bounds[0], bounds[1]],
+                  [50, 50, 50, 50],
+                  600
+                );
+              }
+            }
+          }
+        }
+      } catch (error) {
+        console.error('Failed to get coordinates on AddRegionsScreen', error);
+        setLoadingSize(false);
+      }
+    },
+    [selectedRegions, regions, regionsToSave]
+  );
+
+  return (
+    <SafeAreaView style={{ height: '100%' }} edges={['top']}>
+      <View style={styles.wrapper}>
+        <Header label={'Add Regions'} />
+        <View style={styles.searchContainer}>
+          <TouchableOpacity style={[styles.regionSelector]} onPress={() => setIsModalVisible(true)}>
+            <SearchSvg fill={Colors.LIGHT_GRAY} />
+            <Text style={styles.regionText}>Search</Text>
+          </TouchableOpacity>
+          <TouchableOpacity
+            style={[
+              styles.saveBtn,
+              selectedRegions.length ? styles.saveBtnActive : styles.saveBtnDisabled
+            ]}
+            onPress={handleSavePress}
+            disabled={!selectedRegions.length}
+          >
+            <SaveSvg fill={selectedRegions.length ? Colors.WHITE : Colors.LIGHT_GRAY} />
+          </TouchableOpacity>
+        </View>
+      </View>
+
+      <View style={styles.container}>
+        <MapLibreRN.MapView
+          ref={mapRef}
+          style={styles.map}
+          mapStyle={VECTOR_MAP_HOST + '/nomadmania-maps.json'}
+          rotateEnabled={false}
+          attributionEnabled={false}
+          onPress={handleMapPress}
+        >
+          <MapLibreRN.Camera ref={cameraRef} />
+
+          <MapLibreRN.LineLayer
+            id="nm-regions-line-layer"
+            sourceID={nm_regions.source}
+            sourceLayerID={nm_regions['source-layer']}
+            filter={nm_regions.filter as any}
+            maxZoomLevel={nm_regions.maxzoom}
+            style={{
+              lineColor: 'rgba(14, 80, 109, 1)',
+              lineWidth: ['interpolate', ['linear'], ['zoom'], 0, 0.2, 4, 1, 5, 1.5, 12, 3],
+              lineWidthTransition: { duration: 300, delay: 0 }
+            }}
+            belowLayerID="waterway-name"
+          />
+          <MapLibreRN.FillLayer
+            id={nm_regions.id}
+            sourceID={nm_regions.source}
+            sourceLayerID={nm_regions['source-layer']}
+            filter={nm_regions.filter as any}
+            style={nm_regions.style}
+            maxZoomLevel={nm_regions.maxzoom}
+            belowLayerID="nm-regions-line-layer"
+          />
+
+          {selectedRegions && selectedRegions.length > 0 ? (
+            <MapLibreRN.FillLayer
+              id={selected_region.id}
+              sourceID={nm_regions.source}
+              sourceLayerID={nm_regions['source-layer']}
+              filter={filterSelectedRegions as any}
+              style={selected_region.style}
+              maxZoomLevel={selected_region.maxzoom}
+              belowLayerID="nm-regions-line-layer"
+            />
+          ) : null}
+        </MapLibreRN.MapView>
+      </View>
+
+      <View style={styles.infoContainer}>
+        {regionPopupVisible && regionData && (
+          <View style={styles.popupWrapper}>
+            <View style={styles.popupContainer}>
+              <Text style={styles.popupText}>{regionData.name ?? regionData.region_name}</Text>
+            </View>
+          </View>
+        )}
+        <View style={styles.infoRow}>
+          <Text style={styles.sizeText}>Estimated size: </Text>
+          {loadingSize ? (
+            <ActivityIndicator size={15} color={Colors.DARK_BLUE} />
+          ) : (
+            <Text style={[styles.sizeText, { fontWeight: '600' }]}>
+              {formatBytes(estimatedSize)}
+            </Text>
+          )}
+        </View>
+
+        <Text style={[styles.sizeText, { marginBottom: 8 }]}>Regions:</Text>
+        <ScrollView>
+          {regionsToSave.map((region, index) => (
+            <View style={{ marginBottom: 8 }} key={`${region.id}`}>
+              <Text style={styles.tilesText}>{region.name}</Text>
+            </View>
+          ))}
+        </ScrollView>
+      </View>
+
+      <Modal
+        onRequestClose={() => setIsModalVisible(false)}
+        headerTitle={'Select Regions'}
+        visible={isModalVisible}
+      >
+        <List
+          itemObject={(object) => {
+            setIsModalVisible(false);
+            setRegionData(object);
+            addRegionFromSearch(object);
+          }}
+        />
+      </Modal>
+    </SafeAreaView>
+  );
+};
+
+const styles = StyleSheet.create({
+  container: {
+    flex: 1
+  },
+  wrapper: {
+    marginLeft: '5%',
+    marginRight: '5%',
+    alignItems: 'center'
+  },
+  map: {
+    ...StyleSheet.absoluteFillObject
+  },
+  searchContainer: {
+    gap: 16,
+    flexDirection: 'row',
+    alignItems: 'center',
+    marginBottom: 8
+  },
+  regionSelector: {
+    flexDirection: 'row',
+    alignItems: 'center',
+    paddingHorizontal: 16,
+    borderRadius: 4,
+    height: 36,
+    backgroundColor: Colors.FILL_LIGHT,
+    justifyContent: 'flex-start',
+    gap: 10,
+    flex: 1
+  },
+  regionText: {
+    fontSize: 15,
+    fontWeight: '500',
+    color: Colors.LIGHT_GRAY
+  },
+  saveBtn: {
+    borderRadius: 20,
+    paddingVertical: 10,
+    paddingHorizontal: 16,
+    borderWidth: 1
+  },
+  saveBtnActive: {
+    borderColor: Colors.ORANGE,
+    backgroundColor: Colors.ORANGE
+  },
+  saveBtnDisabled: {
+    borderColor: Colors.LIGHT_GRAY
+  },
+  popupWrapper: {
+    marginHorizontal: 24,
+    position: 'absolute',
+    top: -48,
+    left: 0,
+    right: 0
+  },
+  popupContainer: {
+    alignItems: 'center',
+    justifyContent: 'center',
+    alignSelf: 'center',
+    paddingVertical: 8,
+    paddingHorizontal: 16,
+    backgroundColor: 'rgba(33, 37, 41, 0.8)',
+    borderRadius: 100
+  },
+  popupText: {
+    color: Colors.WHITE,
+    fontSize: getFontSize(12),
+    fontWeight: '600'
+  },
+  infoContainer: {
+    marginHorizontal: '5%',
+    maxHeight: 150,
+    position: 'relative'
+  },
+  infoRow: {
+    flexDirection: 'row',
+    gap: 4,
+    marginBottom: 8,
+    marginTop: 8
+  },
+  sizeText: {
+    fontSize: 13,
+    color: Colors.DARK_BLUE,
+    fontFamily: 'montserrat-700'
+  },
+  tilesText: {
+    fontSize: getFontSize(12),
+    color: Colors.DARK_BLUE,
+    fontWeight: '500'
+  }
+});

+ 3 - 0
src/screens/OfflineMapsScreen/SelectRegionsScreen/styles.tsx

@@ -0,0 +1,3 @@
+import { StyleSheet } from 'react-native';
+
+export const styles = StyleSheet.create({});

+ 11 - 0
src/screens/OfflineMapsScreen/formatters.ts

@@ -0,0 +1,11 @@
+export const formatBytes = (bytes: number, decimals = 2) => {
+  if (bytes === 0) return '0 Bytes';
+
+  const k = 1024;
+  const dm = decimals < 0 ? 0 : decimals;
+  const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB'];
+
+  const i = Math.floor(Math.log(bytes) / Math.log(k));
+
+  return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i];
+};

+ 521 - 0
src/screens/OfflineMapsScreen/index.tsx

@@ -0,0 +1,521 @@
+import React, { useState, useEffect, useCallback } from 'react';
+import {
+  View,
+  Text,
+  StyleSheet,
+  FlatList,
+  TouchableOpacity,
+  Alert,
+  ScrollView,
+  Dimensions
+} from 'react-native';
+import { Ionicons } from '@expo/vector-icons';
+import { useFocusEffect } from '@react-navigation/native';
+import { offlineMapManager } from './OfflineMapManager';
+import { NAVIGATION_PAGES } from 'src/types';
+import { formatBytes } from './formatters';
+import { useSubscription } from './useSubscription';
+import { Header, Input, Loading, PageWrapper } from 'src/components';
+import { Colors } from 'src/theme';
+import { getFontSize } from 'src/utils';
+import * as Progress from 'react-native-progress';
+import { storage, StoreType } from 'src/storage';
+
+const OFFLINE_MAPS_KEY = 'offline_maps';
+
+export default function OfflineMapsScreen({ navigation }: { navigation: any }) {
+  const contentWidth = Dimensions.get('window').width * 0.9;
+  const [maps, setMaps] = useState([]);
+  const [editingId, setEditingId] = useState(null);
+  const [editingName, setEditingName] = useState('');
+  const [loading, setLoading] = useState(true);
+  const { isPremium } = useSubscription();
+
+  useEffect(() => {
+    offlineMapManager.init();
+  }, []);
+
+  useEffect(() => {
+    const intervalId = setInterval(() => {
+      const mapsString = storage.get(OFFLINE_MAPS_KEY, StoreType.STRING) as string;
+      if (mapsString) {
+        const parsedMaps = JSON.parse(mapsString);
+        if (
+          parsedMaps.some((map: any) => map.status === 'downloading') ||
+          JSON.stringify(maps) !== mapsString
+        ) {
+          setMaps(parsedMaps);
+        }
+      }
+    }, 1000);
+
+    return () => clearInterval(intervalId);
+  }, [maps]);
+
+  const loadMaps = useCallback(async () => {
+    try {
+      setLoading(true);
+      const savedMapsString = storage.get(OFFLINE_MAPS_KEY, StoreType.STRING) as string;
+      const savedMaps = savedMapsString ? JSON.parse(savedMapsString) : [];
+
+      const availablePacks = await offlineMapManager.getPacks();
+      const availablePackIds = availablePacks.map((pack) => pack.name);
+
+      const updatedMaps = savedMaps.map((map: any) => {
+        if (map.status === 'downloading' && map.progress < 100) {
+          return map;
+        }
+
+        if (map.status === 'downloading' && map.progress >= 100) {
+          return {
+            ...map,
+            status: 'valid'
+          };
+        }
+
+        return {
+          ...map,
+          status: isPremium && availablePackIds.includes(map.id) ? 'valid' : 'invalid'
+        };
+      });
+
+      setMaps(updatedMaps);
+
+      storage.set(OFFLINE_MAPS_KEY, JSON.stringify(updatedMaps));
+    } catch (error) {
+      console.error('Error loading offline maps:', error);
+    } finally {
+      setLoading(false);
+    }
+  }, [isPremium]);
+
+  useFocusEffect(
+    useCallback(() => {
+      loadMaps();
+    }, [loadMaps])
+  );
+
+  useEffect(() => {
+    if (isPremium) {
+      restoreOfflineMaps();
+    } else {
+      invalidateMaps();
+    }
+  }, [isPremium]);
+
+  const restoreOfflineMaps = async () => {
+    try {
+      const savedMapsString = storage.get(OFFLINE_MAPS_KEY, StoreType.STRING) as string;
+      if (!savedMapsString) return;
+
+      const savedMaps = JSON.parse(savedMapsString);
+
+      setLoading(true);
+
+      for (const map of savedMaps.filter((m: any) => m.status === 'invalid')) {
+        try {
+          const pack = await offlineMapManager.getPack(map.id);
+          if (!pack) {
+            console.log(`Pack ${map.id} not found, will be downloaded`);
+
+            if (map.bounds) {
+              await offlineMapManager.resumePackDownload(map.id);
+            }
+          }
+        } catch (error) {
+          console.log(`Error checking pack ${map.id}:`, error);
+        }
+      }
+
+      loadMaps();
+    } catch (error) {
+      console.error('Error restoring offline maps:', error);
+    }
+  };
+
+  const invalidateMaps = async () => {
+    try {
+      const packs = await offlineMapManager.getPacks();
+      for (const pack of packs) {
+        await offlineMapManager.invalidatePack(pack.name);
+      }
+
+      loadMaps();
+    } catch (error) {
+      console.error('Error invalidating maps:', error);
+    }
+  };
+
+  const handleDelete = (id: string, name: string) => {
+    Alert.alert('Delete Offline Map', `Are you sure you want to delete "${name}"?`, [
+      { text: 'Cancel', style: 'cancel' },
+      {
+        text: 'Delete',
+        style: 'destructive',
+        onPress: async () => {
+          try {
+            const mapsString = storage.get(OFFLINE_MAPS_KEY, StoreType.STRING) as string;
+            const maps = mapsString ? JSON.parse(mapsString) : [];
+            const map = maps.find((m: any) => m.id === id);
+
+            if (map && map.status === 'downloading') {
+              await offlineMapManager.cancelPackDownload(id);
+            }
+
+            await offlineMapManager.deletePack(id);
+
+            const updatedMaps = maps.filter((map) => map.id !== id);
+            storage.set(OFFLINE_MAPS_KEY, JSON.stringify(updatedMaps));
+
+            setMaps(updatedMaps);
+          } catch (error) {
+            console.error('Error deleting offline map:', error);
+            Alert.alert('Error', 'Failed to delete offline map');
+          }
+        }
+      }
+    ]);
+  };
+
+  // TO DO
+  // const handleUpdate = async (map) => {
+  //   try {
+  //     await offlineMapManager.updatePack(
+  //       map.id,
+  //       (progress) => {
+  //         console.log(`Update progress for ${map.id}:`, progress.percentage);
+  //       },
+  //       (error) => {
+  //         console.error(`Error updating map ${map.id}:`, error);
+  //       }
+  //     );
+
+  //     loadMaps();
+  //   } catch (error) {
+  //     console.error('Error updating offline map:', error);
+  //   }
+  // };
+
+  const handleEditName = (id, currentName) => {
+    setEditingId(id);
+    setEditingName(currentName);
+  };
+
+  const handleSaveName = () => {
+    if (!editingName.trim()) {
+      Alert.alert('Error', 'Map name cannot be empty');
+      return;
+    }
+
+    const updatedMaps = maps.map((map) =>
+      map.id === editingId ? { ...map, name: editingName } : map
+    );
+
+    storage.set(OFFLINE_MAPS_KEY, JSON.stringify(updatedMaps));
+    setMaps(updatedMaps);
+    setEditingId(null);
+    setEditingName('');
+  };
+
+  const handleCancelEdit = () => {
+    setEditingId(null);
+    setEditingName('');
+  };
+
+  const renderItem = ({ item }: { item: any }) => (
+    <View style={styles.mapItem}>
+      {editingId === item.id ? (
+        <View style={styles.editNameContainer}>
+          <View style={{ flex: 1 }}>
+            <Input
+              value={editingName}
+              onChange={setEditingName}
+              inputMode="text"
+              formikError={!editingName.length}
+              autoFocus
+            />
+          </View>
+          <TouchableOpacity onPress={handleSaveName} style={styles.iconButton}>
+            <Ionicons name="checkmark" size={22} color={Colors.DARK_BLUE} />
+          </TouchableOpacity>
+          <TouchableOpacity onPress={handleCancelEdit} style={styles.iconButton}>
+            <Ionicons name="close" size={22} color={Colors.RED} />
+          </TouchableOpacity>
+        </View>
+      ) : (
+        <View style={styles.mapInfoContainer}>
+          <Text style={styles.mapName}>{item.name}</Text>
+          <TouchableOpacity
+            onPress={() => handleEditName(item.id, item.name)}
+            style={styles.iconButton}
+          >
+            <Ionicons name="pencil" size={16} color={Colors.DARK_BLUE} />
+          </TouchableOpacity>
+        </View>
+      )}
+
+      <View style={{ flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' }}>
+        <View>
+          <Text style={styles.mapDetails}>Size: {formatBytes(item.size)}</Text>
+          <Text style={styles.mapDetails}>
+            Updated: {new Date(item.updatedAt).toLocaleDateString()}
+          </Text>
+        </View>
+
+        <TouchableOpacity
+          onPress={() => handleDelete(item.id, item.name)}
+          style={[styles.actionButton, styles.deleteButton]}
+        >
+          <Text style={styles.actionButtonText}>Delete</Text>
+        </TouchableOpacity>
+      </View>
+
+      {item.status === 'downloading' && item.progress < 100 && (
+        <View style={styles.downloadProgressContainer}>
+          <Progress.Bar
+            progress={item.progress / 100}
+            width={contentWidth - 32 - 32}
+            color={Colors.DARK_BLUE}
+            borderWidth={0}
+            borderRadius={5}
+            unfilledColor="rgba(0, 0, 0, 0.1)"
+            style={{}}
+          />
+          <Text style={styles.progressText}>{Math.round(item.progress || 0)}%</Text>
+        </View>
+      )}
+
+      {item.status !== 'valid' && item.progress >= 100 && (
+        <View style={styles.statusContainer}>
+          <Text
+            style={[
+              styles.mapStatus,
+              {
+                color: Colors.RED
+              }
+            ]}
+          >
+            {isPremium ? 'Update Required' : 'Premium Required'}
+          </Text>
+
+          {isPremium && (
+            <View style={styles.actionsContainer}>
+              <TouchableOpacity
+                // TO DO
+                // onPress={() => handleUpdate(item)}
+                style={[styles.actionButton, styles.updateButton]}
+                disabled={!isPremium}
+              >
+                <Text style={styles.actionButtonText}>Update</Text>
+              </TouchableOpacity>
+            </View>
+          )}
+        </View>
+      )}
+    </View>
+  );
+
+  return (
+    <PageWrapper>
+      <Header label="Offline Maps" />
+      <ScrollView contentContainerStyle={{ gap: 12 }} showsVerticalScrollIndicator={false}>
+        {/* <TouchableOpacity
+          style={styles.button}
+          onPress={() => {
+            if (isPremium) {
+              navigation.navigate(NAVIGATION_PAGES.OFFLINE_SELECT_MAP);
+            } else {
+              Alert.alert(
+                'Premium Feature',
+                'Offline maps are available only with premium subscription.',
+                [{ text: 'OK' }]
+              );
+            }
+          }}
+        >
+          <Text style={styles.buttonText}>Download New Map</Text>
+        </TouchableOpacity> */}
+
+        <TouchableOpacity
+          style={styles.button}
+          onPress={() => {
+            if (isPremium) {
+              navigation.navigate(NAVIGATION_PAGES.OFFLINE_SELECT_REGIONS);
+            } else {
+              Alert.alert(
+                'Premium Feature',
+                'Offline maps are available only with premium subscription.',
+                [{ text: 'OK' }]
+              );
+            }
+          }}
+        >
+          <Text style={styles.buttonText}>Select regions</Text>
+        </TouchableOpacity>
+
+        {loading ? (
+          <View style={styles.loadingContainer}>
+            <Loading />
+          </View>
+        ) : maps.length === 0 ? (
+          <View style={styles.emptyContainer}>
+            <Text style={styles.emptyText}>No offline maps yet</Text>
+            <Text style={styles.emptySubText}>Download maps to use them when you're offline</Text>
+          </View>
+        ) : (
+          <FlatList
+            data={maps}
+            renderItem={renderItem}
+            keyExtractor={(item) => item.id}
+            contentContainerStyle={styles.listContainer}
+            scrollEnabled={false}
+          />
+        )}
+      </ScrollView>
+    </PageWrapper>
+  );
+}
+
+const styles = StyleSheet.create({
+  container: {
+    flex: 1,
+    backgroundColor: '#fff',
+    paddingTop: 50
+  },
+  header: {
+    flexDirection: 'row',
+    justifyContent: 'space-between',
+    alignItems: 'center',
+    padding: 16,
+    borderBottomWidth: 1,
+    borderBottomColor: '#eee'
+  },
+  title: {
+    fontSize: 20,
+    fontWeight: 'bold'
+  },
+  addButton: {
+    backgroundColor: '#0F67FE',
+    paddingHorizontal: 12,
+    paddingVertical: 8,
+    borderRadius: 4
+  },
+  addButtonText: {
+    color: '#fff',
+    fontWeight: '500'
+  },
+  listContainer: {
+    paddingBottom: 16,
+    paddingTop: 10
+  },
+  mapItem: {
+    backgroundColor: Colors.FILL_LIGHT,
+    borderRadius: 8,
+    paddingHorizontal: 16,
+    paddingVertical: 12,
+    marginBottom: 12,
+    gap: 4
+  },
+  mapInfoContainer: {
+    flexDirection: 'row',
+    alignItems: 'center'
+  },
+  mapName: {
+    fontSize: getFontSize(14),
+    fontWeight: '600',
+    color: Colors.DARK_BLUE,
+    flex: 1
+  },
+  mapDetails: {
+    fontSize: getFontSize(12),
+    color: Colors.TEXT_GRAY
+  },
+  statusContainer: {
+    marginTop: 12,
+    flexDirection: 'row',
+    justifyContent: 'space-between',
+    alignItems: 'center'
+  },
+  mapStatus: {
+    fontSize: 14,
+    fontWeight: '500'
+  },
+  actionsContainer: {
+    flexDirection: 'row'
+  },
+  actionButton: {
+    paddingHorizontal: 12,
+    paddingVertical: 6,
+    borderRadius: 4,
+    marginLeft: 8
+  },
+  updateButton: {
+    backgroundColor: Colors.ORANGE
+  },
+  deleteButton: {
+    backgroundColor: Colors.RED
+  },
+  actionButtonText: {
+    color: Colors.WHITE,
+    fontSize: 12,
+    fontWeight: '600'
+  },
+  emptyContainer: {
+    flex: 1,
+    justifyContent: 'center',
+    alignItems: 'center',
+    padding: 32
+  },
+  emptyText: {
+    fontSize: 14,
+    fontWeight: '600',
+    marginTop: 16
+  },
+  emptySubText: {
+    fontSize: 12,
+    color: '#777',
+    textAlign: 'center',
+    marginTop: 8
+  },
+  loadingContainer: {
+    flex: 1,
+    justifyContent: 'center',
+    alignItems: 'center'
+  },
+  editNameContainer: {
+    flexDirection: 'row',
+    alignItems: 'center'
+  },
+  iconButton: {
+    padding: 8
+  },
+  downloadProgressContainer: {
+    marginTop: 8,
+    flexDirection: 'row',
+    alignItems: 'center',
+    justifyContent: 'space-between',
+    gap: 4
+  },
+  progressText: {
+    fontSize: 12,
+    color: Colors.TEXT_GRAY,
+    textAlign: 'right'
+  },
+  button: {
+    display: 'flex',
+    flexDirection: 'row',
+    justifyContent: 'center',
+    alignItems: 'center',
+    borderRadius: 4,
+    gap: 10,
+    padding: 10,
+    borderColor: Colors.DARK_BLUE,
+    borderWidth: 1,
+    borderStyle: 'solid'
+  },
+  buttonText: {
+    color: Colors.DARK_BLUE,
+    fontSize: getFontSize(14),
+    fontFamily: 'redhat-700'
+  }
+});

+ 3 - 0
src/screens/OfflineMapsScreen/styles.tsx

@@ -0,0 +1,3 @@
+import { StyleSheet } from 'react-native';
+
+export const styles = StyleSheet.create({});

+ 62 - 0
src/screens/OfflineMapsScreen/useSubscription.ts

@@ -0,0 +1,62 @@
+import { fetchPremiumStatus } from '@api/app';
+import { useState, useEffect } from 'react';
+import { storage, StoreType } from 'src/storage';
+
+const SUBSCRIPTION_KEY = 'user_subscription';
+
+export const useSubscription = () => {
+  const token = storage.get('token', StoreType.STRING) as string;
+  const [isPremium, setIsPremium] = useState(true);
+  const [loading, setLoading] = useState(true);
+
+  useEffect(() => {
+    checkSubscription();
+  }, []);
+
+  const checkSubscription = async () => {
+    try {
+      setLoading(true);
+      const status = await fetchPremiumStatus(token);
+      if (status && 'premium-status' in status) {
+        storage.set(SUBSCRIPTION_KEY, status['premium-status']);
+      }
+
+      const subscriptionData = (storage.get(SUBSCRIPTION_KEY, StoreType.NUMBER) as number) ?? null;
+
+      if (subscriptionData) {
+        // const isValid = subscription.active && new Date(subscription.expiryDate) > new Date();
+        const isValid = subscriptionData == 1;
+
+        setIsPremium(isValid);
+      } else {
+        setIsPremium(false);
+      }
+    } catch (error) {
+      console.error('Error checking subscription:', error);
+      setIsPremium(false);
+    } finally {
+      setLoading(false);
+    }
+  };
+
+  const setPremium = (active: boolean, expiryDate = null) => {
+    // try {
+    //   const expiry = expiryDate || new Date(Date.now() + 30 * 24 * 60 * 60 * 1000);
+    //   const subscription = {
+    //     active,
+    //     expiryDate: expiry.toISOString()
+    //   };
+    //   storage.set(SUBSCRIPTION_KEY, JSON.stringify(subscription));
+    //   setIsPremium(active);
+    // } catch (error) {
+    //   console.error('Error setting subscription:', error);
+    // }
+  };
+
+  return {
+    isPremium,
+    loading,
+    checkSubscription,
+    setPremium
+  };
+};

+ 10 - 2
src/types/api.ts

@@ -204,7 +204,11 @@ export enum API_ENDPOINT {
   GET_EVENT_FOR_EDITING = 'get-event-for-editing',
   UPDATE_EVENT = 'update-event',
   CANCEL_EVENT = 'cancel-event',
-  REMOVE_PARTICIPANT = 'remove-participant'
+  REMOVE_PARTICIPANT = 'remove-participant',
+  PREMIUM_STATUS = 'premium-status',
+  GET_MAP_DATA_FOR_REGION = 'get-map-data-for-region',
+  GET_LAST_MAP_UPDATE_DATE = 'get-last-map-update-date',
+  GET_SIZE_FOR_BOUNDING_BOX = 'get-size-for-bounding-box'
 }
 
 export enum API {
@@ -381,7 +385,11 @@ export enum API {
   GET_EVENT_FOR_EDITING = `${API_ROUTE.EVENTS}/${API_ENDPOINT.GET_EVENT_FOR_EDITING}`,
   UPDATE_EVENT = `${API_ROUTE.EVENTS}/${API_ENDPOINT.UPDATE_EVENT}`,
   CANCEL_EVENT = `${API_ROUTE.EVENTS}/${API_ENDPOINT.CANCEL_EVENT}`,
-  REMOVE_PARTICIPANT = `${API_ROUTE.EVENTS}/${API_ENDPOINT.REMOVE_PARTICIPANT}`
+  REMOVE_PARTICIPANT = `${API_ROUTE.EVENTS}/${API_ENDPOINT.REMOVE_PARTICIPANT}`,
+  GET_PREMIUM_STATUS = `${API_ROUTE.APP}/${API_ENDPOINT.PREMIUM_STATUS}`,
+  GET_MAP_DATA_FOR_REGION = `${API_ROUTE.MAPS}/${API_ENDPOINT.GET_MAP_DATA_FOR_REGION}`,
+  GET_LAST_MAP_UPDATE_DATE = `${API_ROUTE.MAPS}/${API_ENDPOINT.GET_LAST_MAP_UPDATE_DATE}`,
+  GET_SIZE_FOR_BOUNDING_BOX = `${API_ROUTE.MAPS}/${API_ENDPOINT.GET_SIZE_FOR_BOUNDING_BOX}`
 }
 
 export type BaseAxiosError = AxiosError;

+ 4 - 0
src/types/navigation.ts

@@ -81,4 +81,8 @@ export enum NAVIGATION_PAGES {
   ALL_EVENT_PHOTOS = 'inAppAllEventPhotos',
   PARTICIPANTS_LIST = 'inAppParticipantsList',
   EVENTS_NOTIFICATIONS = 'inAppEventsNotifications',
+  OFFLINE_MAPS = 'inAppOfflineMaps',
+  OFFLINE_SELECT_MAP = 'inAppOfflineSelectMap',
+  OFFLINE_SELECT_REGIONS = 'inAppOfflineSelectRegions',
+  OFFLINE_DOWNLOAD_PROGRESS = 'inAppOfflineDownloadProgress'
 }

Неке датотеке нису приказане због велике количине промена