Skip to content
Draft
Show file tree
Hide file tree
Changes from 2 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
26 changes: 22 additions & 4 deletions app/(app)/(tabs)/_layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import React, { useMemo } from 'react';
import { Platform } from 'react-native';
import { Tabs } from 'expo-router';
import { NativeTabs } from 'expo-router/unstable-native-tabs';
import { Home, Compass, TrendingUp, CheckSquare, User } from 'lucide-react-native';
import { Home, Compass, TrendingUp, CheckSquare, User, Bot } from 'lucide-react-native';
import { useTheme } from '@/contexts/ThemeContext';
import { useAuth } from '@/contexts/AuthContext';
import type { SFSymbol } from 'sf-symbols-typescript';
Expand Down Expand Up @@ -38,6 +38,13 @@ const TAB_ROUTES: TabRoute[] = [
mdIcon: 'home',
icon: Home,
},
{
name: 'buddy',
title: 'Buddy',
sfSymbol: 'bubble.left.and.text.bubble.right.fill',
mdIcon: 'smart_toy',
icon: Bot,
},
{
name: 'program',
title: 'Program',
Expand Down Expand Up @@ -86,10 +93,18 @@ export default function TabLayout(): React.ReactElement {
// Show Program tab unless explicitly set to false (treats null/undefined as true for backwards compatibility)
const shouldShowProgram = profile?.show_program_content !== false;

// Determine if Buddy tab should be visible
// Show Buddy tab unless explicitly set to false (treats null/undefined as true for new users)
const shouldShowBuddy = profile?.ai_buddy_enabled !== false;

// Filter tab routes for web navigation items only
const visibleTabRoutes = useMemo(() => {
return shouldShowProgram ? TAB_ROUTES : TAB_ROUTES.filter((route) => route.name !== 'program');
}, [shouldShowProgram]);
return TAB_ROUTES.filter((route) => {
if (route.name === 'program' && !shouldShowProgram) return false;
if (route.name === 'buddy' && !shouldShowBuddy) return false;
return true;
});
}, [shouldShowProgram, shouldShowBuddy]);

// Web: Use top navigation instead of bottom tabs
if (Platform.OS === 'web') {
Expand Down Expand Up @@ -135,7 +150,10 @@ export default function TabLayout(): React.ReactElement {
<NativeTabs.Trigger
key={route.name}
name={route.name}
hidden={route.name === 'program' && !shouldShowProgram}
hidden={
(route.name === 'program' && !shouldShowProgram) ||
(route.name === 'buddy' && !shouldShowBuddy)
}
>
<NativeTabs.Trigger.Label>{route.title}</NativeTabs.Trigger.Label>
<NativeTabs.Trigger.Icon sf={route.sfSymbol} md={route.mdIcon} />
Expand Down
125 changes: 125 additions & 0 deletions app/(app)/(tabs)/buddy.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
// =============================================================================
// Imports
// =============================================================================
import React from 'react';
import { View, Text, StyleSheet, Platform } from 'react-native';
import { useTheme, type ThemeColors } from '@/contexts/ThemeContext';
import { useAuth } from '@/contexts/AuthContext';
import { useTabBarPadding } from '@/hooks/useTabBarPadding';
import SettingsButton from '@/components/navigation/SettingsButton';
import { Bot } from 'lucide-react-native';
import { hexWithAlpha } from '@/lib/format';

// =============================================================================
// Component
// =============================================================================

/**
* Buddy tab screen — placeholder for the Sobers Buddy AI companion.
*
* Displays a coming soon message while the full chat interface is being built.
* This screen is only visible when the user has `ai_buddy_enabled` set to true.
*/
export default function BuddyScreen(): React.ReactElement {
const { theme } = useTheme();
const { profile } = useAuth();
const tabBarPadding = useTabBarPadding();
const styles = createStyles(theme);

return (
<View style={[styles.container, { paddingBottom: tabBarPadding }]}>
{Platform.OS !== 'web' && <SettingsButton />}
<View style={styles.content}>
<View style={styles.iconContainer}>
<Bot size={48} color={theme.primary} />
</View>
<Text style={styles.title}>Sobers Buddy</Text>
<Text style={styles.subtitle}>Your AI-powered accountability partner</Text>
<View style={styles.card}>
<Text style={styles.cardTitle}>Coming Soon</Text>
<Text style={styles.cardText}>
{profile?.display_name ? `Hey ${profile.display_name}! ` : ''}Sobers Buddy is an AI
companion designed to support your recovery journey. Chat, get encouragement, and
receive personalized support — all with complete privacy.
</Text>
</View>
<View style={styles.featureList}>
<Text style={styles.featureItem}>💬 Real-time chat conversations</Text>
<Text style={styles.featureItem}>🎯 Personalized recovery support</Text>
<Text style={styles.featureItem}>🔒 Private and secure</Text>
<Text style={styles.featureItem}>🤝 Non-judgmental, always supportive</Text>
</View>
</View>
</View>
);
}

// =============================================================================
// Styles
// =============================================================================

const createStyles = (theme: ThemeColors) =>
StyleSheet.create({
container: {
flex: 1,
backgroundColor: theme.background,
},
content: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
paddingHorizontal: 24,
},
iconContainer: {
width: 80,
height: 80,
borderRadius: 40,
backgroundColor: hexWithAlpha(theme.primary, 0.1),
alignItems: 'center',
justifyContent: 'center',
marginBottom: 16,
},
title: {
fontSize: 24,
fontFamily: theme.fontBold,
color: theme.text,
marginBottom: 8,
},
subtitle: {
fontSize: 16,
fontFamily: theme.fontRegular,
color: theme.textSecondary,
marginBottom: 32,
textAlign: 'center',
},
card: {
backgroundColor: theme.card,
borderRadius: 16,
padding: 20,
width: '100%',
marginBottom: 24,
borderWidth: 1,
borderColor: theme.border,
},
cardTitle: {
fontSize: 18,
fontFamily: theme.fontSemiBold,
color: theme.primary,
marginBottom: 8,
},
cardText: {
fontSize: 14,
fontFamily: theme.fontRegular,
color: theme.textSecondary,
lineHeight: 22,
},
featureList: {
width: '100%',
gap: 12,
},
featureItem: {
fontSize: 15,
fontFamily: theme.fontMedium,
color: theme.text,
},
});
71 changes: 71 additions & 0 deletions components/settings/SettingsContent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ import {
Sparkles,
Calendar,
BookOpen,
Bot,
} from 'lucide-react-native';
import * as Clipboard from 'expo-clipboard';
import { logger, LogCategory } from '@/lib/logger';
Expand Down Expand Up @@ -490,6 +491,7 @@ export function SettingsContent({ onDismiss }: SettingsContentProps) {
const [isSavingName, setIsSavingName] = useState(false);
const [isSavingDashboard, setIsSavingDashboard] = useState(false);
const [isSavingTwelveStep, setIsSavingTwelveStep] = useState(false);
const [isSavingAiBuddy, setIsSavingAiBuddy] = useState(false);
const [showSobrietyDatePicker, setShowSobrietyDatePicker] = useState(false);
const [selectedSobrietyDate, setSelectedSobrietyDate] = useState<Date>(new Date());
const buildInfo = getBuildInfo();
Expand Down Expand Up @@ -766,6 +768,44 @@ export function SettingsContent({ onDismiss }: SettingsContentProps) {
}
}, [profile?.id, profile?.show_program_content, isSavingTwelveStep, refreshProfile]);

