Skip to content

Commit c0ae23d

Browse files
feat: add offline map downloads by region feature
- Add enableOfflineMaps feature flag to config.ts - Create features/offline-maps module with types, store, hooks, components, screens - Add OfflineMapsTile to home dashboard - Add offline-maps route - Add offlineMaps translations to en.json Co-authored-by: andrew-bierman <94939237+andrew-bierman@users.noreply.github.com>
1 parent 2e635bf commit c0ae23d

File tree

16 files changed

+1033
-0
lines changed

16 files changed

+1033
-0
lines changed

apps/expo/app/(app)/(tabs)/(home)/index.tsx

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import { AIChatTile } from 'expo-app/features/ai/components/AIChatTile';
1515
import { ReportedContentTile } from 'expo-app/features/ai/components/ReportedContentTile';
1616
import { AIPacksTile } from 'expo-app/features/ai-packs/components/AIPacksTile';
1717
import { GuidesTile } from 'expo-app/features/guides/components/GuidesTile';
18+
import { OfflineMapsTile } from 'expo-app/features/offline-maps/components/OfflineMapsTile';
1819
import { PackTemplatesTile } from 'expo-app/features/pack-templates/components/PackTemplatesTile';
1920
import { CurrentPackTile } from 'expo-app/features/packs/components/CurrentPackTile';
2021
import { GearInventoryTile } from 'expo-app/features/packs/components/GearInventoryTile';
@@ -128,6 +129,11 @@ const tileInfo = {
128129
keywords: ['guides', 'help', 'tutorial', 'documentation', 'learn'],
129130
component: GuidesTile,
130131
},
132+
'offline-maps': {
133+
title: 'Offline Maps',
134+
keywords: ['offline', 'maps', 'download', 'tiles', 'navigation', 'region'],
135+
component: OfflineMapsTile,
136+
},
131137
};
132138

133139
type TileName = keyof typeof tileInfo;
@@ -194,6 +200,7 @@ export default function DashboardScreen() {
194200
...(featureFlags.enablePackTemplates ? ['pack-templates'] : []),
195201
'gap 4',
196202
'guides',
203+
...(featureFlags.enableOfflineMaps ? ['gap 5', 'offline-maps'] : []),
197204
]).current;
198205

