Skip to content
Merged
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
1 change: 1 addition & 0 deletions __tests__/rntl/onboarding/ChatScreenSpotlight.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,7 @@ jest.mock('../../../src/components', () => ({
...require('../../utils/spotlightMocks').createCustomAlertMock(),
ToolPickerSheet: () => null,
SharePromptSheet: () => null,
ProAhaSheet: () => null,
}));

jest.mock('../../../src/components/AnimatedPressable', () =>
Expand Down
1 change: 1 addition & 0 deletions __tests__/rntl/screens/ChatScreen.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -423,6 +423,7 @@ jest.mock('../../../src/components', () => ({
);
},
SharePromptSheet: () => null,
ProAhaSheet: () => null,
}));

jest.mock('../../../src/components/AnimatedEntry', () => ({
Expand Down
6 changes: 3 additions & 3 deletions __tests__/rntl/screens/SettingsScreen.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,7 @@ describe('SettingsScreen', () => {

it('renders version number', () => {
const { getByText } = render(<SettingsScreen />);
expect(getByText('1.0.0')).toBeTruthy();
expect(getByText(/Version 1\.0\.0/)).toBeTruthy();
});

it('renders navigation items', () => {
Expand Down Expand Up @@ -155,8 +155,8 @@ describe('SettingsScreen', () => {

it('renders about section text', () => {
const { getByText } = render(<SettingsScreen />);
expect(getByText('Version')).toBeTruthy();
expect(getByText(/Off Grid brings AI/)).toBeTruthy();
expect(getByText('About')).toBeTruthy();
expect(getByText(/Version/)).toBeTruthy();
});

it('renders Reset Onboarding button in __DEV__ mode', () => {
Expand Down
4 changes: 1 addition & 3 deletions src/components/MadeWithLove.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,6 @@
import React from 'react';
import { View, Text, Image, TouchableOpacity, Linking, StyleSheet } from 'react-native';
import { TYPOGRAPHY } from '../constants';

const WEDNESDAY_URL = 'https://www.wednesday.is/?utm_source=off-grid-mobile-app';
import { TYPOGRAPHY, WEDNESDAY_URL } from '../constants';

export const MadeWithLove: React.FC = () => (
<TouchableOpacity onPress={() => Linking.openURL(WEDNESDAY_URL)} style={styles.container}>
Expand Down
115 changes: 115 additions & 0 deletions src/components/ProAhaSheet.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
import React from 'react';
import { View, Text, TouchableOpacity } from 'react-native';
import Icon from 'react-native-vector-icons/Feather';
import { AppSheet } from './AppSheet';
import { useThemedStyles } from '../theme';

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remove duplicate imports

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed — merged the two ../theme imports into one.

import type { ThemeColors, ThemeShadows } from '../theme';
import { SPACING, TYPOGRAPHY, PRO_AHA_FEATURES } from '../constants';

interface ProAhaSheetProps {
visible: boolean;
onClose: () => void;
onRegister: () => void;
}

export const ProAhaSheet: React.FC<ProAhaSheetProps> = ({ visible, onClose, onRegister }) => {
const styles = useThemedStyles(createStyles);

const handleCta = () => {
onClose();
onRegister();
};

return (
<AppSheet visible={visible} onClose={onClose} enableDynamicSizing title="Off Grid PRO">
<View style={styles.content}>
<Text style={styles.headline}>Loving Off Grid?</Text>
<Text style={styles.subheadline}>
Help us build what's next - and get it free for life.
</Text>

<View style={styles.featureList}>
{PRO_AHA_FEATURES.map(feature => (
<View key={feature} style={styles.featureRow}>
<Icon name="check" size={14} color={styles.checkIcon.color} />
<Text style={styles.featureText}>{feature}</Text>
</View>
))}
</View>

<Text style={styles.guarantee}>
Ship in 12 weeks or full refund. No questions asked.
</Text>

<TouchableOpacity style={styles.ctaButton} onPress={handleCta}>
<Text style={styles.ctaText}>I am in 🔥</Text>
Comment on lines +24 to +45

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

This component introduces several hardcoded user-facing strings (e.g., "Off Grid PRO", "Loving Off Grid?", etc.). To support future localization and maintain consistency, these should be moved to a localization system using the project's established patterns.

</TouchableOpacity>
</View>
</AppSheet>
);
};

const createStyles = (colors: ThemeColors, _shadows: ThemeShadows) => ({
content: {
paddingHorizontal: SPACING.xl,
paddingTop: SPACING.md,
paddingBottom: SPACING.xxl,
alignItems: 'center' as const,
},
headline: {
...TYPOGRAPHY.h2,
color: colors.text,
textAlign: 'center' as const,
marginBottom: SPACING.sm,
},
subheadline: {
...TYPOGRAPHY.body,
color: colors.textSecondary,
textAlign: 'center' as const,
marginBottom: SPACING.md,
},
priceRow: {
marginBottom: SPACING.lg,
},
price: {
...TYPOGRAPHY.bodySmall,
color: colors.primary,
textAlign: 'center' as const,
},
featureList: {
width: '100%' as const,
marginBottom: SPACING.lg,
gap: SPACING.sm,
},
featureRow: {
flexDirection: 'row' as const,
alignItems: 'center' as const,
gap: SPACING.sm,
},
checkIcon: {
color: colors.primary,
},
featureText: {
...TYPOGRAPHY.body,
color: colors.text,
},
guarantee: {
...TYPOGRAPHY.bodySmall,
color: colors.textMuted,
textAlign: 'center' as const,
marginBottom: SPACING.lg,
},
ctaButton: {
width: '100%' as const,
paddingVertical: SPACING.md,
backgroundColor: colors.primary,
borderRadius: 8,
alignItems: 'center' as const,
justifyContent: 'center' as const,
marginBottom: SPACING.sm,
},
ctaText: {
...TYPOGRAPHY.body,
color: colors.background,
},
});
1 change: 1 addition & 0 deletions src/components/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ export { ProjectSelectorSheet } from './ProjectSelectorSheet';
export { DebugSheet } from './DebugSheet';
export { ToolPickerSheet } from './ToolPickerSheet';
export { SharePromptSheet } from './SharePromptSheet';
export { ProAhaSheet } from './ProAhaSheet';
export {
OnboardingSheet,
PulsatingIcon,
Expand Down
10 changes: 10 additions & 0 deletions src/constants/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,15 @@
export { MODEL_RECOMMENDATIONS, RECOMMENDED_MODELS, TRENDING_FAMILIES, TRENDING_MODEL_IDS, MODEL_ORGS, QUANTIZATION_INFO } from './models';

// External URLs
export const WEDNESDAY_URL = 'https://mobile.wednesday.is/hire-ai-native-mobile-squad?utm_source=off-grid-mobile-app&utm_medium=made-with-love&utm_campaign=in-app';

export const PRO_AHA_FEATURES = [
'Voice-native conversation',
'Custom MCP servers',
'Calendar and WhatsApp integration',
'More, shipping monthly',
];

// Hugging Face API configuration
export const HF_API = {
baseUrl: 'https://huggingface.co',
Expand Down
12 changes: 12 additions & 0 deletions src/navigation/AppNavigator.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,8 @@ import {
SecuritySettingsScreen,
GalleryScreen,
RemoteServersScreen,
ProDetailScreen,
AboutScreen,
} from '../screens';
import {
RootStackParamList,
Expand Down Expand Up @@ -232,6 +234,16 @@ export const AppNavigator: React.FC = () => {
<RootStack.Screen name="DeviceInfo" component={DeviceInfoScreen} />
<RootStack.Screen name="StorageSettings" component={StorageSettingsScreen} />
<RootStack.Screen name="SecuritySettings" component={SecuritySettingsScreen} />
<RootStack.Screen
name="ProDetail"
component={ProDetailScreen}
options={{ headerShown: false, animation: 'slide_from_bottom' }}
/>
<RootStack.Screen
name="About"
component={AboutScreen}
options={{ headerShown: false }}
/>
<RootStack.Screen
name="DownloadManager"
component={DownloadManagerScreen}
Expand Down
2 changes: 2 additions & 0 deletions src/navigation/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ export type RootStackParamList = {
// Already in RootStack
DownloadManager: undefined;
Gallery: { conversationId?: string } | undefined;
ProDetail: undefined;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Make sure to add types

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

undefined is the correct React Navigation type for routes that take no params — it signals the route exists but accepts nothing. No change needed here.

About: undefined;
};

// Tab navigator — simple, no sub-stacks
Expand Down
166 changes: 166 additions & 0 deletions src/screens/AboutScreen.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
import React from 'react';
import { View, Text, TouchableOpacity, Linking, ScrollView, Image, StyleSheet } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { useNavigation } from '@react-navigation/native';
import Icon from 'react-native-vector-icons/Feather';
import { useTheme, useThemedStyles } from '../theme';
import type { ThemeColors, ThemeShadows } from '../theme';
import { SPACING, TYPOGRAPHY } from '../constants';
import { MadeWithLove } from '../components/MadeWithLove';
import { AnimatedListItem } from '../components/AnimatedListItem';
import { useFocusTrigger } from '../hooks/useFocusTrigger';
import { GITHUB_URL } from '../utils/sharePrompt';
import packageJson from '../../package.json';

const WEDNESDAY_MOBILE_URL = 'https://mobile.wednesday.is/hire-ai-native-mobile-squad?utm_source=off-grid-mobile-app&utm_medium=about-screen&utm_campaign=in-app';

export const AboutScreen: React.FC = () => {
const navigation = useNavigation();
const { colors } = useTheme();
const styles = useThemedStyles(createStyles);
const focusTrigger = useFocusTrigger();

return (
<SafeAreaView style={styles.container} edges={['top', 'bottom']}>
<View style={styles.header}>
<TouchableOpacity onPress={() => navigation.goBack()} style={styles.backButton}>
<Icon name="arrow-left" size={20} color={colors.text} />
</TouchableOpacity>
<Text style={styles.headerTitle}>About</Text>
<View style={styles.backButton} />
</View>

<ScrollView contentContainerStyle={styles.content}>
{/* App identity */}
<View style={styles.heroSection}>
<Image source={require('../assets/logo.png')} style={staticStyles.appIcon} />
<Text style={styles.appName}>Off Grid</Text>
<Text style={styles.version}>Version {packageJson.version}</Text>
<Text style={styles.description}>
Local AI that runs entirely on your phone. No cloud, no telemetry, nothing leaves the device.
</Text>
</View>

{/* Open Source row */}
<View style={styles.navSection}>
<AnimatedListItem
index={0}
staggerMs={40}
trigger={focusTrigger}
style={[styles.navItem, styles.navItemLast]}
onPress={() => Linking.openURL(GITHUB_URL)}
>
<View style={styles.navItemIcon}>
<Icon name="github" size={16} color={colors.textSecondary} />
</View>
<View style={styles.navItemContent}>
<Text style={styles.navItemTitle}>Open Source</Text>
<Text style={styles.navItemDesc}>View the source on GitHub</Text>
</View>
<Icon name="external-link" size={14} color={colors.textMuted} />
</AnimatedListItem>
</View>

{/* Built by Wednesday row */}
<View style={styles.navSection}>
<AnimatedListItem
index={1}
staggerMs={40}
trigger={focusTrigger}
style={[styles.navItem, styles.navItemLast]}
onPress={() => Linking.openURL(WEDNESDAY_MOBILE_URL)}
>
<View style={styles.navItemIcon}>
<Image source={require('../assets/wednesday_logo.png')} style={styles.wednesdayLogo} />
</View>
<View style={styles.navItemContent}>
<Text style={styles.navItemTitle}>Built by Wednesday</Text>
<Text style={styles.navItemDesc}>We build mobile apps for enterprise teams</Text>
</View>
<Icon name="external-link" size={14} color={colors.textMuted} />
</AnimatedListItem>
</View>
</ScrollView>

{/* Pinned footer */}
<MadeWithLove />
</SafeAreaView>
);
};

const staticStyles = StyleSheet.create({
appIcon: { width: 72, height: 72, borderRadius: 16, marginBottom: SPACING.md },
});

const createStyles = (colors: ThemeColors, shadows: ThemeShadows) => ({
container: { flex: 1, backgroundColor: colors.background },
header: {
flexDirection: 'row' as const,
alignItems: 'center' as const,
justifyContent: 'space-between' as const,
paddingHorizontal: SPACING.lg,
paddingVertical: SPACING.md,
minHeight: 60,
borderBottomWidth: 1,
borderBottomColor: colors.border,
backgroundColor: colors.surface,
...shadows.small,
zIndex: 1,
},
backButton: { width: 36, padding: SPACING.xs },
headerTitle: { ...TYPOGRAPHY.h2, color: colors.text },
content: {
paddingHorizontal: SPACING.lg,
paddingTop: SPACING.lg,
paddingBottom: SPACING.xxl,
},
heroSection: {
alignItems: 'center' as const,
paddingVertical: SPACING.xxl,
marginBottom: SPACING.xl,
},
appName: {
...TYPOGRAPHY.h1,
color: colors.text,
marginBottom: SPACING.xs,
},
version: {
...TYPOGRAPHY.meta,
color: colors.textMuted,
marginBottom: SPACING.md,
},
description: {
...TYPOGRAPHY.body,
color: colors.textSecondary,
textAlign: 'center' as const,
lineHeight: 22,
paddingHorizontal: SPACING.md,
},
navSection: {
backgroundColor: colors.surface,
borderRadius: 8,
marginBottom: SPACING.lg,
overflow: 'hidden' as const,
...shadows.small,
},
navItem: {
flexDirection: 'row' as const,
alignItems: 'center' as const,
padding: SPACING.md,
borderBottomWidth: 1,
borderBottomColor: colors.border,
},
navItemLast: { borderBottomWidth: 0 },
navItemIcon: {
width: 28,
height: 28,
borderRadius: 6,
alignItems: 'center' as const,
justifyContent: 'center' as const,
marginRight: SPACING.md,
},
navItemContent: { flex: 1 },
navItemTitle: { ...TYPOGRAPHY.body, color: colors.text },
navItemDesc: { ...TYPOGRAPHY.bodySmall, color: colors.textMuted, marginTop: 2 },
wednesdayLogo: { width: 24, height: 24, resizeMode: 'contain' as const },
});
Loading
Loading