/**
* Handles toggling the AI Buddy feature.
* Updates profile in Supabase and refreshes profile state.
*/
const handleToggleAiBuddy = useCallback(async () => {
if (!profile?.id || isSavingAiBuddy) return;

setIsSavingAiBuddy(true);
try {
const currentValue = profile.ai_buddy_enabled !== false;
const newValue = !currentValue;
const { error } = await supabase
.from('profiles')
.update({ ai_buddy_enabled: newValue })
.eq('id', profile.id);

if (error) throw error;

await refreshProfile();

// Track settings change
trackEvent(newValue ? AnalyticsEvents.AI_BUDDY_ENABLED : AnalyticsEvents.AI_BUDDY_DISABLED, {
setting: 'ai_buddy_enabled',
value: newValue,
});

showToast.success(newValue ? 'Sobers Buddy enabled' : 'Sobers Buddy disabled');
} catch (error) {
const err = error instanceof Error ? error : new Error('Failed to update setting');
logger.error('Failed to toggle AI Buddy', err, {
category: LogCategory.DATABASE,
});
showToast.error('Failed to update. Please try again.');
} finally {
setIsSavingAiBuddy(false);
}
}, [profile?.id, profile?.ai_buddy_enabled, isSavingAiBuddy, refreshProfile]);

/**
* Opens the sobriety date picker with the current sobriety date pre-selected.
*/
Expand Down Expand Up @@ -1074,6 +1114,37 @@ export function SettingsContent({ onDismiss }: SettingsContentProps) {
</View>
)}
</Pressable>
<View style={styles.separator} />
<Pressable
testID="settings-ai-buddy-toggle"
style={styles.menuItem}
onPress={handleToggleAiBuddy}
disabled={isSavingAiBuddy}
accessibilityRole="switch"
accessibilityState={{ checked: profile?.ai_buddy_enabled !== false }}
accessibilityLabel="Enable Sobers Buddy"
>
<View style={styles.menuItemLeft}>
<Bot size={20} color={theme.textSecondary} />
<View>
<Text style={styles.menuItemText}>Sobers Buddy</Text>
<Text style={styles.menuItemSubtext}>
AI-powered accountability partner for your recovery journey
</Text>
</View>
</View>
{isSavingAiBuddy ? (
<ActivityIndicator size="small" color={theme.primary} />
) : (
<View
style={[styles.toggle, profile?.ai_buddy_enabled !== false && styles.toggleActive]}
>
<Text style={styles.toggleText}>
{profile?.ai_buddy_enabled !== false ? 'ON' : 'OFF'}
</Text>
</View>
)}
Comment thread
qltysh[bot] marked this conversation as resolved.
Comment thread
qltysh[bot] marked this conversation as resolved.
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Found 31 lines of similar code in 2 locations (mass = 157) [qlty:similar-code]

</Pressable>
</View>
</View>

Expand Down
Loading
Loading