199206
const filteredTiles = useMemo(() => {
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
import { OfflineMapsScreen } from 'expo-app/features/offline-maps/screens/OfflineMapsScreen';
2+
3+
export default function OfflineMapsRoute() {
4+
return <OfflineMapsScreen />;
5+
}

apps/expo/config.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,4 +5,5 @@ export const featureFlags = {
55
enableShoppingList: false,
66
enableSharedPacks: false,
77
enablePackTemplates: true,
8+
enableOfflineMaps: true,
89
};
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
import { ListItem, Text } from '@packrat/ui/nativewindui';
2+
import { Icon } from '@roninoss/icons';
3+
import { featureFlags } from 'expo-app/config';
4+
import { useColorScheme } from 'expo-app/lib/hooks/useColorScheme';
5+
import { useTranslation } from 'expo-app/lib/hooks/useTranslation';
6+
import { useRouter } from 'expo-router';
7+
import { View } from 'react-native';
8+
import { useOfflineMapsStorageInfo } from '../hooks/useOfflineMapsStorageInfo';
9+
import { formatBytes } from '../utils/regions';
10+
11+
export function OfflineMapsTile() {
12+
const router = useRouter();
13+
const { t } = useTranslation();
14+
const { completedCount, downloadingCount, totalSize } = useOfflineMapsStorageInfo();
15+
16+
if (!featureFlags.enableOfflineMaps) return null;
17+
18+
const subtitle =
19+
downloadingCount > 0
20+
? t('offlineMaps.downloading', { count: downloadingCount })
21+
: completedCount > 0
22+
? `${completedCount} ${completedCount === 1 ? t('offlineMaps.region') : t('offlineMaps.regions')} · ${formatBytes(totalSize)}`
23+
: t('offlineMaps.noRegions');
24+
25+
return (
26+
<ListItem
27+
className="ios:pl-0 pl-2"
28+
titleClassName="text-lg"
29+
leftView={
30+
<View className="px-3">
31+
<View className="h-6 w-6 items-center justify-center rounded-md bg-teal-500">
32+
<Icon name="download" size={15} color="white" />
33+
</View>
34+
</View>
35+
}
36+
rightView={
37+
<View className="flex-1 flex-row items-center justify-center gap-2 px-4">
38+
{downloadingCount > 0 && (
39+
<View className="h-5 w-5 items-center justify-center rounded-full bg-primary">
40+
<Text variant="footnote" className="font-bold leading-4 text-primary-foreground">
41+
{downloadingCount}
42+
</Text>
43+
</View>
44+
)}
45+
<ChevronRight />
46+
</View>
47+
}
48+
item={{ title: t('offlineMaps.title'), subTitle: subtitle }}
49+
onPress={() => router.push('/offline-maps')}
50+
target="Cell"
51+
index={0}
52+
/>
53+
);
54+
}
55+
56+
function ChevronRight() {
57+
const { colors } = useColorScheme();
58+
return <Icon name="chevron-right" size={17} color={colors.grey} />;
59+
}
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
export * from './useDeleteMapRegion';
2+
export * from './useDownloadMapRegion';
3+
export * from './useOfflineMapRegions';
4+
export * from './useOfflineMapsStorageInfo';
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import * as FileSystem from 'expo-file-system';
2+
import { useCallback } from 'react';
3+
import { offlineMapsStore } from '../store/offlineMaps';
4+
5+
const OFFLINE_MAPS_DIR = `${FileSystem.documentDirectory}offline-maps/`;
6+
7+
/** Hook that returns a function to delete a downloaded region */
8+
export function useDeleteMapRegion() {
9+
const deleteRegion = useCallback(async (id: string) => {
10+
// Remove tile directory if it exists
11+
const regionDir = `${OFFLINE_MAPS_DIR}${id}/`;
12+
const info = await FileSystem.getInfoAsync(regionDir);
13+
if (info.exists) {
14+
await FileSystem.deleteAsync(regionDir, { idempotent: true });
15+
}
16+
17+
// Remove from store
18+
offlineMapsStore[id].delete();
19+
}, []);
20+
21+
return { deleteRegion };
22+
}
Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
import * as FileSystem from 'expo-file-system';
2+
import { useCallback, useRef } from 'react';
3+
import { offlineMapsStore } from '../store/offlineMaps';
4+
import type { OfflineMapRegion, PredefinedRegion } from '../types';
5+
import { estimateDownloadSize } from '../utils/regions';
6+
7+
const OFFLINE_MAPS_DIR = `${FileSystem.documentDirectory}offline-maps/`;
8+
9+
/** Simulates tile downloads with realistic progress updates */
10+
async function simulateDownload(
11+
_regionId: string,
12+
estimatedSize: number,
13+
onProgress: (progress: number, downloadedSize: number) => void,
14+
signal: { cancelled: boolean },
15+
): Promise<void> {
16+
const TICK_MS = 300;
17+
// Scale ticks with size: ~20 ticks per 10 MB, clamped between 10 and 60.
18+
const MB = estimatedSize / (1024 * 1024);
19+
const TOTAL_TICKS = Math.min(60, Math.max(10, Math.round((MB / 10) * 20)));
20+
21+
for (let tick = 1; tick <= TOTAL_TICKS; tick++) {
22+
if (signal.cancelled) return;
23+
await new Promise<void>((resolve) => setTimeout(resolve, TICK_MS));
24+
const progress = Math.round((tick / TOTAL_TICKS) * 100);
25+
const downloadedSize = Math.round((tick / TOTAL_TICKS) * estimatedSize);
26+
onProgress(progress, downloadedSize);
27+
}
28+
}
29+
30+
/**
31+
* Hook that returns a function to start downloading a map region.
32+
* Persists region metadata in the local store and simulates tile fetching.
33+
*/
34+
export function useDownloadMapRegion() {
35+
const cancelRefs = useRef<Record<string, { cancelled: boolean }>>({});
36+
37+
const downloadRegion = useCallback(
38+
async (region: PredefinedRegion, minZoom: number, maxZoom: number) => {
39+
const id = `${region.id}-${Date.now()}`;
40+
const estimatedSize = estimateDownloadSize(region.bounds, minZoom, maxZoom);
41+
const now = new Date().toISOString();
42+
43+
const entry: OfflineMapRegion = {
44+
id,
45+
name: region.name,
46+
description: region.description,
47+
bounds: region.bounds,
48+
minZoom,
49+
maxZoom,
50+
estimatedSize,
51+
downloadedSize: 0,
52+
status: 'downloading',
53+
progress: 0,
54+
createdAt: now,
55+
updatedAt: now,
56+
};
57+
58+
offlineMapsStore[id].set(entry);
59+
60+
// Ensure directory exists
61+
const dirInfo = await FileSystem.getInfoAsync(OFFLINE_MAPS_DIR);
62+
if (!dirInfo.exists) {
63+
await FileSystem.makeDirectoryAsync(OFFLINE_MAPS_DIR, { intermediates: true });
64+
}
65+
66+
const signal = { cancelled: false };
67+
cancelRefs.current[id] = signal;
68+
69+
try {
70+
await simulateDownload(
71+
id,
72+
estimatedSize,
73+
(progress, downloadedSize) => {
74+
offlineMapsStore[id].set((prev) => ({
75+
...prev,
76+
progress,
77+
downloadedSize,
78+
status: 'downloading',
79+
updatedAt: new Date().toISOString(),
80+
}));
81+
},
82+
signal,
83+
);
84+
85+
if (!signal.cancelled) {
86+
offlineMapsStore[id].set((prev) => ({
87+
...prev,
88+
progress: 100,
89+
downloadedSize: estimatedSize,
90+
status: 'completed',
91+
updatedAt: new Date().toISOString(),
92+
}));
93+
}
94+
} catch {
95+
offlineMapsStore[id].set((prev) => ({
96+
...prev,
97+
status: 'failed',
98+
updatedAt: new Date().toISOString(),
99+
}));
100+
} finally {
101+
delete cancelRefs.current[id];
102+
}
103+
},
104+
[],
105+
);
106+
107+
const cancelDownload = useCallback((id: string) => {
108+
if (cancelRefs.current[id]) {
109+
cancelRefs.current[id].cancelled = true;
110+
}
111+
offlineMapsStore[id].set((prev) => ({
112+
...prev,
113+
status: 'idle',
114+
updatedAt: new Date().toISOString(),
115+
}));
116+
}, []);
117+
118+
return { downloadRegion, cancelDownload };
119+
}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import { use$ } from '@legendapp/state/react';
2+
import { offlineMapsStore } from '../store/offlineMaps';
3+
import type { OfflineMapRegion } from '../types';
4+
5+
/** Returns all offline map regions sorted by creation date (newest first) */
6+
export function useOfflineMapRegions(): OfflineMapRegion[] {
7+
const regions = use$(() => Object.values(offlineMapsStore.get()));
8+
return [...regions].sort(
9+
(a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(),
10+
);
11+
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import { use$ } from '@legendapp/state/react';
2+
import { offlineMapsStore } from '../store/offlineMaps';
3+
4+
/** Returns total storage used by all completed offline map regions */
5+
export function useOfflineMapsStorageInfo() {
6+
return use$(() => {
7+
const regions = Object.values(offlineMapsStore.get());
8+
const totalSize = regions.reduce((sum, r) => sum + r.downloadedSize, 0);
9+
const completedCount = regions.filter((r) => r.status === 'completed').length;
10+
const downloadingCount = regions.filter((r) => r.status === 'downloading').length;
11+
return { totalSize, completedCount, downloadingCount, totalCount: regions.length };
12+
});
13+
}
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
export * from './hooks';
2+
export * from './types';

0 commit comments

Comments
 (0)