Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions app.json
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,15 @@
"output": "static",
"favicon": "./assets/images/favicon.png"
},
"updates": {
"enabled": true,
"checkAutomatically": "ON_LOAD",
"fallbackToCacheTimeout": 0,
"url": "https://u.expo.dev/your-project-id"
},
"runtimeVersion": {
"policy": "appVersion"
},
"plugins": [
"expo-router",
[
Expand Down
44 changes: 24 additions & 20 deletions app/_layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,31 +7,35 @@ import { ErrorBoundary } from '../src/components/common/ErrorBoundary';
import { AnalyticsProvider } from "../src/components/mobile/AnalyticsProvider";
import { OfflineIndicatorProvider } from "../src/components/mobile/OfflineIndicatorProvider";
import { SwipeableNavigation } from '../src/components/mobile/SwipeableNavigation';
import { UpdateProvider } from "../src/components/mobile/UpdateProvider";

export default function RootLayout() {
return (
<ErrorBoundary boundaryName="RootLayout">
<AnalyticsProvider>
<OfflineIndicatorProvider>
<GestureHandlerRootView style={{ flex: 1 }}>
<Stack>
<Stack.Screen name="(tabs)" options={{ headerShown: false }} />
<Stack.Screen
name="course-viewer"
options={{ headerShown: false }}
/>
<Stack.Screen
name="profile/[userId]"
options={{ headerShown: false }}
/>
<Stack.Screen name="search" options={{ headerShown: false }} />
<Stack.Screen name="settings" options={{ headerShown: false }} />
<Stack.Screen name="quiz" options={{ headerShown: false }} />
<Stack.Screen name="modal" options={{ presentation: "modal" }} />
</Stack>
</GestureHandlerRootView>
</OfflineIndicatorProvider>
</AnalyticsProvider>
{/* UpdateProvider sits inside AnalyticsProvider so events are tracked */}
<UpdateProvider>
<OfflineIndicatorProvider>
<GestureHandlerRootView style={{ flex: 1 }}>
<Stack>
<Stack.Screen name="(tabs)" options={{ headerShown: false }} />
<Stack.Screen
name="course-viewer"
options={{ headerShown: false }}
/>
<Stack.Screen
name="profile/[userId]"
options={{ headerShown: false }}
/>
<Stack.Screen name="search" options={{ headerShown: false }} />
<Stack.Screen name="settings" options={{ headerShown: false }} />
<Stack.Screen name="quiz" options={{ headerShown: false }} />
<Stack.Screen name="modal" options={{ presentation: "modal" }} />
</Stack>
</GestureHandlerRootView>
</OfflineIndicatorProvider>
</UpdateProvider>
</AnalyticsProvider>
</ErrorBoundary>
);
}
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@
"expo-speech-recognition": "^3.1.0",
"expo-splash-screen": "~31.0.13",
"expo-status-bar": "~3.0.9",
"expo-updates": "~0.28.13",
"expo-symbols": "~1.0.8",
"expo-system-ui": "~6.0.9",
"expo-web-browser": "~15.0.10",
Expand Down
233 changes: 233 additions & 0 deletions src/components/mobile/UpdatePrompt.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,233 @@
import React from 'react';
import {
ActivityIndicator,
Modal,
Pressable,
StyleSheet,
Text,
TouchableOpacity,
View,
} from 'react-native';
import { ErrorBoundary } from '../common/ErrorBoundary';

// ─── Types ────────────────────────────────────────────────────────────────────

interface UpdatePromptProps {
/** Whether the modal is visible */
visible: boolean;
/** Whether the update is currently downloading */
isDownloading: boolean;
/** Called when the user taps "Update Now" */
onUpdate: () => void;
/** Called when the user taps "Later" */
onDismiss: () => void;
/** Whether to use dark mode styling */
isDark?: boolean;
}

// ─── Component ────────────────────────────────────────────────────────────────

export const UpdatePrompt: React.FC<UpdatePromptProps> = ({
visible,
isDownloading,
onUpdate,
onDismiss,
isDark = false,
}) => {
const bg = isDark ? '#1e293b' : '#ffffff';
const overlay = isDark ? 'rgba(0,0,0,0.75)' : 'rgba(0,0,0,0.5)';
const textPrimary = isDark ? '#f1f5f9' : '#1e293b';
const textSecondary = isDark ? '#94a3b8' : '#64748b';
const accentColor = '#19c3e6';
const iconBg = isDark ? '#0f172a' : '#f0fbff';
const divider = isDark ? '#334155' : '#e2e8f0';

return (
<ErrorBoundary boundaryName="UpdatePromptModal">
<Modal
visible={visible}
transparent
animationType="fade"
onRequestClose={onDismiss}
statusBarTranslucent
>
<Pressable style={[styles.overlay, { backgroundColor: overlay }]} onPress={onDismiss}>
{/* Stop tap-through on the sheet itself */}
<Pressable
style={[styles.sheet, { backgroundColor: bg }]}
onPress={(e: { stopPropagation: () => void }) => e.stopPropagation()}
>
{/* Icon */}
<View style={[styles.iconContainer, { backgroundColor: iconBg }]}>
{isDownloading ? (
<ActivityIndicator size="large" color={accentColor} />
) : (
<Text style={styles.iconEmoji}>🚀</Text>
)}
</View>

{/* Title */}
<Text style={[styles.title, { color: textPrimary }]}>
{isDownloading ? 'Updating…' : 'Update Available'}
</Text>

{/* Body */}
<Text style={[styles.body, { color: textSecondary }]}>
{isDownloading
? 'Downloading the latest version. The app will restart automatically.'
: 'A new version of TeachLink is ready. Update now for the latest features and security improvements.'}
</Text>

{/* What's new bullets */}
{!isDownloading && (
<View style={[styles.bulletContainer, { borderColor: divider }]}>
<BulletRow icon="✨" text="New features and improvements" textColor={textSecondary} />
<BulletRow icon="🛡️" text="Security patches applied" textColor={textSecondary} />
<BulletRow icon="⚡" text="Performance enhancements" textColor={textSecondary} />
</View>
)}

{/* CTA */}
{!isDownloading && (
<TouchableOpacity
style={[styles.primaryBtn, { backgroundColor: accentColor }]}
onPress={onUpdate}
activeOpacity={0.85}
accessibilityRole="button"
accessibilityLabel="Update now"
>
<Text style={styles.primaryBtnText}>Update Now</Text>
</TouchableOpacity>
)}

{/* Dismiss */}
{!isDownloading && (
<TouchableOpacity
style={styles.secondaryBtn}
onPress={onDismiss}
activeOpacity={0.7}
accessibilityRole="button"
accessibilityLabel="Remind me later"
>
<Text style={[styles.secondaryBtnText, { color: textSecondary }]}>
Remind Me Later
</Text>
</TouchableOpacity>
)}
</Pressable>
</Pressable>
</Modal>
</ErrorBoundary>
);
};

// ─── Bullet row helper ────────────────────────────────────────────────────────

function BulletRow({
icon,
text,
textColor,
}: {
icon: string;
text: string;
textColor: string;
}) {
return (
<View style={styles.bulletRow}>
<Text style={styles.bulletIcon}>{icon}</Text>
<Text style={[styles.bulletText, { color: textColor }]}>{text}</Text>
</View>
);
}

// ─── Styles ───────────────────────────────────────────────────────────────────

const styles = StyleSheet.create({
overlay: {
flex: 1,
justifyContent: 'flex-end',
},
sheet: {
borderTopLeftRadius: 28,
borderTopRightRadius: 28,
paddingHorizontal: 28,
paddingTop: 32,
paddingBottom: 48,
alignItems: 'center',
gap: 12,
shadowColor: '#000',
shadowOffset: { width: 0, height: -4 },
shadowOpacity: 0.12,
shadowRadius: 24,
elevation: 20,
},
iconContainer: {
width: 88,
height: 88,
borderRadius: 44,
justifyContent: 'center',
alignItems: 'center',
marginBottom: 4,
},
iconEmoji: {
fontSize: 40,
},
title: {
fontSize: 20,
fontWeight: '800',
textAlign: 'center',
},
body: {
fontSize: 14,
textAlign: 'center',
lineHeight: 21,
maxWidth: 300,
},
bulletContainer: {
width: '100%',
borderWidth: 1,
borderRadius: 14,
paddingVertical: 12,
paddingHorizontal: 16,
gap: 8,
marginTop: 4,
},
bulletRow: {
flexDirection: 'row',
alignItems: 'center',
gap: 10,
},
bulletIcon: {
fontSize: 16,
width: 24,
textAlign: 'center',
},
bulletText: {
fontSize: 13,
fontWeight: '500',
flex: 1,
},
primaryBtn: {
width: '100%',
paddingVertical: 15,
borderRadius: 14,
alignItems: 'center',
justifyContent: 'center',
marginTop: 4,
},
primaryBtnText: {
color: '#ffffff',
fontSize: 16,
fontWeight: '700',
},
secondaryBtn: {
paddingVertical: 10,
paddingHorizontal: 16,
},
secondaryBtnText: {
fontSize: 14,
fontWeight: '500',
},
});

export default UpdatePrompt;
37 changes: 37 additions & 0 deletions src/components/mobile/UpdateProvider.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import React, { ReactNode } from 'react';
import { useColorScheme } from 'react-native';
import { useAppUpdate } from '../../hooks/useAppUpdate';
import { ErrorBoundary } from '../common/ErrorBoundary';
import { UpdatePrompt } from './UpdatePrompt';

interface UpdateProviderProps {
children: ReactNode;
}

/**
* UpdateProvider mounts at the root of the app and:
* 1. Checks for OTA updates on launch (via useAppUpdate)
* 2. Renders the UpdatePrompt modal when an update is available
*
* Wrap this inside AnalyticsProvider so analytics are ready before
* the first update check fires.
*/
export const UpdateProvider: React.FC<UpdateProviderProps> = ({ children }) => {
const colorScheme = useColorScheme();
const { isPromptVisible, status, applyUpdate, dismissUpdate } = useAppUpdate();

return (
<ErrorBoundary boundaryName="UpdateProvider">
{children}
<UpdatePrompt
visible={isPromptVisible}
isDownloading={status === 'downloading'}
onUpdate={applyUpdate}
onDismiss={dismissUpdate}
isDark={colorScheme === 'dark'}
/>
</ErrorBoundary>
);
};

export default UpdateProvider;
3 changes: 2 additions & 1 deletion src/components/mobile/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,5 +15,6 @@ export * from "./SearchResultCard";
export * from "./SettingsPicker";
export * from "./SettingsSection";
export * from "./StatisticsDisplay";
export * from "./UpdatePrompt";
export * from "./UpdateProvider";
export * from "./VoiceSearch";

Loading