diff --git a/app/terms.tsx b/app/terms.tsx
index 2922ae7fe..f1040798c 100644
--- a/app/terms.tsx
+++ b/app/terms.tsx
@@ -24,6 +24,8 @@ function Terms() {
const handleAcceptTerms = useCallback(() => {
console.log('Accepting terms...');
acceptTerms();
+ // After accepting terms, show the onboarding walkthrough
+ // The onboarding will show automatically when navigating to projects view
router.navigate('/');
}, [acceptTerms, router]);
diff --git a/components/AppHeader.tsx b/components/AppHeader.tsx
index ac9053c97..65fc7125e 100644
--- a/components/AppHeader.tsx
+++ b/components/AppHeader.tsx
@@ -11,6 +11,7 @@ import {
AlertTriangle,
ChevronRight,
CloudOff,
+ HelpCircle,
Menu,
RefreshCw
} from 'lucide-react-native';
@@ -29,11 +30,13 @@ import { Button } from './ui/button';
export default function AppHeader({
drawerToggleCallback,
isCloudLoading = false,
- isNavigating = false
+ isNavigating = false,
+ onOnboardingPress
}: {
drawerToggleCallback: () => void;
isCloudLoading?: boolean;
isNavigating?: boolean;
+ onOnboardingPress?: () => void;
}) {
const {
breadcrumbs,
@@ -231,6 +234,19 @@ export default function AppHeader({
: null}
+ {/* Help/Onboarding Button */}
+ {onOnboardingPress && (
+
+ )}
+
{/* Menu Button with Indicators */}
+ );
+}
diff --git a/components/OnboardingProgressIndicator.tsx b/components/OnboardingProgressIndicator.tsx
new file mode 100644
index 000000000..bc9637bf8
--- /dev/null
+++ b/components/OnboardingProgressIndicator.tsx
@@ -0,0 +1,187 @@
+import { Text } from '@/components/ui/text';
+import { cn, getThemeColor } from '@/utils/styleUtils';
+import { useEffect } from 'react';
+import { View } from 'react-native';
+import Animated, {
+ useAnimatedStyle,
+ useSharedValue,
+ withSpring
+} from 'react-native-reanimated';
+
+export type OnboardingStep =
+ | 'vision'
+ | 'region'
+ | 'language'
+ | 'projects'
+ | 'create-project'
+ | 'create-project-simple'
+ | 'bible-select-book'
+ | 'create-quest'
+ | 'record-audio'
+ | 'invite-collaborators';
+
+interface OnboardingProgressIndicatorProps {
+ currentStep: OnboardingStep;
+ className?: string;
+}
+
+// Simple onboarding steps (for minimal onboarding flow - Other path)
+// Language selection happens on terms page, so we start with vision
+const SIMPLE_STEP_ORDER: OnboardingStep[] = [
+ 'vision',
+ 'create-project-simple',
+ 'create-quest',
+ 'record-audio',
+ 'invite-collaborators'
+];
+
+// Bible onboarding steps (bible-create-chapter is now part of bible-select-book)
+// Language selection happens on terms page, so we start with vision
+const BIBLE_STEP_ORDER: OnboardingStep[] = [
+ 'vision',
+ 'create-project-simple',
+ 'bible-select-book',
+ 'record-audio',
+ 'invite-collaborators'
+];
+
+// Original onboarding steps (for region/language flow)
+const ORIGINAL_STEP_ORDER: OnboardingStep[] = [
+ 'region',
+ 'language',
+ 'projects',
+ 'create-project'
+];
+
+// Determine which step order to use based on current step
+const getStepOrder = (currentStep: OnboardingStep): OnboardingStep[] => {
+ // If vision step, use simple order (will be updated when project type is selected)
+ if (currentStep === 'vision') {
+ return SIMPLE_STEP_ORDER;
+ }
+ if (BIBLE_STEP_ORDER.includes(currentStep)) {
+ return BIBLE_STEP_ORDER;
+ }
+ if (SIMPLE_STEP_ORDER.includes(currentStep)) {
+ return SIMPLE_STEP_ORDER;
+ }
+ return ORIGINAL_STEP_ORDER;
+};
+
+const STEP_LABELS: Record = {
+ vision: 'Vision',
+ region: 'Region',
+ language: 'Language',
+ projects: 'Projects',
+ 'create-project': 'Create',
+ 'create-project-simple': 'Project',
+ 'bible-select-book': 'Book',
+ 'create-quest': 'Quest',
+ 'record-audio': 'Record',
+ 'invite-collaborators': 'Invite'
+};
+
+export function OnboardingProgressIndicator({
+ currentStep,
+ className
+}: OnboardingProgressIndicatorProps) {
+ const stepOrder = getStepOrder(currentStep);
+ const currentStepIndex = stepOrder.indexOf(currentStep);
+ const progress = useSharedValue(currentStepIndex / (stepOrder.length - 1));
+
+ // Animate progress when step changes
+ useEffect(() => {
+ const targetProgress = currentStepIndex / (stepOrder.length - 1);
+ progress.value = withSpring(targetProgress, {
+ damping: 15,
+ stiffness: 100
+ });
+ }, [currentStepIndex, progress, stepOrder.length]);
+
+ // Animated progress bar style
+ const progressBarStyle = useAnimatedStyle(() => {
+ return {
+ width: `${progress.value * 100}%`
+ };
+ });
+
+ return (
+
+ {/* Steps container */}
+
+ {/* Progress bar background */}
+
+ {/* Animated progress fill */}
+
+
+
+ {/* Step indicators */}
+ {stepOrder.map((step, index) => {
+ const isActive = index === currentStepIndex;
+ const isCompleted = index < currentStepIndex;
+ const isPending = index > currentStepIndex;
+
+ return (
+
+ {/* Step circle */}
+
+ {isCompleted ? (
+
+ ✓
+
+ ) : (
+
+ {index + 1}
+
+ )}
+
+
+ {/* Step label */}
+
+ {STEP_LABELS[step]}
+
+
+ );
+ })}
+
+
+ );
+}
diff --git a/components/WaveformVisualization.tsx b/components/WaveformVisualization.tsx
index fb4cfe144..d51e903c7 100644
--- a/components/WaveformVisualization.tsx
+++ b/components/WaveformVisualization.tsx
@@ -105,8 +105,11 @@ export const WaveformVisualization: React.FC = ({
// But values can be higher, so normalize similar to VADSettingsDrawer
// Using MAX_ENERGY = 20.0 to match VADSettingsDrawer normalization
const MAX_ENERGY = 20.0;
- const normalizedRaw = Math.min(1.0, Math.max(0, currentEnergy / MAX_ENERGY));
-
+ const normalizedRaw = Math.min(
+ 1.0,
+ Math.max(0, currentEnergy / MAX_ENERGY)
+ );
+
// Scale relative to threshold for visualization
// Threshold is already normalized (0-1), so we compare normalized values
const normalizedEnergy = Math.max(
diff --git a/components/language-select.tsx b/components/language-select.tsx
index d60eea121..03a5362d2 100644
--- a/components/language-select.tsx
+++ b/components/language-select.tsx
@@ -151,10 +151,11 @@ export const LanguageSelect: React.FC = ({
if (!option) return;
const lang = languages.find((l) => l.id === option.value);
if (lang) {
- // If onChange is provided, use it (controlled mode)
+ // Always save the language and set UI language
setSavedLanguage(lang);
+ setUILanguage(lang);
+ // If onChange is provided, call it as well (for controlled mode)
if (onChange) onChange(lang);
- else setUILanguage(lang);
}
}}
>
diff --git a/eas.json b/eas.json
index 0245c8602..e70921b96 100644
--- a/eas.json
+++ b/eas.json
@@ -23,7 +23,10 @@
"autoIncrement": true,
"channel": "preview",
"environment": "preview",
- "distribution": "store",
+ "distribution": "internal",
+ "ios": {
+ "distribution": "store"
+ },
"env": {
"EXPO_PUBLIC_SITE_URL": "https://langquest.org",
"EXPO_PUBLIC_APP_VARIANT": "preview"
diff --git a/hooks/useLanguagesByRegion.ts b/hooks/useLanguagesByRegion.ts
new file mode 100644
index 000000000..8df96a337
--- /dev/null
+++ b/hooks/useLanguagesByRegion.ts
@@ -0,0 +1,89 @@
+import { system } from '@/db/powersync/system';
+import { useHybridData } from '@/views/new/useHybridData';
+
+export interface Languoid {
+ id: string;
+ name: string;
+ level: 'family' | 'language' | 'dialect';
+ parent_id: string | null;
+ active: boolean;
+ created_at: string;
+ last_updated: string;
+}
+
+export interface LanguageByRegion extends Languoid {
+ region_id: string;
+ region_name: string;
+}
+
+/**
+ * Hook to query languoids filtered by region
+ * Uses the languoid_region join table to connect languoids to regions
+ */
+export function useLanguagesByRegion(regionId: string | null) {
+ return useHybridData({
+ dataType: 'languages-by-region',
+ queryKeyParams: [regionId || ''],
+
+ // Note: languoid and languoid_region tables don't exist in local SQLite
+ // Use a dummy offline query that returns empty array
+ offlineQuery: `SELECT '' as id, '' as name, '' as level, '' as parent_id, '' as region_id, '' as region_name, 0 as active, '' as created_at, '' as last_updated WHERE 1 = 0`,
+
+ // Cloud query - fetch languoids filtered by region
+ cloudQueryFn: async () => {
+ if (!regionId) return [];
+
+ // Query languoids via languoid_region join
+ const { data, error } = await system.supabaseConnector.client
+ .from('languoid_region')
+ .select(
+ `
+ languoid_id,
+ region_id,
+ languoid:languoid_id (
+ id,
+ name,
+ level,
+ parent_id,
+ active,
+ created_at,
+ last_updated
+ ),
+ region:region_id (
+ name
+ )
+ `
+ )
+ .eq('region_id', regionId)
+ .eq('active', true);
+
+ if (error) throw error;
+
+ // Transform the nested data structure
+ const languages: LanguageByRegion[] = (data || []).map((item: any) => {
+ const languoid = item.languoid;
+ const region = item.region;
+
+ return {
+ id: languoid.id,
+ name: languoid.name,
+ level: languoid.level,
+ parent_id: languoid.parent_id,
+ active: languoid.active,
+ created_at: languoid.created_at,
+ last_updated: languoid.last_updated,
+ region_id: item.region_id,
+ region_name: region?.name || ''
+ };
+ });
+
+ // Sort by name
+ languages.sort((a, b) => a.name.localeCompare(b.name));
+
+ return languages;
+ },
+
+ enableCloudQuery: !!regionId,
+ enableOfflineQuery: false // Languoid tables don't exist locally
+ });
+}
diff --git a/hooks/useMicrophoneEnergy.ts b/hooks/useMicrophoneEnergy.ts
index 7d0bfee6b..3e25b4309 100644
--- a/hooks/useMicrophoneEnergy.ts
+++ b/hooks/useMicrophoneEnergy.ts
@@ -50,7 +50,9 @@ export function useMicrophoneEnergy(): UseMicrophoneEnergy {
console.error('❌ [JS] Microphone energy error received from native:');
console.error(' Message:', error.message);
if (error.code !== undefined) {
- console.error(` Code: ${error.code}, Domain: ${error.domain ?? 'unknown'}`);
+ console.error(
+ ` Code: ${error.code}, Domain: ${error.domain ?? 'unknown'}`
+ );
}
console.error(' Full error object:', error);
setState((prev) => ({ ...prev, error: error.message }));
diff --git a/hooks/useProjectsByLanguage.ts b/hooks/useProjectsByLanguage.ts
new file mode 100644
index 000000000..556a7ebc8
--- /dev/null
+++ b/hooks/useProjectsByLanguage.ts
@@ -0,0 +1,136 @@
+import { project, profile_project_link } from '@/db/drizzleSchema';
+import { system } from '@/db/powersync/system';
+import { useHybridData } from '@/views/new/useHybridData';
+import { toCompilableQuery } from '@powersync/drizzle-driver';
+import { and, eq, getTableColumns, notInArray, or, sql } from 'drizzle-orm';
+import { useAuth } from '@/contexts/AuthContext';
+import { useUserRestrictions } from '@/hooks/db/useBlocks';
+import { useLocalStore } from '@/store/localStore';
+
+type Project = typeof project.$inferSelect;
+
+/**
+ * Hook to query projects filtered by target language ID
+ * Excludes projects the user is already a member of
+ * Respects user's privacy settings and blocked content/users
+ */
+export function useProjectsByLanguage(languageId: string | null) {
+ const { currentUser } = useAuth();
+ const userId = currentUser?.id;
+
+ const showInvisibleContent = useLocalStore(
+ (state) => state.showHiddenContent
+ );
+
+ const { data: restrictions } = useUserRestrictions(
+ 'project',
+ true,
+ true,
+ false
+ );
+
+ const blockContentIds = (restrictions.blockedContentIds ?? []).map(
+ (c) => c.content_id
+ );
+ const blockUserIds = (restrictions.blockedUserIds ?? []).map(
+ (c) => c.blocked_id
+ );
+
+ return useHybridData({
+ dataType: 'projects-by-language',
+ queryKeyParams: [
+ languageId || '',
+ userId || '',
+ showInvisibleContent ? 'show-hidden' : ''
+ ],
+
+ // Offline query - get projects by target language
+ offlineQuery: toCompilableQuery(
+ system.db
+ .select({
+ ...getTableColumns(project)
+ })
+ .from(project)
+ .where(
+ and(
+ ...[
+ languageId
+ ? eq(project.target_language_id, languageId)
+ : undefined,
+ eq(project.active, true),
+ // Exclude projects user is already a member of
+ userId &&
+ sql`NOT EXISTS (
+ SELECT 1 FROM ${profile_project_link}
+ WHERE ${profile_project_link.project_id} = ${project.id}
+ AND ${profile_project_link.profile_id} = ${userId}
+ AND ${profile_project_link.active} = 1
+ )`,
+ // Visibility filter
+ or(
+ !showInvisibleContent ? eq(project.visible, true) : undefined,
+ userId ? eq(project.creator_id, userId) : undefined
+ ),
+ // Blocked users filter
+ blockUserIds.length > 0 &&
+ notInArray(project.creator_id, blockUserIds),
+ // Blocked content filter
+ blockContentIds.length > 0 &&
+ notInArray(project.id, blockContentIds)
+ ].filter(Boolean)
+ )
+ )
+ ),
+
+ // Cloud query - get projects by target language
+ cloudQueryFn: async () => {
+ if (!languageId) return [];
+
+ // Get projects user is already a member of (to exclude)
+ const userProjectIds = userId
+ ? await system.supabaseConnector.client
+ .from('profile_project_link')
+ .select('project_id')
+ .eq('profile_id', userId)
+ .eq('active', true)
+ .then(({ data }) => data?.map((p) => p.project_id) || [])
+ : [];
+
+ let query = system.supabaseConnector.client
+ .from('project')
+ .select('*')
+ .eq('target_language_id', languageId)
+ .eq('active', true);
+
+ // Exclude projects user is already a member of
+ if (userProjectIds.length > 0) {
+ query = query.not('id', 'in', `(${userProjectIds.join(',')})`);
+ }
+
+ // Visibility filter
+ if (!showInvisibleContent) {
+ query = query.eq('visible', true);
+ }
+
+ // Blocked users filter
+ if (blockUserIds.length > 0) {
+ query = query.or(
+ `creator_id.is.null,creator_id.not.in.(${blockUserIds.join(',')})`
+ );
+ }
+
+ // Blocked content filter
+ if (blockContentIds.length > 0) {
+ query = query.not('id', 'in', `(${blockContentIds.join(',')})`);
+ }
+
+ const { data, error } = await query.overrideTypes();
+
+ if (error) throw error;
+ return data || [];
+ },
+
+ enableCloudQuery: !!languageId,
+ enableOfflineQuery: !!languageId
+ });
+}
diff --git a/hooks/useRegions.ts b/hooks/useRegions.ts
new file mode 100644
index 000000000..2a2bc5bc2
--- /dev/null
+++ b/hooks/useRegions.ts
@@ -0,0 +1,45 @@
+import { system } from '@/db/powersync/system';
+import { useHybridData } from '@/views/new/useHybridData';
+
+export interface Region {
+ id: string;
+ name: string;
+ level: 'continent' | 'nation' | 'subnational';
+ parent_id: string | null;
+ active: boolean;
+ created_at: string;
+ last_updated: string;
+}
+
+/**
+ * Hook to query regions from the region table
+ * Filters by level (continent or nation)
+ */
+export function useRegions(
+ levels: ('continent' | 'nation')[] = ['continent', 'nation']
+) {
+ return useHybridData({
+ dataType: 'regions',
+ queryKeyParams: [levels.join(',')],
+
+ // Note: region table doesn't exist in local SQLite, so we'll only query cloud
+ // Use a dummy offline query that returns empty array
+ offlineQuery: `SELECT '' as id, '' as name, '' as level, '' as parent_id, 0 as active, '' as created_at, '' as last_updated WHERE 1 = 0`,
+
+ // Cloud query - fetch regions from Supabase
+ cloudQueryFn: async () => {
+ const { data, error } = await system.supabaseConnector.client
+ .from('region')
+ .select('*')
+ .in('level', levels)
+ .eq('active', true)
+ .order('name');
+
+ if (error) throw error;
+ return data as Region[];
+ },
+
+ enableCloudQuery: true,
+ enableOfflineQuery: false // Region table doesn't exist locally
+ });
+}
diff --git a/package-lock.json b/package-lock.json
index 8cc216869..ff94bd3a1 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "langquest",
- "version": "2.0.0",
+ "version": "2.0.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "langquest",
- "version": "2.0.0",
+ "version": "2.0.1",
"dependencies": {
"@azure/core-asynciterator-polyfill": "^1.0.2",
"@expo-google-fonts/noto-sans": "^0.4.2",
@@ -85,6 +85,7 @@
"react-native": "0.79.5",
"react-native-element-dropdown": "2.12.2",
"react-native-gesture-handler": "~2.24.0",
+ "react-native-onboarding": "^1.0.6",
"react-native-pager-view": "^7.0.0",
"react-native-reanimated": "~4.1.0",
"react-native-safe-area-context": "5.4.0",
@@ -9229,6 +9230,38 @@
"integrity": "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==",
"license": "MIT"
},
+ "node_modules/assign-deep": {
+ "version": "0.4.8",
+ "resolved": "https://registry.npmjs.org/assign-deep/-/assign-deep-0.4.8.tgz",
+ "integrity": "sha512-uxqXJCnNZDEjPnsaLKVzmh/ST5+Pqoz0wi06HDfHKx1ASNpSbbvz2qW2Gl8ZyHwr5jnm11X2S5eMQaP1lMZmCg==",
+ "license": "MIT",
+ "dependencies": {
+ "assign-symbols": "^0.1.1",
+ "is-primitive": "^2.0.0",
+ "kind-of": "^5.0.2"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/assign-deep/node_modules/kind-of": {
+ "version": "5.1.0",
+ "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-5.1.0.tgz",
+ "integrity": "sha512-NGEErnH6F2vUuXDh+OlbcKW7/wOcfdRHaZ7VWtqCztfHri/++YKmP51OdWeGPuqCOba6kk2OTe5d02VmTB80Pw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/assign-symbols": {
+ "version": "0.1.1",
+ "resolved": "https://registry.npmjs.org/assign-symbols/-/assign-symbols-0.1.1.tgz",
+ "integrity": "sha512-gwzH8QS/GV4pQsf6XOrlpBC6aDE8uJeZvymbEJ0W9TuDYqYOZc4RodvKDH98HCc+KFPYil1kD2XT0X0JWeOzQg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
"node_modules/ast-types": {
"version": "0.15.2",
"resolved": "https://registry.npmjs.org/ast-types/-/ast-types-0.15.2.tgz",
@@ -16214,6 +16247,15 @@
"integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==",
"license": "MIT"
},
+ "node_modules/is-primitive": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/is-primitive/-/is-primitive-2.0.0.tgz",
+ "integrity": "sha512-N3w1tFaRfk3UrPfqeRyD+GYDASU3W5VinKhlORy8EWVf/sIdDL9GAcew85XmktCfH+ngG7SRXEVDoO18WMdB/Q==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
"node_modules/is-regex": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz",
@@ -21125,6 +21167,16 @@
"react-native": "*"
}
},
+ "node_modules/react-native-onboarding": {
+ "version": "1.0.6",
+ "resolved": "https://registry.npmjs.org/react-native-onboarding/-/react-native-onboarding-1.0.6.tgz",
+ "integrity": "sha512-vI/UlspW2N2Y+Q7jEAPskRXqgsJcdn48SDXU8sI91Tnc5xL/EkOQukrIxthSVIt10CRel++LgJws+B6bGk7Z2A==",
+ "license": "MIT",
+ "dependencies": {
+ "assign-deep": "^0.4.5",
+ "react-native-swiper": "git+https://github.com/FuYaoDe/react-native-swiper.git"
+ }
+ },
"node_modules/react-native-pager-view": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/react-native-pager-view/-/react-native-pager-view-7.0.0.tgz",
@@ -21220,6 +21272,14 @@
"react-native-svg": ">=12.0.0"
}
},
+ "node_modules/react-native-swiper": {
+ "version": "1.4.4",
+ "resolved": "git+ssh://git@github.com/FuYaoDe/react-native-swiper.git#a6417ff41a8b2d490bdcc4541015ab35736d4595",
+ "license": "ISC",
+ "dependencies": {
+ "react-timer-mixin": "^0.13.3"
+ }
+ },
"node_modules/react-native-url-polyfill": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/react-native-url-polyfill/-/react-native-url-polyfill-2.0.0.tgz",
@@ -21489,6 +21549,12 @@
"react": "^19.0.0"
}
},
+ "node_modules/react-timer-mixin": {
+ "version": "0.13.4",
+ "resolved": "https://registry.npmjs.org/react-timer-mixin/-/react-timer-mixin-0.13.4.tgz",
+ "integrity": "sha512-4+ow23tp/Tv7hBM5Az5/Be/eKKF7DIvJ09voz5LyHGQaqqz9WV8YMs31eFvcYQs7d451LSg7kDJV70XYN/Ug/Q==",
+ "license": "MIT"
+ },
"node_modules/read-cache": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz",
diff --git a/package.json b/package.json
index 4335093c7..23b3eeab3 100644
--- a/package.json
+++ b/package.json
@@ -126,6 +126,7 @@
"react-native": "0.79.5",
"react-native-element-dropdown": "2.12.2",
"react-native-gesture-handler": "~2.24.0",
+ "react-native-onboarding": "^1.0.6",
"react-native-pager-view": "^7.0.0",
"react-native-reanimated": "~4.1.0",
"react-native-safe-area-context": "5.4.0",
diff --git a/services/localizations.ts b/services/localizations.ts
index 8a2298ee6..ff13f021a 100644
--- a/services/localizations.ts
+++ b/services/localizations.ts
@@ -906,6 +906,160 @@ export const localizations = {
tok_pisin: 'Plis makim wanpela tokples',
indonesian: 'Silakan pilih bahasa'
},
+ selectRegion: {
+ english: 'Select Region',
+ spanish: 'Seleccionar Región',
+ brazilian_portuguese: 'Selecionar Região',
+ tok_pisin: 'Makim Region',
+ indonesian: 'Pilih Wilayah'
+ },
+ selectRegionToFilterLanguages: {
+ english: 'Select a region to see languages from that area',
+ spanish: 'Seleccione una región para ver idiomas de esa área',
+ brazilian_portuguese: 'Selecionar uma região para ver idiomas dessa área',
+ tok_pisin: 'Makim wanpela region long lukim ol tokples bilong ples ya',
+ indonesian: 'Pilih wilayah untuk melihat bahasa dari area tersebut'
+ },
+ selectYourLanguage: {
+ english: 'Select Your Language',
+ spanish: 'Seleccione Su Idioma',
+ brazilian_portuguese: 'Selecionar Seu Idioma',
+ tok_pisin: 'Makim Tokples Bilong Yu',
+ indonesian: 'Pilih Bahasa Anda'
+ },
+ createLanguage: {
+ english: 'Create Language',
+ spanish: 'Crear Idioma',
+ brazilian_portuguese: 'Criar Idioma',
+ tok_pisin: 'Mekim Tokples',
+ indonesian: 'Buat Bahasa'
+ },
+ createNewLanguage: {
+ english: 'Create New Language',
+ spanish: 'Crear Nuevo Idioma',
+ brazilian_portuguese: 'Criar Novo Idioma',
+ tok_pisin: 'Mekim Nupela Tokples',
+ indonesian: 'Buat Bahasa Baru'
+ },
+ languageNotInList: {
+ english: 'My language is not in the list',
+ spanish: 'Mi idioma no está en la lista',
+ brazilian_portuguese: 'Meu idioma não está na lista',
+ tok_pisin: 'Tokples bilong mi i no stap long list',
+ indonesian: 'Bahasa saya tidak ada dalam daftar'
+ },
+ willCreateLanguage: {
+ english: 'Will create language',
+ spanish: 'Creará idioma',
+ brazilian_portuguese: 'Criará idioma',
+ tok_pisin: 'Bai mekim tokples',
+ indonesian: 'Akan membuat bahasa'
+ },
+ nativeName: {
+ english: 'Native Name',
+ spanish: 'Nombre Nativo',
+ brazilian_portuguese: 'Nome Nativo',
+ tok_pisin: 'Nem Bilong Tokples',
+ indonesian: 'Nama Asli'
+ },
+ englishName: {
+ english: 'English Name',
+ spanish: 'Nombre en Inglés',
+ brazilian_portuguese: 'Nome em Inglês',
+ tok_pisin: 'Nem Long English',
+ indonesian: 'Nama dalam Bahasa Inggris'
+ },
+ iso6393Code: {
+ english: 'ISO 639-3 Code',
+ spanish: 'Código ISO 639-3',
+ brazilian_portuguese: 'Código ISO 639-3',
+ tok_pisin: 'ISO 639-3 Code',
+ indonesian: 'Kode ISO 639-3'
+ },
+ locale: {
+ english: 'Locale',
+ spanish: 'Idioma',
+ brazilian_portuguese: 'Idioma',
+ tok_pisin: 'Locale',
+ indonesian: 'Lokalisasi'
+ },
+ createAndContinue: {
+ english: 'Create and Continue',
+ spanish: 'Crear y Continuar',
+ brazilian_portuguese: 'Criar e Continuar',
+ tok_pisin: 'Mekim na Go Long',
+ indonesian: 'Buat dan Lanjutkan'
+ },
+ whatWouldYouLikeToCreate: {
+ english: 'What would you like to create?',
+ spanish: '¿Qué te gustaría crear?',
+ brazilian_portuguese: 'O que você gostaria de criar?',
+ tok_pisin: 'Wanem samting yu laik mekim?',
+ indonesian: 'Apa yang ingin Anda buat?'
+ },
+ createBibleProject: {
+ english: 'Bible',
+ spanish: 'Biblia',
+ brazilian_portuguese: 'Bíblia',
+ tok_pisin: 'Baibel',
+ indonesian: 'Alkitab'
+ },
+ translateBibleIntoYourLanguage: {
+ english: 'Translate the Bible into your language',
+ spanish: 'Traduce la Biblia a tu idioma',
+ brazilian_portuguese: 'Traduza a Bíblia para o seu idioma',
+ tok_pisin: 'Translate Baibel long tokples bilong yu',
+ indonesian: 'Terjemahkan Alkitab ke bahasa Anda'
+ },
+ createOtherProject: {
+ english: 'Other Translation',
+ spanish: 'Otra Traducción',
+ brazilian_portuguese: 'Outra Tradução',
+ tok_pisin: 'Narapela Translation',
+ indonesian: 'Terjemahan Lain'
+ },
+ createGeneralTranslationProject: {
+ english: 'Create a general translation project',
+ spanish: 'Crear un proyecto de traducción general',
+ brazilian_portuguese: 'Criar um projeto de tradução geral',
+ tok_pisin: 'Mekim wanpela project long translate ol samting',
+ indonesian: 'Buat proyek terjemahan umum'
+ },
+ selectProject: {
+ english: 'Select Project',
+ spanish: 'Seleccionar Proyecto',
+ brazilian_portuguese: 'Selecionar Projeto',
+ tok_pisin: 'Makim Project',
+ indonesian: 'Pilih Proyek'
+ },
+ createFirstProject: {
+ english: 'Create First Project',
+ spanish: 'Crear Primer Proyecto',
+ brazilian_portuguese: 'Criar Primeiro Projeto',
+ tok_pisin: 'Mekim Nambawan Project',
+ indonesian: 'Buat Proyek Pertama'
+ },
+ createNewProject: {
+ english: 'Create New Project',
+ spanish: 'Crear Nuevo Proyecto',
+ brazilian_portuguese: 'Criar Novo Projeto',
+ tok_pisin: 'Mekim Nupela Project',
+ indonesian: 'Buat Proyek Baru'
+ },
+ existingProjectsInLanguage: {
+ english: 'Existing projects in {language}',
+ spanish: 'Proyectos existentes en {language}',
+ brazilian_portuguese: 'Projetos existentes em {language}',
+ tok_pisin: 'Ol project i stap pinis long {language}',
+ indonesian: 'Proyek yang ada dalam {language}'
+ },
+ noProjectsInLanguage: {
+ english: 'No projects yet in {language}',
+ spanish: 'Aún no hay proyectos en {language}',
+ brazilian_portuguese: 'Ainda não há projetos em {language}',
+ tok_pisin: 'I no gat project yet long {language}',
+ indonesian: 'Belum ada proyek dalam {language}'
+ },
searchLanguages: {
english: 'Search languages...',
spanish: 'Buscar idiomas...',
@@ -920,6 +1074,18 @@ export const localizations = {
tok_pisin: 'I no gat tokples',
indonesian: 'Tidak ada bahasa ditemukan'
},
+ noLanguagesInRegion: {
+ english:
+ 'No languages found in this region. You can create a new language below.',
+ spanish:
+ 'No se encontraron idiomas en esta región. Puedes crear un nuevo idioma a continuación.',
+ brazilian_portuguese:
+ 'Nenhum idioma encontrado nesta região. Você pode criar um novo idioma abaixo.',
+ tok_pisin:
+ 'I no gat tokples long dispela region. Yu ken mekim nupela tokples long bihain.',
+ indonesian:
+ 'Tidak ada bahasa ditemukan di wilayah ini. Anda dapat membuat bahasa baru di bawah ini.'
+ },
typeToSearch: {
english: 'Type at least {min} characters to search',
spanish: 'Escriba al menos {min} caracteres para buscar',
@@ -1060,6 +1226,13 @@ export const localizations = {
tok_pisin: 'Welkam bek, hero!',
indonesian: 'Selamat datang kembali, pahlawan!'
},
+ welcomeToApp: {
+ english: 'Welcome',
+ spanish: 'Bienvenido',
+ brazilian_portuguese: 'Bem-vindo',
+ tok_pisin: 'Welkam',
+ indonesian: 'Selamat datang'
+ },
recentlyVisited: {
english: 'Recently Visited',
spanish: 'Recientemente visitado',
@@ -2183,7 +2356,14 @@ export const localizations = {
english: 'Go Back',
spanish: 'Volver',
brazilian_portuguese: 'Voltar',
- tok_pisin: 'Go bek',
+ tok_pisin: 'Go Bek',
+ indonesian: 'Kembali'
+ },
+ back: {
+ english: 'Back',
+ spanish: 'Atrás',
+ brazilian_portuguese: 'Voltar',
+ tok_pisin: 'Go Bek',
indonesian: 'Kembali'
},
confirmRemove: {
@@ -4546,8 +4726,7 @@ export const localizations = {
'La calibración falló. Por favor, inténtalo de nuevo en un entorno más silencioso.',
brazilian_portuguese:
'Calibração falhou. Por favor, tente novamente em um ambiente mais silencioso.',
- tok_pisin:
- 'Em i no wok. Traim gen long ples i no gat tumas nois.',
+ tok_pisin: 'Em i no wok. Traim gen long ples i no gat tumas nois.',
indonesian:
'Kalibrasi gagal. Silakan coba lagi di lingkungan yang lebih tenang.'
},
@@ -5180,6 +5359,304 @@ export const localizations = {
brazilian_portuguese: 'Não sincronizado',
tok_pisin: 'i no sync yet',
indonesian: 'Tidak disinkronkan'
+ },
+ onboardingCreateProjectTitle: {
+ english: 'Record a Bible, or any other content',
+ spanish: 'Graba una Biblia o cualquier otro contenido',
+ brazilian_portuguese: 'Grave uma Bíblia ou qualquer outro conteúdo',
+ tok_pisin: 'Rekodim Baibel o ol narapela samting',
+ indonesian: 'Rekam Alkitab atau konten lainnya'
+ },
+ onboardingCreateProjectSubtitle: {
+ english: 'Start by creating your first project',
+ spanish: 'Comienza creando tu primer proyecto',
+ brazilian_portuguese: 'Comece criando seu primeiro projeto',
+ tok_pisin: 'Stat long mekim nupela projek',
+ indonesian: 'Mulai dengan membuat proyek pertama Anda'
+ },
+ onboardingCreateProjectExample: {
+ english: 'Stories',
+ spanish: 'Historias',
+ brazilian_portuguese: 'Histórias',
+ tok_pisin: 'Stori',
+ indonesian: 'Cerita'
+ },
+ onboardingCreateProjectDescription: {
+ english: 'Example project name',
+ spanish: 'Nombre de proyecto de ejemplo',
+ brazilian_portuguese: 'Nome do projeto de exemplo',
+ tok_pisin: 'Nem bilong projek olsem',
+ indonesian: 'Nama proyek contoh'
+ },
+ onboardingCreateProject: {
+ english: 'Create Project',
+ spanish: 'Crear Proyecto',
+ brazilian_portuguese: 'Criar Projeto',
+ tok_pisin: 'Mekim Projek',
+ indonesian: 'Buat Proyek'
+ },
+ onboardingCreateQuestTitle: {
+ english: 'Organize your content',
+ spanish: 'Organiza tu contenido',
+ brazilian_portuguese: 'Organize seu conteúdo',
+ tok_pisin: 'Oganaisim samting bilong yu',
+ indonesian: 'Organisir konten Anda'
+ },
+ onboardingCreateQuestSubtitle: {
+ english: 'Add quests to break down your project into manageable pieces',
+ spanish: 'Agrega misiones para dividir tu proyecto en partes manejables',
+ brazilian_portuguese:
+ 'Adicione missões para dividir seu projeto em partes gerenciáveis',
+ tok_pisin: 'Putim kwest long brukim projek i go long liklik hap',
+ indonesian:
+ 'Tambahkan quest untuk membagi proyek Anda menjadi bagian yang dapat dikelola'
+ },
+ onboardingQuestExample1: {
+ english: 'Story 1',
+ spanish: 'Historia 1',
+ brazilian_portuguese: 'História 1',
+ tok_pisin: 'Stori 1',
+ indonesian: 'Cerita 1'
+ },
+ onboardingQuestExample2: {
+ english: 'Story 2',
+ spanish: 'Historia 2',
+ brazilian_portuguese: 'História 2',
+ tok_pisin: 'Stori 2',
+ indonesian: 'Cerita 2'
+ },
+ onboardingCreateQuest: {
+ english: 'Create Quest',
+ spanish: 'Crear Misión',
+ brazilian_portuguese: 'Criar Missão',
+ tok_pisin: 'Mekim Kwest',
+ indonesian: 'Buat Quest'
+ },
+ onboardingRecordAudioTitle: {
+ english: 'Start recording',
+ spanish: 'Comienza a grabar',
+ brazilian_portuguese: 'Comece a gravar',
+ tok_pisin: 'Stat long rekodim',
+ indonesian: 'Mulai merekam'
+ },
+ onboardingRecordAudioSubtitle: {
+ english: 'Hold the button to record, or slide to record anytime you talk',
+ spanish:
+ 'Mantén presionado el botón para grabar, o desliza para grabar cuando hables',
+ brazilian_portuguese:
+ 'Mantenha pressionado o botão para gravar ou deslize para gravar quando falar',
+ tok_pisin:
+ 'Holim button long rekodim, o slipim long rekodim taim yu toktok',
+ indonesian:
+ 'Tahan tombol untuk merekam, atau geser untuk merekam kapan saja Anda berbicara'
+ },
+ onboardingRecordMethod1: {
+ english: 'Hold button to record',
+ spanish: 'Mantén presionado para grabar',
+ brazilian_portuguese: 'Mantenha pressionado para gravar',
+ tok_pisin: 'Holim button long rekodim',
+ indonesian: 'Tahan tombol untuk merekam'
+ },
+ onboardingRecordMethod2: {
+ english: 'Slide to record anytime you talk',
+ spanish: 'Desliza para grabar cuando hables',
+ brazilian_portuguese: 'Deslize para gravar quando falar',
+ tok_pisin: 'Slipim long rekodim taim yu toktok',
+ indonesian: 'Geser untuk merekam kapan saja Anda berbicara'
+ },
+ onboardingStartRecording: {
+ english: 'Start Recording',
+ spanish: 'Comenzar Grabación',
+ brazilian_portuguese: 'Iniciar Gravação',
+ tok_pisin: 'Stat Rekodim',
+ indonesian: 'Mulai Merekam'
+ },
+ onboardingInviteTitle: {
+ english: 'Work together',
+ spanish: 'Trabaja en equipo',
+ brazilian_portuguese: 'Trabalhe juntos',
+ tok_pisin: 'Wok wantaim',
+ indonesian: 'Bekerja bersama'
+ },
+ onboardingInviteSubtitle: {
+ english:
+ "Invite others to collaborate. They'll receive a notification and see your project in their list",
+ spanish:
+ 'Invita a otros a colaborar. Recibirán una notificación y verán tu proyecto en su lista',
+ brazilian_portuguese:
+ 'Convide outros para colaborar. Eles receberão uma notificação e verão seu projeto em sua lista',
+ tok_pisin:
+ 'Singim ol narapela long wok wantaim. Bai ol kisim notis na lukim projek bilong yu long list bilong ol',
+ indonesian:
+ 'Undang orang lain untuk berkolaborasi. Mereka akan menerima notifikasi dan melihat proyek Anda di daftar mereka'
+ },
+ onboardingInviteBenefit1: {
+ english: 'They receive a notification',
+ spanish: 'Reciben una notificación',
+ brazilian_portuguese: 'Eles recebem uma notificação',
+ tok_pisin: 'Ol kisim notis',
+ indonesian: 'Mereka menerima notifikasi'
+ },
+ onboardingInviteBenefit2: {
+ english: 'Project appears in their list',
+ spanish: 'El proyecto aparece en su lista',
+ brazilian_portuguese: 'O projeto aparece em sua lista',
+ tok_pisin: 'Projek i kamap long list bilong ol',
+ indonesian: 'Proyek muncul di daftar mereka'
+ },
+ onboardingInviteCollaborators: {
+ english: 'Invite Collaborators',
+ spanish: 'Invitar Colaboradores',
+ brazilian_portuguese: 'Convidar Colaboradores',
+ tok_pisin: 'Singim Ol Wokman',
+ indonesian: 'Undang Kolaborator'
+ },
+ onboardingContinue: {
+ english: 'Continue',
+ spanish: 'Continuar',
+ brazilian_portuguese: 'Continuar',
+ tok_pisin: 'Gohet',
+ indonesian: 'Lanjutkan'
+ },
+ onboardingBible: {
+ english: 'Bible',
+ spanish: 'Biblia',
+ brazilian_portuguese: 'Bíblia',
+ tok_pisin: 'Baibel',
+ indonesian: 'Alkitab'
+ },
+ onboardingOther: {
+ english: 'Other',
+ spanish: 'Otro',
+ brazilian_portuguese: 'Outro',
+ tok_pisin: 'Narapela',
+ indonesian: 'Lainnya'
+ },
+ onboardingBibleSelectBookTitle: {
+ english: 'Select a Book',
+ spanish: 'Selecciona un Libro',
+ brazilian_portuguese: 'Selecione um Livro',
+ tok_pisin: 'Pilim Buk',
+ indonesian: 'Pilih Buku'
+ },
+ onboardingBibleSelectBookSubtitle: {
+ english: 'Choose which book of the Bible to translate',
+ spanish: 'Elige qué libro de la Biblia traducir',
+ brazilian_portuguese: 'Escolha qual livro da Bíblia traduzir',
+ tok_pisin: 'Pilim wanpela buk bilong Baibel long tanim',
+ indonesian: 'Pilih buku Alkitab mana yang akan diterjemahkan'
+ },
+ onboardingBibleBookExample1: {
+ english: 'Genesis',
+ spanish: 'Génesis',
+ brazilian_portuguese: 'Gênesis',
+ tok_pisin: 'Jenesis',
+ indonesian: 'Kejadian'
+ },
+ onboardingBibleBookExample2: {
+ english: 'Matthew',
+ spanish: 'Mateo',
+ brazilian_portuguese: 'Mateus',
+ tok_pisin: 'Matyu',
+ indonesian: 'Matius'
+ },
+ onboardingBibleCreateChapterTitle: {
+ english: 'Create Chapter Quests',
+ spanish: 'Crear Quests de Capítulos',
+ brazilian_portuguese: 'Criar Quests de Capítulos',
+ tok_pisin: 'Mekim Ol Kwest bilong Kapitol',
+ indonesian: 'Buat Quest Bab'
+ },
+ onboardingBibleCreateChapterSubtitle: {
+ english: 'Each chapter becomes a quest you can work on',
+ spanish:
+ 'Cada capítulo se convierte en una quest en la que puedes trabajar',
+ brazilian_portuguese:
+ 'Cada capítulo se torna uma quest em que você pode trabalhar',
+ tok_pisin: 'Olgeta kapitol i kamap wanpela kwest yu ken wok long en',
+ indonesian: 'Setiap bab menjadi quest yang dapat Anda kerjakan'
+ },
+ onboardingBibleChapterExample1: {
+ english: 'Chapter 1',
+ spanish: 'Capítulo 1',
+ brazilian_portuguese: 'Capítulo 1',
+ tok_pisin: 'Kapitol 1',
+ indonesian: 'Bab 1'
+ },
+ onboardingBibleChapterExample2: {
+ english: 'Chapter 2',
+ spanish: 'Capítulo 2',
+ brazilian_portuguese: 'Capítulo 2',
+ tok_pisin: 'Kapitol 2',
+ indonesian: 'Bab 2'
+ },
+ onboardingVisionTitle: {
+ english: 'Every language. Every culture.',
+ spanish: 'Cada idioma. Cada cultura.',
+ brazilian_portuguese: 'Cada idioma. Cada cultura.',
+ tok_pisin: 'Olgeta tokples. Olgeta kalsa.',
+ indonesian: 'Setiap bahasa. Setiap budaya.'
+ },
+ onboardingVisionSubtitle: {
+ english:
+ 'Collect text and audio language data quickly. Local-first, sync when connected. Collaborate, translate, validate.',
+ spanish:
+ 'Recopila datos de texto y audio de idiomas rápidamente. Primero local, sincroniza cuando estés conectado. Colabora, traduce, valida.',
+ brazilian_portuguese:
+ 'Colete dados de texto e áudio de idiomas rapidamente. Primeiro local, sincronize quando conectado. Colabore, traduza, valide.',
+ tok_pisin:
+ 'Kisim ol text na audio bilong tokples kwiktaim. Stat long lokal, sync taim yu gat internet. Wok wantaim, tanim tokples, stretim.',
+ indonesian:
+ 'Kumpulkan data bahasa teks dan audio dengan cepat. Lokal pertama, sinkronkan saat terhubung. Berkolaborasi, terjemahkan, validasi.'
+ },
+ onboardingVisionStatement1: {
+ english: "Every language having access to the world's knowledge.",
+ spanish: 'Cada idioma con acceso al conocimiento del mundo.',
+ brazilian_portuguese: 'Cada idioma tendo acesso ao conhecimento do mundo.',
+ tok_pisin: 'Olgeta tokples i gat akses long save bilong wol.',
+ indonesian: 'Setiap bahasa memiliki akses ke pengetahuan dunia.'
+ },
+ onboardingVisionStatement2: {
+ english: 'Every culture sharing its meaning with the world.',
+ spanish: 'Cada cultura compartiendo su significado con el mundo.',
+ brazilian_portuguese:
+ 'Cada cultura compartilhando seu significado com o mundo.',
+ tok_pisin: 'Olgeta kalsa i salim save bilong en i go long wol.',
+ indonesian: 'Setiap budaya berbagi maknanya dengan dunia.'
+ },
+ onboardingVisionCC0: {
+ english: 'CC0/public domain data ensures no party can stop this vision.',
+ spanish:
+ 'Los datos CC0/dominio público garantizan que ninguna parte pueda detener esta visión.',
+ brazilian_portuguese:
+ 'Dados CC0/domínio público garantem que nenhuma parte possa impedir esta visão.',
+ tok_pisin:
+ 'CC0/pablik domain data i mekim olsem wanpela man o grup i no inap stopim dispela visen.',
+ indonesian:
+ 'Data CC0/domain publik memastikan tidak ada pihak yang dapat menghentikan visi ini.'
+ },
+ onboardingOurVision: {
+ english: 'Our Vision',
+ spanish: 'Nuestra Visión',
+ brazilian_portuguese: 'Nossa Visão',
+ tok_pisin: 'Visen Bilong Mipela',
+ indonesian: 'Visi Kami'
+ },
+ onboardingSelectLanguageTitle: {
+ english: 'Choose Your Language',
+ spanish: 'Elige Tu Idioma',
+ brazilian_portuguese: 'Escolha Seu Idioma',
+ tok_pisin: 'Pilim Tokples Bilong Yu',
+ indonesian: 'Pilih Bahasa Anda'
+ },
+ onboardingSelectLanguageSubtitle: {
+ english: "Select the language you'd like to use for the app interface",
+ spanish:
+ 'Selecciona el idioma que deseas usar para la interfaz de la aplicación',
+ brazilian_portuguese:
+ 'Selecione o idioma que deseja usar para a interface do aplicativo',
+ tok_pisin: 'Pilim tokples yu laikim long yusim long app',
+ indonesian: 'Pilih bahasa yang ingin Anda gunakan untuk antarmuka aplikasi'
}
} as const;
diff --git a/store/localStore.ts b/store/localStore.ts
index c5b9c9b4f..0f9a943eb 100644
--- a/store/localStore.ts
+++ b/store/localStore.ts
@@ -149,6 +149,16 @@ export interface LocalState {
dismissUpdate: (version: string) => void;
resetUpdateDismissal: () => void;
+ // Onboarding dismissal tracking
+ onboardingDismissed: boolean;
+ setOnboardingDismissed: (dismissed: boolean) => void;
+ onboardingCompleted: boolean;
+ setOnboardingCompleted: (completed: boolean) => void;
+ triggerOnboarding: boolean;
+ setTriggerOnboarding: (trigger: boolean) => void;
+ onboardingIsOpen: boolean;
+ setOnboardingIsOpen: (isOpen: boolean) => void;
+
setProjectSourceFilter: (filter: string) => void;
setProjectTargetFilter: (filter: string) => void;
setAnalyticsOptOut: (optOut: boolean) => void;
@@ -261,6 +271,18 @@ export const useLocalStore = create()(
dismissedUpdateVersion: null
}),
+ // Onboarding dismissal tracking
+ onboardingDismissed: false,
+ setOnboardingDismissed: (dismissed) =>
+ set({ onboardingDismissed: dismissed }),
+ onboardingCompleted: false,
+ setOnboardingCompleted: (completed) =>
+ set({ onboardingCompleted: completed }),
+ triggerOnboarding: false,
+ setTriggerOnboarding: (trigger) => set({ triggerOnboarding: trigger }),
+ onboardingIsOpen: false,
+ setOnboardingIsOpen: (isOpen) => set({ onboardingIsOpen: isOpen }),
+
setAnalyticsOptOut: (optOut) => set({ analyticsOptOut: optOut }),
setTheme: (theme) => {
set({ theme });
diff --git a/utils/fileUtils.ts b/utils/fileUtils.ts
index fa0e78612..779c0e9c1 100644
--- a/utils/fileUtils.ts
+++ b/utils/fileUtils.ts
@@ -22,11 +22,15 @@ export function getDirectory(uri: string) {
if (uri.startsWith('file://')) {
const pathWithoutProtocol = uri.replace(/^file:\/\//, ''); // Remove file:// or file:///
// Remove any trailing slashes or path components like '/..'
- const cleanPath = pathWithoutProtocol.replace(/\/\.\.?\/?$/, '').replace(/\/+$/, '');
+ const cleanPath = pathWithoutProtocol
+ .replace(/\/\.\.?\/?$/, '')
+ .replace(/\/+$/, '');
if (!cleanPath || cleanPath === '') {
return 'file:///';
}
- const parts = cleanPath.split('/').filter(p => p !== '' && p !== '.' && p !== '..');
+ const parts = cleanPath
+ .split('/')
+ .filter((p) => p !== '' && p !== '.' && p !== '..');
if (parts.length === 0) {
return 'file:///';
}
@@ -35,7 +39,9 @@ export function getDirectory(uri: string) {
}
// Remove any trailing slashes or path components like '/..'
const cleanPath = uri.replace(/\/\.\.?\/?$/, '').replace(/\/+$/, '');
- const parts = cleanPath.split('/').filter(p => p !== '' && p !== '.' && p !== '..');
+ const parts = cleanPath
+ .split('/')
+ .filter((p) => p !== '' && p !== '.' && p !== '..');
if (parts.length === 0) {
return '/';
}
@@ -86,7 +92,7 @@ export async function writeFile(
*/
export function normalizeFileUri(uri: string): string {
let normalized = uri.trim();
-
+
// Handle file:// URIs
if (normalized.startsWith('file://')) {
// Normalize to file:/// (three slashes) if needed
@@ -95,13 +101,13 @@ export function normalizeFileUri(uri: string): string {
} else if (!normalized.startsWith('file:///')) {
normalized = normalized.replace(/^file:\/\//, 'file:///');
}
-
+
// Remove path traversal components (/.. and /.)
normalized = normalized.replace(/\/\.\.?(\/|$)/g, '/');
-
+
// Remove trailing slashes (but keep file:///)
normalized = normalized.replace(/\/+$/, '');
-
+
// Remove double slashes in the path part (after file:///)
normalized = normalized.replace(/file:\/\/(.+)/, (match, path) => {
const cleanPath = path.replace(/\/+/g, '/');
@@ -114,7 +120,7 @@ export function normalizeFileUri(uri: string): string {
normalized = normalized.replace(/\/+$/, '');
normalized = normalized.replace(/\/\/+/g, '/');
}
-
+
return normalized;
}
@@ -122,11 +128,11 @@ export async function moveFile(sourceUri: string, targetUri: string) {
// On iOS Simulator, FileSystem.moveAsync has a bug where it appends /.. to paths
// Workaround: Use copy + delete instead of move
// This is more reliable across platforms and avoids the simulator bug
-
+
// Normalize both URIs to ensure they're properly formatted
let fromUri = normalizeFileUri(sourceUri);
let toUri = normalizeFileUri(targetUri);
-
+
try {
// Try moveAsync first (faster on real devices)
await FileSystem.moveAsync({ from: fromUri, to: toUri });
@@ -144,7 +150,8 @@ export async function moveFile(sourceUri: string, targetUri: string) {
} catch (deleteError) {
// If deletion fails (common on iOS Simulator with temp files), log but don't fail
// Temp files will be cleaned up automatically by the OS
- const isTempFile = deleteUri.includes('/tmp/') || deleteUri.includes('/tmp');
+ const isTempFile =
+ deleteUri.includes('/tmp/') || deleteUri.includes('/tmp');
if (isTempFile) {
console.log(
`⚠️ Failed to delete temp file (will be cleaned up automatically): ${deleteUri}`
@@ -229,25 +236,25 @@ export async function saveAudioLocally(uri: string) {
// Normalize the source URI - remove any path traversal components and trailing slashes
// Handle both file:// URIs and regular paths
const cleanSourceUri = normalizeFileUri(uri);
-
+
// Extract extension before further processing
const extension = cleanSourceUri.split('.').pop() || 'wav';
-
+
const newUri = `local/${uuid.v4()}.${extension}`;
console.log('🔍 Saving audio file locally:', cleanSourceUri, newUri);
-
+
// Retry logic for file existence - iOS Simulator can have timing issues
// where the file isn't immediately available after Swift writes it
const maxRetries = 5;
const retryDelayMs = 100;
let fileExistsNow = false;
-
+
for (let attempt = 0; attempt < maxRetries; attempt++) {
fileExistsNow = await fileExists(cleanSourceUri);
if (fileExistsNow) {
break;
}
-
+
if (attempt < maxRetries - 1) {
console.log(
`⏳ File not found yet (attempt ${attempt + 1}/${maxRetries}), retrying in ${retryDelayMs}ms...`
@@ -255,7 +262,7 @@ export async function saveAudioLocally(uri: string) {
await new Promise((resolve) => setTimeout(resolve, retryDelayMs));
}
}
-
+
if (!fileExistsNow) {
// Get file info for better error message
const fileInfo = await getFileInfo(cleanSourceUri);
@@ -263,10 +270,10 @@ export async function saveAudioLocally(uri: string) {
console.error('❌', errorMsg);
throw new Error(errorMsg);
}
-
+
const newPath = getLocalUri(getLocalFilePathSuffix(newUri));
await ensureDir(getDirectory(newPath));
-
+
// Debug: Log the URIs before moving
console.log('📦 Moving file:', {
from: cleanSourceUri,
@@ -274,7 +281,7 @@ export async function saveAudioLocally(uri: string) {
fromLength: cleanSourceUri.length,
toLength: newPath.length
});
-
+
try {
await moveFile(cleanSourceUri, newPath);
} catch (error) {
@@ -288,7 +295,7 @@ export async function saveAudioLocally(uri: string) {
});
throw error;
}
-
+
console.log(
'✅ Audio file saved locally:',
`${getLocalUri(getLocalFilePathSuffix('local'))}/${newUri}`
diff --git a/views/AppView.tsx b/views/AppView.tsx
index 3690c668a..b5597fb43 100644
--- a/views/AppView.tsx
+++ b/views/AppView.tsx
@@ -40,6 +40,11 @@ const NextGenProjectsView = React.lazy(
const ProjectDirectoryView = React.lazy(
() => import('@/views/new/ProjectDirectoryView')
);
+const SimpleOnboardingFlow = React.lazy(() =>
+ import('@/views/new/SimpleOnboardingFlow').then((module) => ({
+ default: module.SimpleOnboardingFlow
+ }))
+);
// Common UI Components
import AppDrawer from '@/components/AppDrawer';
@@ -62,10 +67,57 @@ function AppViewContent() {
const { isAuthenticated } = useAuth();
const authView = useLocalStore((state) => state.authView);
const setAuthView = useLocalStore((state) => state.setAuthView);
+ const setTriggerOnboarding = useLocalStore(
+ (state) => state.setTriggerOnboarding
+ );
+ const dateTermsAccepted = useLocalStore((state) => state.dateTermsAccepted);
+ const triggerOnboarding = useLocalStore((state) => state.triggerOnboarding);
+ const onboardingIsOpen = useLocalStore((state) => state.onboardingIsOpen);
+ const setOnboardingIsOpen = useLocalStore(
+ (state) => state.setOnboardingIsOpen
+ );
const [drawerIsVisible, setDrawerIsVisible] = useState(false);
const [deferredView, setDeferredView] = useState(currentView);
const { isCloudLoading } = useCloudLoading();
+ // Handler for onboarding button in header (only show on projects view)
+ const handleOnboardingPress = React.useCallback(() => {
+ if (currentView === 'projects') {
+ setTriggerOnboarding(true);
+ }
+ }, [currentView, setTriggerOnboarding]);
+
+ // Show onboarding AFTER terms are accepted (one-time walkthrough)
+ // This ensures users see the walkthrough after accepting terms
+ const onboardingCompleted = useLocalStore(
+ (state) => state.onboardingCompleted
+ );
+ React.useEffect(() => {
+ // Show onboarding if terms are accepted but onboarding hasn't been completed
+ // Only set flag if not already open to prevent duplicate modals
+ if (dateTermsAccepted && !onboardingCompleted && !onboardingIsOpen) {
+ setOnboardingIsOpen(true);
+ }
+ }, [
+ dateTermsAccepted,
+ onboardingCompleted,
+ onboardingIsOpen,
+ setOnboardingIsOpen
+ ]);
+
+ // Watch for trigger from AppHeader
+ React.useEffect(() => {
+ if (triggerOnboarding && !onboardingIsOpen) {
+ setOnboardingIsOpen(true);
+ setTriggerOnboarding(false);
+ }
+ }, [
+ triggerOnboarding,
+ setTriggerOnboarding,
+ onboardingIsOpen,
+ setOnboardingIsOpen
+ ]);
+
// Memoize drawer toggle callback to prevent AppHeader re-renders
const drawerToggleCallback = React.useCallback(
() => setDrawerIsVisible((prev) => !prev),
@@ -186,6 +238,9 @@ function AppViewContent() {
drawerToggleCallback={drawerToggleCallback}
isCloudLoading={isCloudLoading}
isNavigating={isNavigating}
+ onOnboardingPress={
+ currentView === 'projects' ? handleOnboardingPress : undefined
+ }
/>
{/* Debug Controls (DEV only) - uncomment to test OTA updates */}
@@ -209,6 +264,12 @@ function AppViewContent() {
onClose={() => setAuthView(null)}
/>
)}
+
+ {/* Onboarding Flow - shows globally until terms are accepted */}
+ setOnboardingIsOpen(false)}
+ />
diff --git a/views/new/NextGenProjectsView.tsx b/views/new/NextGenProjectsView.tsx
index 11de84aa8..d739e35f8 100644
--- a/views/new/NextGenProjectsView.tsx
+++ b/views/new/NextGenProjectsView.tsx
@@ -8,7 +8,9 @@ import { LayerType, useStatusContext } from '@/contexts/StatusContext';
import { invite, profile_project_link, project } from '@/db/drizzleSchema';
import { system } from '@/db/powersync/system';
import { useUserRestrictions } from '@/hooks/db/useBlocks';
+import { useAppNavigation } from '@/hooks/useAppNavigation';
import { useLocalization } from '@/hooks/useLocalization';
+import { useLocalStore } from '@/store/localStore';
import { cn, getThemeColor } from '@/utils/styleUtils';
import {
useHybridData,
@@ -63,7 +65,6 @@ import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
import { Switch } from '@/components/ui/switch';
import { Textarea } from '@/components/ui/textarea';
import { templateOptions } from '@/db/constants';
-import { useLocalStore } from '@/store/localStore';
import { resolveTable } from '@/utils/dbUtils';
import { zodResolver } from '@hookform/resolvers/zod';
import { toCompilableQuery } from '@powersync/drizzle-driver';
@@ -297,7 +298,7 @@ export default function NextGenProjectsView() {
// Watch 1: Watch all invites (not just pending) to detect status changes
// This will fire when an invite changes from pending to accepted/declined
- const watch1 = system.powersync.watch(
+ const _watch1 = system.powersync.watch(
`SELECT id, status, active, email, receiver_profile_id, project_id, last_updated FROM invite WHERE (email = ? OR receiver_profile_id = ?)`,
[userEmail || '', userId || ''],
{
@@ -314,7 +315,7 @@ export default function NextGenProjectsView() {
);
// Watch 2: Watch memberships to detect when invites are accepted (creates membership)
- const watch2 = userId
+ const _watch2 = userId
? system.powersync.watch(
`SELECT id, profile_id, project_id, active, membership, last_updated FROM profile_project_link WHERE profile_id = ?`,
[userId],
@@ -549,6 +550,16 @@ export default function NextGenProjectsView() {
!isAuthenticated || activeTab === 'all' ? allProjects : myProjectsQuery;
const { data: projectData, isLoading } = currentQuery;
+ const { goToProject } = useAppNavigation();
+
+ // Get the first project for onboarding navigation
+ const firstProject = React.useMemo(() => {
+ if (Array.isArray(projectData) && projectData.length > 0) {
+ return projectData[0];
+ }
+ return null;
+ }, [projectData]);
+
// Get fetching state for search indicator
const isFetchingProjects = React.useMemo(() => {
// For anonymous users or "all" tab, use allProjects fetching state
@@ -655,306 +666,356 @@ export default function NextGenProjectsView() {
const dimensions = useWindowDimensions();
+ // Handlers for onboarding flow (kept for potential future use)
+ const _handleOnboardingCreateProject = () => {
+ if (currentUser) {
+ setIsCreateOpen(true);
+ } else {
+ // For anonymous users, just advance to next step
+ // They can see the flow but can't actually create projects
+ }
+ };
+
+ const _handleOnboardingCreateQuest = () => {
+ if (firstProject) {
+ goToProject({
+ id: firstProject.id,
+ name: firstProject.name,
+ template: firstProject.template
+ });
+ // The onboarding will close and user can create quest in ProjectDirectoryView
+ }
+ };
+
+ const _handleOnboardingStartRecording = () => {
+ if (firstProject) {
+ // Navigate to project - user can then navigate to a quest and start recording
+ goToProject({
+ id: firstProject.id,
+ name: firstProject.name,
+ template: firstProject.template
+ });
+ // The recording view will be shown when user navigates to a quest
+ }
+ };
+
+ const _handleOnboardingInviteCollaborators = () => {
+ if (firstProject) {
+ goToProject({
+ id: firstProject.id,
+ name: firstProject.name,
+ template: firstProject.template
+ });
+ // User can access project membership modal from project settings
+ }
+ };
+
return (
- {
- setIsCreateOpen(open);
- resetForm();
- }}
- dismissible={!isCreatingProject}
- >
-
-
- {/* Tabs */}
- setActiveTab(v as TabType)}
- >
-
- {isAuthenticated ? (
- <>
-
- {t('myProjects')}
-
-
- {t('allProjects')}
-
- >
- ) : (
- <>
-
- {t('signIn') || 'Sign In'}
-
-
- {t('allProjects')}
-
- >
- )}
-
-
-
- {/* Show login invitation for anonymous users in "my" tab, otherwise show search */}
- {!isAuthenticated && activeTab === 'my' ? (
-
-
-
-
-
- {t('signInToSaveOrContribute') ||
- 'Sign in to save or contribute to projects'}
-
+ <>
+ {
+ setIsCreateOpen(open);
+ resetForm();
+ }}
+ dismissible={!isCreatingProject}
+ >
+
+
+ {/* Tabs */}
+ setActiveTab(v as TabType)}
+ >
+
+ {isAuthenticated ? (
+ <>
+
+ {t('myProjects')}
+
+
+ {t('allProjects')}
+
+ >
+ ) : (
+ <>
+
+ {t('signIn') || 'Sign In'}
+
+
+ {t('allProjects')}
+
+ >
+ )}
+
+
+
+ {/* Show login invitation for anonymous users in "my" tab, otherwise show search */}
+ {!isAuthenticated && activeTab === 'my' ? (
+
+
+
+
+
+ {t('signInToSaveOrContribute') ||
+ 'Sign in to save or contribute to projects'}
+
+
+
+
-
-
-
- {/* Arrow and option to view all projects */}
-
-
- {t('orBrowseAllProjects') || 'Or browse all public projects'}
-
-
-
-
- ) : (
- <>
- {/* Search and filter */}
-
-
- ) : undefined
- }
- suffixStyling={false}
- hitSlop={{ top: 12, bottom: 12, left: 12, right: 12 }}
- />
- {currentUser && (
- setActiveTab('all')}
+ className="flex-row items-center gap-2"
>
-
-
- )}
+ {t('viewAllProjects') || 'View All Projects'}
+
+
+
- >
- )}
-
-
- {/* Show project list only if not showing login invitation */}
- {!isAuthenticated && activeTab === 'my' ? null : isLoading ||
- (isFetchingProjects && searchQuery && data.length === 0) ? (
-
- ) : (
- 768 && data.length > 1 ? 2 : 1}
- keyExtractor={(item) => item.id}
- recycleItems
- estimatedItemSize={175}
- maintainVisibleContentPosition
- renderItem={({ item }) => (
- 768 && 'h-[212px]')}
- />
- )}
- onEndReached={() => {
- if (allProjects.hasNextPage && !allProjects.isFetchingNextPage) {
- allProjects.fetchNextPage();
- }
- }}
- onEndReachedThreshold={0.5}
- ListFooterComponent={() =>
- allProjects.isFetchingNextPage && (
-
-
+ {/* Search and filter */}
+
+
+ ) : undefined
+ }
+ suffixStyling={false}
+ hitSlop={{ top: 12, bottom: 12, left: 12, right: 12 }}
/>
-
- )
- }
- ListEmptyComponent={() => (
-
-
-
- {searchQuery
- ? 'No projects found'
- : activeTab === 'my'
- ? 'No projects yet'
- : 'No projects available'}
-
- {activeTab === 'my' && !searchQuery && (
-
+
+
)}
-
+ >
)}
- />
- )}
-
-
-
-
- {
- return (
+
+
-
-
+
+ createProject(data))}
+ className="flex-row items-center gap-2"
+ >
+ {t('createObject')}
+
+
+ {t('cancel')}
+
+
+
+
+
+ >
);
}
diff --git a/views/new/OnboardingFlow.tsx b/views/new/OnboardingFlow.tsx
new file mode 100644
index 000000000..75abf2223
--- /dev/null
+++ b/views/new/OnboardingFlow.tsx
@@ -0,0 +1,763 @@
+import { LanguageListSkeleton } from '@/components/LanguageListSkeleton';
+import type { OnboardingStep } from '@/components/OnboardingProgressIndicator';
+import { OnboardingProgressIndicator } from '@/components/OnboardingProgressIndicator';
+import { Button } from '@/components/ui/button';
+import { Card } from '@/components/ui/card';
+import { Icon } from '@/components/ui/icon';
+import { Input } from '@/components/ui/input';
+import { Text } from '@/components/ui/text';
+import { useAuth } from '@/contexts/AuthContext';
+import type { language as languageTable } from '@/db/drizzleSchema';
+import { system } from '@/db/powersync/system';
+import { useAppNavigation } from '@/hooks/useAppNavigation';
+import { useLanguagesByRegion } from '@/hooks/useLanguagesByRegion';
+import { useLocalization } from '@/hooks/useLocalization';
+import { useProjectsByLanguage } from '@/hooks/useProjectsByLanguage';
+import { useRegions } from '@/hooks/useRegions';
+import { resolveTable } from '@/utils/dbUtils';
+import { getThemeColor } from '@/utils/styleUtils';
+import { useHybridData } from '@/views/new/useHybridData';
+import { zodResolver } from '@hookform/resolvers/zod';
+import { toCompilableQuery } from '@powersync/drizzle-driver';
+import { useMutation, useQueryClient } from '@tanstack/react-query';
+import {
+ BookOpenIcon,
+ ChurchIcon,
+ GlobeIcon,
+ LanguagesIcon,
+ PlusIcon,
+ XIcon
+} from 'lucide-react-native';
+import React, { useMemo, useState } from 'react';
+import { useForm } from 'react-hook-form';
+import {
+ ActivityIndicator,
+ KeyboardAvoidingView,
+ Modal,
+ Platform,
+ Pressable,
+ ScrollView,
+ View
+} from 'react-native';
+import { useSafeAreaInsets } from 'react-native-safe-area-context';
+import uuid from 'react-native-uuid';
+import { z } from 'zod';
+import { ProjectListItem } from './ProjectListItem';
+
+type Language = typeof languageTable.$inferSelect;
+
+interface OnboardingFlowProps {
+ visible: boolean;
+ onClose: () => void;
+}
+
+type Step = OnboardingStep | 'create-language';
+
+export function OnboardingFlow({ visible, onClose }: OnboardingFlowProps) {
+ const { t } = useLocalization();
+ const { currentUser } = useAuth();
+ const { goToProject } = useAppNavigation();
+ const queryClient = useQueryClient();
+ const insets = useSafeAreaInsets();
+
+ const [step, setStep] = useState('region');
+ const [projectType, setProjectType] = useState<
+ 'bible' | 'unstructured' | null
+ >(null);
+ const [selectedRegionId, setSelectedRegionId] = useState(null);
+ const [selectedLanguageId, setSelectedLanguageId] = useState(
+ null
+ );
+ const [showCreateLanguage, setShowCreateLanguage] = useState(false);
+
+ const { db } = system;
+
+ // Query regions
+ const { data: regions, isLoading: isLoadingRegions } = useRegions([
+ 'continent',
+ 'nation'
+ ]);
+
+ // Query languages by region
+ const { data: languagesByRegion, isLoading: isLoadingLanguages } =
+ useLanguagesByRegion(selectedRegionId);
+
+ // Query projects by selected language
+ const { data: projectsByLanguage = [], isLoading: isLoadingProjects } =
+ useProjectsByLanguage(selectedLanguageId);
+
+ // Query existing languages to map languoids to language records
+ const { data: existingLanguages = [] } = useHybridData({
+ dataType: 'all-languages-for-onboarding',
+ queryKeyParams: [],
+ offlineQuery: toCompilableQuery(
+ system.db.query.language.findMany({
+ where: (language, { eq }) => eq(language.active, true)
+ })
+ ),
+ cloudQueryFn: async () => {
+ const { data, error } = await system.supabaseConnector.client
+ .from('language')
+ .select('*')
+ .eq('active', true)
+ .overrideTypes();
+ if (error) throw error;
+ return data;
+ },
+ enableCloudQuery: true,
+ enableOfflineQuery: true
+ });
+
+ // Language creation form schema
+ const languageFormSchema = z.object({
+ native_name: z.string().min(1, t('nameRequired')).trim(),
+ english_name: z.string().optional().or(z.literal('')),
+ iso639_3: z.string().optional(),
+ locale: z.string().optional()
+ });
+
+ type LanguageFormData = z.infer;
+
+ const languageForm = useForm({
+ resolver: zodResolver(languageFormSchema),
+ defaultValues: {
+ native_name: '',
+ english_name: '',
+ iso639_3: '',
+ locale: ''
+ }
+ });
+
+ // Create language mutation
+ const { mutateAsync: createLanguage, isPending: isCreatingLanguage } =
+ useMutation({
+ mutationFn: async (values: LanguageFormData) => {
+ const newLanguage = await db
+ .insert(resolveTable('language', { localOverride: true }))
+ .values({
+ id: uuid.v4(),
+ native_name: values.native_name,
+ english_name: values.english_name || null,
+ iso639_3: values.iso639_3 || null,
+ locale: values.locale || null,
+ ui_ready: false,
+ creator_id: currentUser!.id,
+ download_profiles: [currentUser!.id]
+ })
+ .returning();
+
+ if (!newLanguage[0]) throw new Error('Failed to create language');
+ return newLanguage[0] as Language;
+ }
+ });
+
+ // Create project mutation
+ const { mutateAsync: createProject, isPending: isCreatingProject } =
+ useMutation({
+ mutationFn: async (languageId: string) => {
+ const languageName =
+ existingLanguages.find((l) => l.id === languageId)?.native_name ||
+ existingLanguages.find((l) => l.id === languageId)?.english_name ||
+ 'Unknown Language';
+ const projectName =
+ projectType === 'bible'
+ ? `Bible Translation - ${languageName}`
+ : `Translation Project - ${languageName}`;
+
+ let newProject:
+ | { id: string; name: string; template: string | null }
+ | undefined;
+ await db.transaction(async (tx) => {
+ const [project] = await tx
+ .insert(resolveTable('project', { localOverride: true }))
+ .values({
+ name: projectName,
+ template: projectType!,
+ target_language_id: languageId,
+ creator_id: currentUser!.id,
+ download_profiles: [currentUser!.id],
+ private: true,
+ visible: true
+ })
+ .returning();
+
+ if (!project) throw new Error('Failed to create project');
+
+ await tx
+ .insert(
+ resolveTable('profile_project_link', { localOverride: true })
+ )
+ .values({
+ id: `${currentUser!.id}_${project.id}`,
+ project_id: project.id,
+ profile_id: currentUser!.id,
+ membership: 'owner'
+ });
+
+ newProject = project as {
+ id: string;
+ name: string;
+ template: string | null;
+ };
+ });
+
+ if (!newProject) throw new Error('Failed to create project');
+ return newProject;
+ },
+ onSuccess: (newProject) => {
+ // Invalidate queries
+ void queryClient.invalidateQueries({ queryKey: ['my-projects'] });
+ void queryClient.invalidateQueries({
+ queryKey: ['projects-by-language']
+ });
+
+ // Navigate to the project
+ goToProject({
+ id: newProject.id,
+ name: newProject.name,
+ template: newProject.template
+ });
+
+ // Close onboarding and reset state
+ handleClose();
+ },
+ onError: (error) => {
+ console.error('Failed to create project', error);
+ }
+ });
+
+ // Map languoids to language records - create language record if it doesn't exist
+ const mappedLanguages = useMemo(() => {
+ if (!Array.isArray(languagesByRegion) || !Array.isArray(existingLanguages))
+ return [];
+
+ return languagesByRegion.map((languoid) => {
+ // Try to find existing language by matching names
+ const existingLang = existingLanguages.find(
+ (lang) =>
+ lang.native_name?.toLowerCase() === languoid.name.toLowerCase() ||
+ lang.english_name?.toLowerCase() === languoid.name.toLowerCase()
+ );
+
+ return {
+ ...languoid,
+ languageId: existingLang?.id || null,
+ language: existingLang || null,
+ // Store languoid info for creating language if needed
+ languoidName: languoid.name
+ };
+ });
+ }, [languagesByRegion, existingLanguages]);
+
+ const handleRegionSelect = (regionId: string) => {
+ setSelectedRegionId(regionId);
+ setStep('language');
+ };
+
+ const handleLanguageSelect = async (
+ languageId: string | null,
+ languoidName?: string
+ ) => {
+ if (!languageId && languoidName) {
+ // Need to create language record first
+ try {
+ const newLanguage = await createLanguage({
+ native_name: languoidName,
+ english_name: languoidName,
+ iso639_3: '',
+ locale: ''
+ });
+ setSelectedLanguageId(newLanguage.id);
+ setStep('projects');
+ } catch (error) {
+ console.error('Failed to create language', error);
+ }
+ } else if (languageId) {
+ setSelectedLanguageId(languageId);
+ setStep('projects');
+ }
+ };
+
+ const handleCreateLanguage = async (values: LanguageFormData) => {
+ try {
+ const newLanguage = await createLanguage(values);
+ setSelectedLanguageId(newLanguage.id);
+ setShowCreateLanguage(false);
+ languageForm.reset();
+ setStep('projects');
+ } catch (error) {
+ console.error('Failed to create language', error);
+ }
+ };
+
+ const handleProjectSelect = (project: (typeof projectsByLanguage)[0]) => {
+ // Navigate to existing project
+ goToProject({
+ id: project.id,
+ name: project.name,
+ template: project.template,
+ projectData: project as Record
+ });
+ handleClose();
+ };
+
+ const handleCreateNewProject = () => {
+ setStep('create-project');
+ };
+
+ const handleProjectTypeSelect = async (type: 'bible' | 'unstructured') => {
+ if (!selectedLanguageId) return;
+ setProjectType(type);
+ try {
+ await createProject(selectedLanguageId);
+ } catch (error) {
+ console.error('Failed to create project', error);
+ }
+ };
+
+ const handleBack = () => {
+ if (step === 'language') {
+ setStep('region');
+ setSelectedRegionId(null);
+ } else if (step === 'projects') {
+ setStep('language');
+ setSelectedLanguageId(null);
+ } else if (step === 'create-project') {
+ setStep('projects');
+ setProjectType(null);
+ } else if (step === 'create-language') {
+ setStep('language');
+ setShowCreateLanguage(false);
+ languageForm.reset();
+ }
+ };
+
+ const handleClose = () => {
+ // Reset all state when closing
+ setStep('region');
+ setProjectType(null);
+ setSelectedRegionId(null);
+ setSelectedLanguageId(null);
+ setShowCreateLanguage(false);
+ languageForm.reset();
+ onClose();
+ };
+
+ const isLoading = isCreatingLanguage || isCreatingProject;
+
+ // Get selected language name for display
+ const selectedLanguageName = useMemo(() => {
+ if (!selectedLanguageId) return '';
+ const lang = existingLanguages.find((l) => l.id === selectedLanguageId);
+ return lang?.native_name || lang?.english_name || '';
+ }, [selectedLanguageId, existingLanguages]);
+
+ if (!visible) return null;
+
+ // Determine progress step (exclude create-language from progress)
+ const progressStep: OnboardingStep =
+ step === 'create-language' ? 'language' : step;
+
+ return (
+
+
+ {/* Progress Indicator */}
+ {step !== 'create-language' && (
+
+ )}
+
+ {/* Header */}
+
+
+ {step === 'region' && t('selectRegion')}
+ {step === 'language' && t('selectYourLanguage')}
+ {step === 'projects' && t('selectProject')}
+ {step === 'create-project' && t('whatWouldYouLikeToCreate')}
+ {step === 'create-language' && t('createLanguage')}
+
+
+
+
+
+
+ {/* Content */}
+
+ {/* Step 1: Region Selection */}
+ {step === 'region' && (
+
+
+ {t('selectRegionToFilterLanguages')}
+
+
+ {isLoadingRegions ? (
+
+
+
+ ) : (
+
+ {regions.map((region) => (
+
+ ))}
+
+ )}
+
+ )}
+
+ {/* Step 2: Language Selection */}
+ {step === 'language' && !showCreateLanguage && (
+
+
+ {t('selectYourLanguage')}
+
+
+ {isLoadingLanguages ? (
+
+ ) : mappedLanguages.length === 0 ? (
+
+
+
+ {t('noLanguagesFound')}
+
+
+ {t('noLanguagesInRegion')}
+
+
+
+ ) : (
+ <>
+
+ {mappedLanguages.map((lang) => {
+ const languageId = lang.languageId;
+
+ return (
+
+ );
+ })}
+
+
+
+ >
+ )}
+
+ )}
+
+ {/* Step 3: Project Selection */}
+ {step === 'projects' && (
+
+ {isLoadingProjects ? (
+
+
+
+ ) : (
+ <>
+ {/* Create New Project Button - Always prominent */}
+
+
+ {/* Existing Projects List */}
+ {projectsByLanguage.length > 0 ? (
+
+
+ {t('existingProjectsInLanguage', {
+ language: selectedLanguageName
+ })}
+
+
+ {projectsByLanguage.map((project) => (
+ handleProjectSelect(project)}
+ >
+
+
+ ))}
+
+
+ ) : (
+
+
+ {t('noProjectsInLanguage', {
+ language: selectedLanguageName
+ })}
+
+
+ )}
+ >
+ )}
+
+ )}
+
+ {/* Step 4: Create Project Type Selection */}
+ {step === 'create-project' && (
+
+
+ {t('whatWouldYouLikeToCreate')}
+
+
+
+ {/* Bible Project Card */}
+
+ handleProjectTypeSelect('bible')}
+ disabled={isLoading}
+ accessibilityRole="button"
+ >
+
+
+
+
+
+
+ {t('createBibleProject')}
+
+
+ {t('translateBibleIntoYourLanguage')}
+
+
+
+
+
+
+ {/* Other Translation Project Card */}
+
+ handleProjectTypeSelect('unstructured')}
+ disabled={isLoading}
+ accessibilityRole="button"
+ >
+
+
+
+
+
+
+ {t('createOtherProject')}
+
+
+ {t('createGeneralTranslationProject')}
+
+
+
+
+
+
+
+ )}
+
+ {/* Step 5: Create Language */}
+ {(step === 'create-language' || showCreateLanguage) && (
+
+
+ {t('createNewLanguage')}
+
+
+
+
+ languageForm.setValue('native_name', text)
+ }
+ editable={!isLoading}
+ />
+
+
+ languageForm.setValue('english_name', text)
+ }
+ editable={!isLoading}
+ />
+
+
+ languageForm.setValue('iso639_3', text)
+ }
+ editable={!isLoading}
+ />
+
+ languageForm.setValue('locale', text)}
+ editable={!isLoading}
+ />
+
+
+
+
+ )}
+
+
+ {/* Footer with Back button */}
+ {step !== 'region' && (
+
+
+
+ )}
+
+
+ );
+}
diff --git a/views/new/SimpleOnboardingFlow.tsx b/views/new/SimpleOnboardingFlow.tsx
new file mode 100644
index 000000000..6a0fe07d4
--- /dev/null
+++ b/views/new/SimpleOnboardingFlow.tsx
@@ -0,0 +1,546 @@
+import type { OnboardingStep } from '@/components/OnboardingProgressIndicator';
+import { OnboardingProgressIndicator } from '@/components/OnboardingProgressIndicator';
+import { Button } from '@/components/ui/button';
+import { Card } from '@/components/ui/card';
+import { Icon } from '@/components/ui/icon';
+import { Text } from '@/components/ui/text';
+import { useLocalization } from '@/hooks/useLocalization';
+import { useLocalStore } from '@/store/localStore';
+import { PortalHost } from '@rn-primitives/portal';
+import {
+ BookOpenIcon,
+ FolderIcon,
+ HelpCircle,
+ MicIcon,
+ UserPlusIcon,
+ XIcon
+} from 'lucide-react-native';
+import React, { useState } from 'react';
+import {
+ KeyboardAvoidingView,
+ Modal,
+ Platform,
+ Pressable,
+ ScrollView,
+ View
+} from 'react-native';
+import { useSafeAreaInsets } from 'react-native-safe-area-context';
+import { AnimatedOnboardingIcon } from './onboarding/AnimatedOnboardingIcon';
+import { AnimatedStepContent } from './onboarding/AnimatedStepContent';
+import { BibleBookListAnimation } from './onboarding/BibleBookListAnimation';
+import { BibleChapterGrid } from './onboarding/BibleChapterGrid';
+import { InviteAnimation } from './onboarding/InviteAnimation';
+import { ProjectCreationAnimation } from './onboarding/ProjectCreationAnimation';
+import { QuestListAnimation } from './onboarding/QuestListAnimation';
+import { RecordingAnimation } from './onboarding/RecordingAnimation';
+import { VisionScreen } from './onboarding/VisionScreen';
+
+interface SimpleOnboardingFlowProps {
+ visible: boolean;
+ onClose: () => void;
+ onCreateProject?: () => void;
+ onCreateQuest?: () => void;
+ onStartRecording?: () => void;
+ onInviteCollaborators?: () => void;
+}
+
+export function SimpleOnboardingFlow({
+ visible,
+ onClose,
+ onCreateProject: _onCreateProject,
+ onCreateQuest: _onCreateQuest,
+ onStartRecording: _onStartRecording,
+ onInviteCollaborators: _onInviteCollaborators
+}: SimpleOnboardingFlowProps) {
+ const { t } = useLocalization();
+ const insets = useSafeAreaInsets();
+ // Always start with vision step - language selection happens on terms page
+ const [step, setStep] = useState('vision');
+ const [projectType, setProjectType] = useState<'bible' | 'other' | null>(
+ null
+ );
+ const [showBibleChapters, setShowBibleChapters] = useState(false);
+
+ // Reset step when modal opens - always start with vision
+ React.useEffect(() => {
+ if (visible) {
+ setStep('vision');
+ }
+ }, [visible]);
+
+ const handleNext = () => {
+ if (step === 'vision') {
+ setStep('create-project-simple');
+ } else if (step === 'create-project-simple') {
+ // This shouldn't happen - type selection handles navigation
+ return;
+ } else if (step === 'bible-select-book') {
+ // First continue: show chapters below Genesis
+ if (!showBibleChapters) {
+ setShowBibleChapters(true);
+ } else {
+ // Second continue: move to recording step
+ setStep('record-audio');
+ }
+ } else if (step === 'create-quest') {
+ setStep('record-audio');
+ } else if (step === 'record-audio') {
+ setStep('invite-collaborators');
+ } else {
+ handleClose();
+ }
+ };
+
+ const handleBack = () => {
+ if (step === 'vision') {
+ // Can't go back from vision - close onboarding
+ handleClose();
+ } else if (step === 'bible-select-book') {
+ if (showBibleChapters) {
+ setShowBibleChapters(false);
+ } else {
+ setStep('create-project-simple');
+ setProjectType(null);
+ }
+ } else if (step === 'create-quest') {
+ setStep('create-project-simple');
+ setProjectType(null);
+ } else if (step === 'record-audio') {
+ if (projectType === 'bible') {
+ setStep('bible-select-book');
+ setShowBibleChapters(true); // Keep chapters visible when going back
+ } else {
+ setStep('create-quest');
+ }
+ } else if (step === 'invite-collaborators') {
+ setStep('record-audio');
+ } else if (step === 'create-project-simple') {
+ setStep('vision');
+ }
+ };
+
+ const setOnboardingCompleted = useLocalStore(
+ (state) => state.setOnboardingCompleted
+ );
+ const setOnboardingIsOpen = useLocalStore(
+ (state) => state.setOnboardingIsOpen
+ );
+
+ // Mark as open when this instance becomes visible
+ // The parent component already prevents multiple instances by checking onboardingIsOpen
+ // before setting showSimpleOnboarding to true
+ React.useEffect(() => {
+ if (visible) {
+ setOnboardingIsOpen(true);
+ } else {
+ setOnboardingIsOpen(false);
+ }
+ }, [visible, setOnboardingIsOpen]);
+
+ const handleClose = () => {
+ // Reset to initial step (vision)
+ setStep('vision');
+ setProjectType(null);
+ setShowBibleChapters(false);
+ // Mark onboarding as completed so it doesn't show again
+ setOnboardingCompleted(true);
+ // Mark as closed in store
+ setOnboardingIsOpen(false);
+ onClose();
+ };
+
+ // Guard: Don't render if not visible
+ if (!visible) {
+ return null;
+ }
+
+ const handleAction = () => {
+ // Just continue to next step - buttons are informational, not action buttons
+ handleNext();
+ };
+
+ const handleProjectTypeSelect = (type: 'bible' | 'other') => {
+ setProjectType(type);
+ setShowBibleChapters(false);
+ if (type === 'bible') {
+ setStep('bible-select-book');
+ } else {
+ setStep('create-quest');
+ }
+ };
+
+ return (
+
+
+ {/* PortalHost for Select dropdowns inside Modal */}
+
+
+ {/* Progress Indicator */}
+
+
+ {/* Header */}
+
+
+
+
+
+
+
+ {/* Content */}
+
+ {/* Step 0: Vision Screen */}
+ {step === 'vision' && (
+
+
+
+
+
+
+ )}
+
+ {/* Step 1: Create Project - Type Selection */}
+ {step === 'create-project-simple' && (
+
+
+
+
+
+ {t('onboardingCreateProjectTitle')}
+
+
+ {t('onboardingCreateProjectSubtitle')}
+
+
+
+
+
+
+ {/* Bible Project Button */}
+
+
+ {/* Other Project Button */}
+
+
+
+
+ )}
+
+ {/* Bible Flow: Step 2 - Select Book (with chapters on second continue) */}
+ {step === 'bible-select-book' && (
+
+
+
+
+
+ {showBibleChapters
+ ? t('onboardingBibleCreateChapterTitle')
+ : t('onboardingBibleSelectBookTitle')}
+
+
+ {showBibleChapters
+ ? t('onboardingBibleCreateChapterSubtitle')
+ : t('onboardingBibleSelectBookSubtitle')}
+
+
+
+
+
+
+
+ {/* Animated book list */}
+
+ {/* Genesis with chapters below */}
+
+ (
+
+ {genesisElement}
+ {/* Show chapters below Genesis when showBibleChapters is true */}
+ {showBibleChapters && (
+
+
+
+ )}
+
+ )}
+ />
+
+
+
+
+
+
+
+
+
+
+ )}
+
+ {/* Step 2: Create Quest */}
+ {step === 'create-quest' && (
+
+
+
+
+
+ {t('onboardingCreateQuestTitle')}
+
+
+ {t('onboardingCreateQuestSubtitle')}
+
+
+
+
+
+
+
+
+
+
+
+
+ {t('onboardingQuestExample1')}
+
+
+
+
+
+ {t('onboardingQuestExample2')}
+
+
+
+
+
+
+
+
+
+
+
+ )}
+
+ {/* Step 3: Record Audio */}
+ {step === 'record-audio' && (
+
+
+
+
+
+
+
+
+
+
+ {t('onboardingRecordAudioTitle')}
+
+
+ {t('onboardingRecordAudioSubtitle')}
+
+
+
+
+
+
+
+
+
+
+ {t('onboardingRecordMethod1')}
+
+
+
+
+
+ {t('onboardingRecordMethod2')}
+
+
+
+
+
+
+
+
+
+
+ )}
+
+ {/* Step 4: Invite Collaborators */}
+ {step === 'invite-collaborators' && (
+
+
+
+
+
+ {t('onboardingInviteTitle')}
+
+
+ {t('onboardingInviteSubtitle')}
+
+
+
+
+
+
+
+
+
+
+
+
+ {t('onboardingInviteBenefit1')}
+
+
+
+
+
+ {t('onboardingInviteBenefit2')}
+
+
+
+
+
+
+
+
+
+
+
+ )}
+
+
+ {/* Footer with Back button */}
+ {step !== 'vision' && step !== 'create-project-simple' && (
+
+
+
+ )}
+
+
+ );
+}
diff --git a/views/new/onboarding/AnimatedOnboardingIcon.tsx b/views/new/onboarding/AnimatedOnboardingIcon.tsx
new file mode 100644
index 000000000..7144f3941
--- /dev/null
+++ b/views/new/onboarding/AnimatedOnboardingIcon.tsx
@@ -0,0 +1,129 @@
+import { Icon } from '@/components/ui/icon';
+import { getThemeColor } from '@/utils/styleUtils';
+import { LucideIcon } from 'lucide-react-native';
+import React, { useEffect } from 'react';
+import { View } from 'react-native';
+import Animated, {
+ Easing,
+ useAnimatedStyle,
+ useSharedValue,
+ withRepeat,
+ withSequence,
+ withSpring,
+ withTiming
+} from 'react-native-reanimated';
+
+interface AnimatedOnboardingIconProps {
+ icon: LucideIcon;
+ size?: number;
+ className?: string;
+ animationType?: 'pulse' | 'float' | 'scale' | 'rotate';
+ delay?: number;
+}
+
+export function AnimatedOnboardingIcon({
+ icon: IconComponent,
+ size = 48,
+ className,
+ animationType = 'pulse',
+ delay = 0
+}: AnimatedOnboardingIconProps) {
+ const scale = useSharedValue(1);
+ const translateY = useSharedValue(0);
+ const rotate = useSharedValue(0);
+ const opacity = useSharedValue(0);
+
+ useEffect(() => {
+ // Fade in on mount
+ opacity.value = withTiming(1, {
+ duration: 500,
+ easing: Easing.out(Easing.ease)
+ });
+
+ // Start animation after delay
+ const timer = setTimeout(() => {
+ if (animationType === 'pulse') {
+ scale.value = withRepeat(
+ withSequence(
+ withTiming(1.1, {
+ duration: 1000,
+ easing: Easing.inOut(Easing.ease)
+ }),
+ withTiming(1, {
+ duration: 1000,
+ easing: Easing.inOut(Easing.ease)
+ })
+ ),
+ -1,
+ false
+ );
+ } else if (animationType === 'float') {
+ translateY.value = withRepeat(
+ withSequence(
+ withTiming(-8, {
+ duration: 1500,
+ easing: Easing.inOut(Easing.ease)
+ }),
+ withTiming(0, {
+ duration: 1500,
+ easing: Easing.inOut(Easing.ease)
+ })
+ ),
+ -1,
+ false
+ );
+ } else if (animationType === 'scale') {
+ scale.value = withRepeat(
+ withSequence(
+ withSpring(1.15, {
+ damping: 8,
+ stiffness: 100
+ }),
+ withSpring(1, {
+ damping: 8,
+ stiffness: 100
+ })
+ ),
+ -1,
+ false
+ );
+ } else if (animationType === 'rotate') {
+ rotate.value = withRepeat(
+ withTiming(360, {
+ duration: 3000,
+ easing: Easing.linear
+ }),
+ -1,
+ false
+ );
+ }
+ }, delay);
+
+ return () => {
+ clearTimeout(timer);
+ };
+ }, [animationType, delay, opacity, scale, translateY, rotate]);
+
+ const animatedStyle = useAnimatedStyle(() => {
+ return {
+ transform: [
+ { scale: scale.value },
+ { translateY: translateY.value },
+ { rotate: `${rotate.value}deg` }
+ ],
+ opacity: opacity.value
+ };
+ });
+
+ return (
+
+
+
+
+
+ );
+}
diff --git a/views/new/onboarding/AnimatedStepContent.tsx b/views/new/onboarding/AnimatedStepContent.tsx
new file mode 100644
index 000000000..b9c36d2dc
--- /dev/null
+++ b/views/new/onboarding/AnimatedStepContent.tsx
@@ -0,0 +1,47 @@
+import React, { useEffect } from 'react';
+import Animated, {
+ Easing,
+ useAnimatedStyle,
+ useSharedValue,
+ withDelay,
+ withTiming
+} from 'react-native-reanimated';
+
+interface AnimatedStepContentProps {
+ children: React.ReactNode;
+ delay?: number;
+}
+
+export function AnimatedStepContent({
+ children,
+ delay = 0
+}: AnimatedStepContentProps) {
+ const opacity = useSharedValue(0);
+ const translateY = useSharedValue(20);
+
+ useEffect(() => {
+ opacity.value = withDelay(
+ delay,
+ withTiming(1, {
+ duration: 600,
+ easing: Easing.out(Easing.ease)
+ })
+ );
+ translateY.value = withDelay(
+ delay,
+ withTiming(0, {
+ duration: 600,
+ easing: Easing.out(Easing.ease)
+ })
+ );
+ }, [delay, opacity, translateY]);
+
+ const animatedStyle = useAnimatedStyle(() => {
+ return {
+ opacity: opacity.value,
+ transform: [{ translateY: translateY.value }]
+ };
+ });
+
+ return {children};
+}
diff --git a/views/new/onboarding/BibleBookListAnimation.tsx b/views/new/onboarding/BibleBookListAnimation.tsx
new file mode 100644
index 000000000..fae5cf6c7
--- /dev/null
+++ b/views/new/onboarding/BibleBookListAnimation.tsx
@@ -0,0 +1,95 @@
+import { Text } from '@/components/ui/text';
+import { BOOK_ICON_MAP } from '@/utils/BOOK_GRAPHICS';
+import { useThemeColor } from '@/utils/styleUtils';
+import React, { useEffect } from 'react';
+import { Image } from 'react-native';
+import Animated, {
+ useAnimatedStyle,
+ useSharedValue,
+ withDelay,
+ withSpring,
+ withTiming
+} from 'react-native-reanimated';
+
+interface BibleBookListAnimationProps {
+ showChapters?: boolean;
+ renderGenesis?: (genesisElement: React.ReactElement) => React.ReactElement;
+}
+
+export function BibleBookListAnimation({
+ showChapters = false,
+ renderGenesis
+}: BibleBookListAnimationProps) {
+ const primaryColor = useThemeColor('primary');
+ const secondaryColor = useThemeColor('chart-2');
+
+ const book1X = useSharedValue(-30);
+ const book2X = useSharedValue(-30);
+ const book1Opacity = useSharedValue(0);
+ const book2Opacity = useSharedValue(0);
+
+ useEffect(() => {
+ // Animate books sliding in
+ book1Opacity.value = withDelay(200, withTiming(1, { duration: 400 }));
+ book1X.value = withDelay(
+ 200,
+ withSpring(0, { damping: 12, stiffness: 100 })
+ );
+
+ book2Opacity.value = withDelay(400, withTiming(1, { duration: 400 }));
+ book2X.value = withDelay(
+ 400,
+ withSpring(0, { damping: 12, stiffness: 100 })
+ );
+ }, [book1X, book2X, book1Opacity, book2Opacity]);
+
+ const book1Style = useAnimatedStyle(() => {
+ return {
+ transform: [{ translateX: book1X.value }],
+ opacity: book1Opacity.value
+ };
+ });
+
+ const book2Style = useAnimatedStyle(() => {
+ return {
+ transform: [{ translateX: book2X.value }],
+ opacity: book2Opacity.value
+ };
+ });
+
+ const genesisElement = (
+
+
+ Genesis
+
+ );
+
+ return (
+ <>
+ {/* Genesis - can be wrapped by parent if needed */}
+ {renderGenesis ? renderGenesis(genesisElement) : genesisElement}
+
+ {/* Matthew */}
+
+
+ Matthew
+
+ >
+ );
+}
diff --git a/views/new/onboarding/BibleChapterGrid.tsx b/views/new/onboarding/BibleChapterGrid.tsx
new file mode 100644
index 000000000..a46ff6a40
--- /dev/null
+++ b/views/new/onboarding/BibleChapterGrid.tsx
@@ -0,0 +1,167 @@
+import { Text } from '@/components/ui/text';
+import React, { useEffect } from 'react';
+import { View } from 'react-native';
+import Animated, {
+ useAnimatedStyle,
+ useSharedValue,
+ withDelay,
+ withSpring,
+ withTiming
+} from 'react-native-reanimated';
+
+interface BibleChapterGridProps {
+ visible: boolean;
+}
+
+export function BibleChapterGrid({ visible }: BibleChapterGridProps) {
+ // Show chapters 1-6 in a grid, then "..."
+ const chapters = React.useMemo(() => [1, 2, 3, 4, 5, 6], []);
+
+ // Hooks must be called at the top level, not inside useMemo
+ const chapter1Opacity = useSharedValue(0);
+ const chapter2Opacity = useSharedValue(0);
+ const chapter3Opacity = useSharedValue(0);
+ const chapter4Opacity = useSharedValue(0);
+ const chapter5Opacity = useSharedValue(0);
+ const chapter6Opacity = useSharedValue(0);
+
+ const chapter1Scale = useSharedValue(0.8);
+ const chapter2Scale = useSharedValue(0.8);
+ const chapter3Scale = useSharedValue(0.8);
+ const chapter4Scale = useSharedValue(0.8);
+ const chapter5Scale = useSharedValue(0.8);
+ const chapter6Scale = useSharedValue(0.8);
+
+ const dotsOpacity = useSharedValue(0);
+
+ // Create arrays for easier iteration - memoized to avoid recreating on every render
+ const chapterOpacities = React.useMemo(
+ () => [
+ chapter1Opacity,
+ chapter2Opacity,
+ chapter3Opacity,
+ chapter4Opacity,
+ chapter5Opacity,
+ chapter6Opacity
+ ],
+ [
+ chapter1Opacity,
+ chapter2Opacity,
+ chapter3Opacity,
+ chapter4Opacity,
+ chapter5Opacity,
+ chapter6Opacity
+ ]
+ );
+ const chapterScales = React.useMemo(
+ () => [
+ chapter1Scale,
+ chapter2Scale,
+ chapter3Scale,
+ chapter4Scale,
+ chapter5Scale,
+ chapter6Scale
+ ],
+ [
+ chapter1Scale,
+ chapter2Scale,
+ chapter3Scale,
+ chapter4Scale,
+ chapter5Scale,
+ chapter6Scale
+ ]
+ );
+
+ useEffect(() => {
+ if (visible) {
+ // Animate chapters appearing one by one
+ chapters.forEach((_, index) => {
+ const opacity = chapterOpacities[index];
+ const scale = chapterScales[index];
+ if (opacity && scale) {
+ opacity.value = withDelay(
+ 200 + index * 50,
+ withTiming(1, { duration: 300 })
+ );
+ scale.value = withDelay(
+ 200 + index * 50,
+ withSpring(1, { damping: 10, stiffness: 100 })
+ );
+ }
+ });
+ // Animate dots appearing last
+ dotsOpacity.value = withDelay(600, withTiming(1, { duration: 300 }));
+ } else {
+ // Reset when hidden
+ chapters.forEach((_, index) => {
+ const opacity = chapterOpacities[index];
+ const scale = chapterScales[index];
+ if (opacity && scale) {
+ opacity.value = 0;
+ scale.value = 0.8;
+ }
+ });
+ dotsOpacity.value = 0;
+ }
+ }, [visible, chapterOpacities, chapterScales, dotsOpacity, chapters]);
+
+ // Create animated styles at the top level
+ const chapter1Style = useAnimatedStyle(() => ({
+ opacity: chapter1Opacity.value,
+ transform: [{ scale: chapter1Scale.value }]
+ }));
+ const chapter2Style = useAnimatedStyle(() => ({
+ opacity: chapter2Opacity.value,
+ transform: [{ scale: chapter2Scale.value }]
+ }));
+ const chapter3Style = useAnimatedStyle(() => ({
+ opacity: chapter3Opacity.value,
+ transform: [{ scale: chapter3Scale.value }]
+ }));
+ const chapter4Style = useAnimatedStyle(() => ({
+ opacity: chapter4Opacity.value,
+ transform: [{ scale: chapter4Scale.value }]
+ }));
+ const chapter5Style = useAnimatedStyle(() => ({
+ opacity: chapter5Opacity.value,
+ transform: [{ scale: chapter5Scale.value }]
+ }));
+ const chapter6Style = useAnimatedStyle(() => ({
+ opacity: chapter6Opacity.value,
+ transform: [{ scale: chapter6Scale.value }]
+ }));
+ const dotsStyle = useAnimatedStyle(() => ({
+ opacity: dotsOpacity.value
+ }));
+
+ const chapterStyles = [
+ chapter1Style,
+ chapter2Style,
+ chapter3Style,
+ chapter4Style,
+ chapter5Style,
+ chapter6Style
+ ];
+
+ return (
+
+
+ {chapters.map((chapterNum, index) => (
+
+ {chapterNum}
+
+ ))}
+
+ ...
+
+
+
+ );
+}
diff --git a/views/new/onboarding/InviteAnimation.tsx b/views/new/onboarding/InviteAnimation.tsx
new file mode 100644
index 000000000..158e1a934
--- /dev/null
+++ b/views/new/onboarding/InviteAnimation.tsx
@@ -0,0 +1,74 @@
+import { Icon } from '@/components/ui/icon';
+import { UserIcon, UserPlusIcon } from 'lucide-react-native';
+import React, { useEffect } from 'react';
+import { View } from 'react-native';
+import Animated, {
+ useAnimatedStyle,
+ useSharedValue,
+ withDelay,
+ withSpring,
+ withTiming
+} from 'react-native-reanimated';
+
+export function InviteAnimation() {
+ const user1X = useSharedValue(-40);
+ const user2X = useSharedValue(-40);
+ const plusOpacity = useSharedValue(0);
+ const user1Opacity = useSharedValue(0);
+ const user2Opacity = useSharedValue(0);
+
+ useEffect(() => {
+ // Simple: two users slide in with plus icon between
+ user1Opacity.value = withDelay(200, withTiming(1, { duration: 400 }));
+ user1X.value = withDelay(
+ 200,
+ withSpring(0, { damping: 12, stiffness: 100 })
+ );
+
+ plusOpacity.value = withDelay(400, withTiming(1, { duration: 300 }));
+
+ user2Opacity.value = withDelay(500, withTiming(1, { duration: 400 }));
+ user2X.value = withDelay(
+ 500,
+ withSpring(0, { damping: 12, stiffness: 100 })
+ );
+ }, [user1X, user2X, plusOpacity, user1Opacity, user2Opacity]);
+
+ const user1Style = useAnimatedStyle(() => {
+ return {
+ transform: [{ translateX: user1X.value }],
+ opacity: user1Opacity.value
+ };
+ });
+
+ const user2Style = useAnimatedStyle(() => {
+ return {
+ transform: [{ translateX: user2X.value }],
+ opacity: user2Opacity.value
+ };
+ });
+
+ const plusStyle = useAnimatedStyle(() => {
+ return {
+ opacity: plusOpacity.value
+ };
+ });
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/views/new/onboarding/ProjectCreationAnimation.tsx b/views/new/onboarding/ProjectCreationAnimation.tsx
new file mode 100644
index 000000000..0185d31bf
--- /dev/null
+++ b/views/new/onboarding/ProjectCreationAnimation.tsx
@@ -0,0 +1,51 @@
+import { Icon } from '@/components/ui/icon';
+import { FolderPenIcon } from 'lucide-react-native';
+import React, { useEffect } from 'react';
+import { View } from 'react-native';
+import Animated, {
+ Easing,
+ useAnimatedStyle,
+ useSharedValue,
+ withRepeat,
+ withSequence,
+ withTiming
+} from 'react-native-reanimated';
+
+export function ProjectCreationAnimation() {
+ const scale = useSharedValue(1);
+ const opacity = useSharedValue(0);
+
+ useEffect(() => {
+ // Simple fade in and gentle pulse
+ opacity.value = withTiming(1, { duration: 500 });
+ scale.value = withRepeat(
+ withSequence(
+ withTiming(1.05, {
+ duration: 1200,
+ easing: Easing.inOut(Easing.ease)
+ }),
+ withTiming(1, {
+ duration: 1200,
+ easing: Easing.inOut(Easing.ease)
+ })
+ ),
+ -1,
+ false
+ );
+ }, [scale, opacity]);
+
+ const animatedStyle = useAnimatedStyle(() => {
+ return {
+ transform: [{ scale: scale.value }],
+ opacity: opacity.value
+ };
+ });
+
+ return (
+
+
+
+
+
+ );
+}
diff --git a/views/new/onboarding/QuestListAnimation.tsx b/views/new/onboarding/QuestListAnimation.tsx
new file mode 100644
index 000000000..01486ef14
--- /dev/null
+++ b/views/new/onboarding/QuestListAnimation.tsx
@@ -0,0 +1,58 @@
+import { Icon } from '@/components/ui/icon';
+import { BookOpenIcon } from 'lucide-react-native';
+import React, { useEffect } from 'react';
+import { View } from 'react-native';
+import Animated, {
+ useAnimatedStyle,
+ useSharedValue,
+ withDelay,
+ withSpring,
+ withTiming
+} from 'react-native-reanimated';
+
+export function QuestListAnimation() {
+ const book1X = useSharedValue(-30);
+ const book2X = useSharedValue(-30);
+ const book1Opacity = useSharedValue(0);
+ const book2Opacity = useSharedValue(0);
+
+ useEffect(() => {
+ // Simple slide-in animation - just two quests sliding in from left
+ book1Opacity.value = withDelay(200, withTiming(1, { duration: 400 }));
+ book1X.value = withDelay(
+ 200,
+ withSpring(0, { damping: 12, stiffness: 100 })
+ );
+
+ book2Opacity.value = withDelay(400, withTiming(1, { duration: 400 }));
+ book2X.value = withDelay(
+ 400,
+ withSpring(0, { damping: 12, stiffness: 100 })
+ );
+ }, [book1X, book2X, book1Opacity, book2Opacity]);
+
+ const book1Style = useAnimatedStyle(() => {
+ return {
+ transform: [{ translateX: book1X.value }],
+ opacity: book1Opacity.value
+ };
+ });
+
+ const book2Style = useAnimatedStyle(() => {
+ return {
+ transform: [{ translateX: book2X.value }],
+ opacity: book2Opacity.value
+ };
+ });
+
+ return (
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/views/new/onboarding/RecordingAnimation.tsx b/views/new/onboarding/RecordingAnimation.tsx
new file mode 100644
index 000000000..d0917ad45
--- /dev/null
+++ b/views/new/onboarding/RecordingAnimation.tsx
@@ -0,0 +1,236 @@
+import { getThemeColor } from '@/utils/styleUtils';
+import React, { useEffect } from 'react';
+import { View } from 'react-native';
+import Animated, {
+ Easing,
+ useAnimatedStyle,
+ useSharedValue,
+ withRepeat,
+ withSequence,
+ withTiming
+} from 'react-native-reanimated';
+import Svg, { Circle } from 'react-native-svg';
+
+interface RecordingAnimationProps {
+ size?: number;
+}
+
+export function RecordingAnimation({ size = 96 }: RecordingAnimationProps) {
+ const scale1 = useSharedValue(1);
+ const scale2 = useSharedValue(1);
+ const scale3 = useSharedValue(1);
+ const opacity1 = useSharedValue(0.6);
+ const opacity2 = useSharedValue(0.4);
+ const opacity3 = useSharedValue(0.2);
+
+ useEffect(() => {
+ // Animate three concentric circles pulsing outward
+ scale1.value = withRepeat(
+ withSequence(
+ withTiming(1.5, {
+ duration: 1500,
+ easing: Easing.out(Easing.ease)
+ }),
+ withTiming(1, {
+ duration: 0
+ })
+ ),
+ -1,
+ false
+ );
+
+ opacity1.value = withRepeat(
+ withSequence(
+ withTiming(0, {
+ duration: 1500,
+ easing: Easing.out(Easing.ease)
+ }),
+ withTiming(0.6, {
+ duration: 0
+ })
+ ),
+ -1,
+ false
+ );
+
+ scale2.value = withRepeat(
+ withSequence(
+ withTiming(1, {
+ duration: 0
+ }),
+ withTiming(1.5, {
+ duration: 1500,
+ easing: Easing.out(Easing.ease)
+ }),
+ withTiming(1, {
+ duration: 0
+ })
+ ),
+ -1,
+ false
+ );
+
+ opacity2.value = withRepeat(
+ withSequence(
+ withTiming(0.4, {
+ duration: 0
+ }),
+ withTiming(0, {
+ duration: 1500,
+ easing: Easing.out(Easing.ease)
+ }),
+ withTiming(0.4, {
+ duration: 0
+ })
+ ),
+ -1,
+ false
+ );
+
+ scale3.value = withRepeat(
+ withSequence(
+ withTiming(1, {
+ duration: 0
+ }),
+ withTiming(1, {
+ duration: 500
+ }),
+ withTiming(1.5, {
+ duration: 1500,
+ easing: Easing.out(Easing.ease)
+ }),
+ withTiming(1, {
+ duration: 0
+ })
+ ),
+ -1,
+ false
+ );
+
+ opacity3.value = withRepeat(
+ withSequence(
+ withTiming(0.2, {
+ duration: 0
+ }),
+ withTiming(0.2, {
+ duration: 500
+ }),
+ withTiming(0, {
+ duration: 1500,
+ easing: Easing.out(Easing.ease)
+ }),
+ withTiming(0.2, {
+ duration: 0
+ })
+ ),
+ -1,
+ false
+ );
+ }, [scale1, scale2, scale3, opacity1, opacity2, opacity3]);
+
+ const circle1Style = useAnimatedStyle(() => {
+ return {
+ transform: [{ scale: scale1.value }],
+ opacity: opacity1.value
+ };
+ });
+
+ const circle2Style = useAnimatedStyle(() => {
+ return {
+ transform: [{ scale: scale2.value }],
+ opacity: opacity2.value
+ };
+ });
+
+ const circle3Style = useAnimatedStyle(() => {
+ return {
+ transform: [{ scale: scale3.value }],
+ opacity: opacity3.value
+ };
+ });
+
+ const center = size / 2;
+ const radius = size / 2 - 8;
+
+ return (
+
+ {/* Wrap each circle in Animated.View for web compatibility */}
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/views/new/onboarding/VisionFlowAnimation.tsx b/views/new/onboarding/VisionFlowAnimation.tsx
new file mode 100644
index 000000000..d142c5d05
--- /dev/null
+++ b/views/new/onboarding/VisionFlowAnimation.tsx
@@ -0,0 +1,373 @@
+import { Icon } from '@/components/ui/icon';
+import { GlobeIcon, UsersIcon } from 'lucide-react-native';
+import React, { useEffect } from 'react';
+import { View } from 'react-native';
+import Animated, {
+ Easing,
+ useAnimatedStyle,
+ useSharedValue,
+ withDelay,
+ withRepeat,
+ withSequence,
+ withTiming
+} from 'react-native-reanimated';
+
+export function VisionFlowAnimation() {
+ // Globe animation
+ const globeOpacity = useSharedValue(0);
+ const globeScale = useSharedValue(0.8);
+
+ // Users animation
+ const usersOpacity = useSharedValue(0);
+ const usersScale = useSharedValue(0.8);
+
+ // Particles flowing from globe to users with arcing motion
+ // Each particle has X (horizontal) and Y (vertical arc) movement
+ const particle1X = useSharedValue(0);
+ const particle1Y = useSharedValue(0);
+ const particle1Opacity = useSharedValue(0);
+
+ const particle2X = useSharedValue(0);
+ const particle2Y = useSharedValue(0);
+ const particle2Opacity = useSharedValue(0);
+
+ const particle3X = useSharedValue(0);
+ const particle3Y = useSharedValue(0);
+ const particle3Opacity = useSharedValue(0);
+
+ // Particles flowing from users back to globe with arcing motion
+ const returnParticle1X = useSharedValue(0);
+ const returnParticle1Y = useSharedValue(0);
+ const returnParticle1Opacity = useSharedValue(0);
+
+ const returnParticle2X = useSharedValue(0);
+ const returnParticle2Y = useSharedValue(0);
+ const returnParticle2Opacity = useSharedValue(0);
+
+ const returnParticle3X = useSharedValue(0);
+ const returnParticle3Y = useSharedValue(0);
+ const returnParticle3Opacity = useSharedValue(0);
+
+ useEffect(() => {
+ // Animate globe in
+ globeOpacity.value = withDelay(200, withTiming(1, { duration: 500 }));
+ globeScale.value = withDelay(
+ 200,
+ withTiming(1, { duration: 500, easing: Easing.out(Easing.ease) })
+ );
+
+ // Animate users in
+ usersOpacity.value = withDelay(400, withTiming(1, { duration: 500 }));
+ usersScale.value = withDelay(
+ 400,
+ withTiming(1, { duration: 500, easing: Easing.out(Easing.ease) })
+ );
+
+ // Flow from globe to users with arcing paths
+ const flowToUsers = () => {
+ // Reset particles - start from globe position (shifted left)
+ particle1X.value = -100;
+ particle1Y.value = 0;
+ particle1Opacity.value = 0;
+ particle2X.value = -100;
+ particle2Y.value = 0;
+ particle2Opacity.value = 0;
+ particle3X.value = -100;
+ particle3Y.value = 0;
+ particle3Opacity.value = 0;
+
+ // Particle 1: Arc up then down
+ particle1Opacity.value = withSequence(
+ withTiming(1, { duration: 150 }),
+ withTiming(1, { duration: 900 }),
+ withTiming(0, { duration: 150 })
+ );
+ particle1X.value = withTiming(100, {
+ duration: 1200,
+ easing: Easing.out(Easing.ease)
+ });
+ particle1Y.value = withSequence(
+ withTiming(-12, { duration: 600, easing: Easing.out(Easing.ease) }),
+ withTiming(0, { duration: 600, easing: Easing.in(Easing.ease) })
+ );
+
+ // Particle 2: Arc down then up (different path)
+ particle2Opacity.value = withDelay(
+ 300,
+ withSequence(
+ withTiming(1, { duration: 150 }),
+ withTiming(1, { duration: 900 }),
+ withTiming(0, { duration: 150 })
+ )
+ );
+ particle2X.value = withDelay(
+ 300,
+ withTiming(100, {
+ duration: 1200,
+ easing: Easing.out(Easing.ease)
+ })
+ );
+ particle2Y.value = withDelay(
+ 300,
+ withSequence(
+ withTiming(8, { duration: 600, easing: Easing.out(Easing.ease) }),
+ withTiming(0, { duration: 600, easing: Easing.in(Easing.ease) })
+ )
+ );
+
+ // Particle 3: Higher arc
+ particle3Opacity.value = withDelay(
+ 600,
+ withSequence(
+ withTiming(1, { duration: 150 }),
+ withTiming(1, { duration: 900 }),
+ withTiming(0, { duration: 150 })
+ )
+ );
+ particle3X.value = withDelay(
+ 600,
+ withTiming(100, {
+ duration: 1200,
+ easing: Easing.out(Easing.ease)
+ })
+ );
+ particle3Y.value = withDelay(
+ 600,
+ withSequence(
+ withTiming(-16, { duration: 600, easing: Easing.out(Easing.ease) }),
+ withTiming(0, { duration: 600, easing: Easing.in(Easing.ease) })
+ )
+ );
+ };
+
+ // Flow from users back to globe with arcing paths
+ const flowToGlobe = () => {
+ // Reset return particles (start from users position - on the right)
+ returnParticle1X.value = 100;
+ returnParticle1Y.value = 0;
+ returnParticle1Opacity.value = 0;
+ returnParticle2X.value = 100;
+ returnParticle2Y.value = 0;
+ returnParticle2Opacity.value = 0;
+ returnParticle3X.value = 100;
+ returnParticle3Y.value = 0;
+ returnParticle3Opacity.value = 0;
+
+ // Return Particle 1: Arc up then down
+ returnParticle1Opacity.value = withSequence(
+ withTiming(1, { duration: 150 }),
+ withTiming(1, { duration: 900 }),
+ withTiming(0, { duration: 150 })
+ );
+ returnParticle1X.value = withTiming(-100, {
+ duration: 1200,
+ easing: Easing.out(Easing.ease)
+ });
+ returnParticle1Y.value = withSequence(
+ withTiming(-10, { duration: 600, easing: Easing.out(Easing.ease) }),
+ withTiming(0, { duration: 600, easing: Easing.in(Easing.ease) })
+ );
+
+ // Return Particle 2: Arc down then up
+ returnParticle2Opacity.value = withDelay(
+ 400,
+ withSequence(
+ withTiming(1, { duration: 150 }),
+ withTiming(1, { duration: 900 }),
+ withTiming(0, { duration: 150 })
+ )
+ );
+ returnParticle2X.value = withDelay(
+ 400,
+ withTiming(-100, {
+ duration: 1200,
+ easing: Easing.out(Easing.ease)
+ })
+ );
+ returnParticle2Y.value = withDelay(
+ 400,
+ withSequence(
+ withTiming(12, { duration: 600, easing: Easing.out(Easing.ease) }),
+ withTiming(0, { duration: 600, easing: Easing.in(Easing.ease) })
+ )
+ );
+
+ // Return Particle 3: Lower arc
+ returnParticle3Opacity.value = withDelay(
+ 800,
+ withSequence(
+ withTiming(1, { duration: 150 }),
+ withTiming(1, { duration: 900 }),
+ withTiming(0, { duration: 150 })
+ )
+ );
+ returnParticle3X.value = withDelay(
+ 800,
+ withTiming(-100, {
+ duration: 1200,
+ easing: Easing.out(Easing.ease)
+ })
+ );
+ returnParticle3Y.value = withDelay(
+ 800,
+ withSequence(
+ withTiming(-14, { duration: 600, easing: Easing.out(Easing.ease) }),
+ withTiming(0, { duration: 600, easing: Easing.in(Easing.ease) })
+ )
+ );
+ };
+
+ // Start flow to users
+ flowToUsers();
+
+ // After particles reach users, start return flow
+ const returnDelay = setTimeout(() => {
+ flowToGlobe();
+ }, 1500);
+
+ // Repeat both flows continuously
+ const flowInterval = setInterval(() => {
+ flowToUsers();
+ setTimeout(() => {
+ flowToGlobe();
+ }, 1500);
+ }, 3000);
+
+ return () => {
+ clearTimeout(returnDelay);
+ clearInterval(flowInterval);
+ };
+ }, [
+ globeOpacity,
+ globeScale,
+ usersOpacity,
+ usersScale,
+ particle1X,
+ particle1Y,
+ particle1Opacity,
+ particle2X,
+ particle2Y,
+ particle2Opacity,
+ particle3X,
+ particle3Y,
+ particle3Opacity,
+ returnParticle1X,
+ returnParticle1Y,
+ returnParticle1Opacity,
+ returnParticle2X,
+ returnParticle2Y,
+ returnParticle2Opacity,
+ returnParticle3X,
+ returnParticle3Y,
+ returnParticle3Opacity
+ ]);
+
+ const globeStyle = useAnimatedStyle(() => ({
+ opacity: globeOpacity.value,
+ transform: [{ scale: globeScale.value }]
+ }));
+
+ const usersStyle = useAnimatedStyle(() => ({
+ opacity: usersOpacity.value,
+ transform: [{ scale: usersScale.value }]
+ }));
+
+ const particle1Style = useAnimatedStyle(() => ({
+ transform: [
+ { translateX: particle1X.value },
+ { translateY: particle1Y.value }
+ ],
+ opacity: particle1Opacity.value
+ }));
+
+ const particle2Style = useAnimatedStyle(() => ({
+ transform: [
+ { translateX: particle2X.value },
+ { translateY: particle2Y.value }
+ ],
+ opacity: particle2Opacity.value
+ }));
+
+ const particle3Style = useAnimatedStyle(() => ({
+ transform: [
+ { translateX: particle3X.value },
+ { translateY: particle3Y.value }
+ ],
+ opacity: particle3Opacity.value
+ }));
+
+ const returnParticle1Style = useAnimatedStyle(() => ({
+ transform: [
+ { translateX: returnParticle1X.value },
+ { translateY: returnParticle1Y.value }
+ ],
+ opacity: returnParticle1Opacity.value
+ }));
+
+ const returnParticle2Style = useAnimatedStyle(() => ({
+ transform: [
+ { translateX: returnParticle2X.value },
+ { translateY: returnParticle2Y.value }
+ ],
+ opacity: returnParticle2Opacity.value
+ }));
+
+ const returnParticle3Style = useAnimatedStyle(() => ({
+ transform: [
+ { translateX: returnParticle3X.value },
+ { translateY: returnParticle3Y.value }
+ ],
+ opacity: returnParticle3Opacity.value
+ }));
+
+ return (
+
+ {/* Globe Icon (left) with background circle */}
+
+
+ {/* Semi-transparent background circle */}
+
+
+
+
+
+
+
+ {/* Flow particles arcing between globe and users */}
+
+ {/* Particles flowing from globe to users */}
+
+
+
+
+
+
+
+
+
+
+ {/* Particles flowing from users back to globe */}
+
+
+
+
+
+
+
+
+
+
+
+ {/* Users Icon (right) with background circle */}
+
+
+ {/* Semi-transparent background circle */}
+
+
+
+
+
+
+
+ );
+}
diff --git a/views/new/onboarding/VisionScreen.tsx b/views/new/onboarding/VisionScreen.tsx
new file mode 100644
index 000000000..5cce42b69
--- /dev/null
+++ b/views/new/onboarding/VisionScreen.tsx
@@ -0,0 +1,157 @@
+import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
+import { Text } from '@/components/ui/text';
+import { useLocalization } from '@/hooks/useLocalization';
+import { useColorScheme } from 'nativewind';
+import React, { useEffect } from 'react';
+import { Image, View } from 'react-native';
+import Animated, {
+ Easing,
+ useAnimatedStyle,
+ useSharedValue,
+ withDelay,
+ withRepeat,
+ withSequence,
+ withSpring,
+ withTiming
+} from 'react-native-reanimated';
+import { AnimatedStepContent } from './AnimatedStepContent';
+import { VisionFlowAnimation } from './VisionFlowAnimation';
+
+export function VisionScreen() {
+ const { t } = useLocalization();
+ const { colorScheme } = useColorScheme();
+ const logoScale = useSharedValue(0.8);
+ const logoOpacity = useSharedValue(0);
+ const logoRotation = useSharedValue(0);
+
+ // Determine which icon to use based on color scheme
+ const iconSource =
+ colorScheme === 'dark'
+ ? require('@/assets/icons/icon_dark.png')
+ : require('@/assets/icons/icon_light.png');
+
+ useEffect(() => {
+ // Fade in and scale up logo
+ logoOpacity.value = withDelay(
+ 200,
+ withTiming(1, {
+ duration: 800,
+ easing: Easing.out(Easing.ease)
+ })
+ );
+
+ logoScale.value = withDelay(
+ 200,
+ withSpring(1, {
+ damping: 12,
+ stiffness: 100
+ })
+ );
+
+ // Subtle rotation animation (very slow, barely noticeable)
+ logoRotation.value = withRepeat(
+ withSequence(
+ withTiming(2, {
+ duration: 3000,
+ easing: Easing.inOut(Easing.ease)
+ }),
+ withTiming(-2, {
+ duration: 3000,
+ easing: Easing.inOut(Easing.ease)
+ })
+ ),
+ -1,
+ false
+ );
+ }, [logoOpacity, logoScale, logoRotation]);
+
+ const logoStyle = useAnimatedStyle(() => ({
+ transform: [
+ { scale: logoScale.value },
+ { rotate: `${logoRotation.value}deg` }
+ ],
+ opacity: logoOpacity.value
+ }));
+
+ return (
+
+ {/* Animated Logo */}
+
+
+
+
+
+
+
+
+ {/* Vision Statement */}
+
+
+
+ {t('onboardingVisionTitle')}
+
+
+
+
+ {/* Value Proposition */}
+
+
+
+ {t('onboardingVisionSubtitle')}
+
+
+
+
+ {/* Vision Details - Highlighted Box */}
+
+
+
+
+ {t('onboardingOurVision')}
+
+
+
+ {/* Flow Animation */}
+
+
+
+
+ {t('onboardingVisionStatement1')}
+
+
+ {t('onboardingVisionStatement2')}
+
+
+
+
+
+ {/* CC0 Note */}
+
+
+
+ {t('onboardingVisionCC0')}
+
+
+
+
+ );
+}
diff --git a/views/new/recording/components/VADSettingsDrawer.tsx b/views/new/recording/components/VADSettingsDrawer.tsx
index 19201c5ac..44ce8ca4e 100644
--- a/views/new/recording/components/VADSettingsDrawer.tsx
+++ b/views/new/recording/components/VADSettingsDrawer.tsx
@@ -70,27 +70,27 @@ interface InputPill {
const generateInputPills = (): InputPill[] => {
const NUM_PILLS = 10;
const pills: InputPill[] = [];
-
+
// Use exponential distribution: each pill covers an exponentially larger dB range
// Total range: DB_MIN (-60) to DB_MAX (0) = 60 dB
// We'll use a logarithmic distribution
-
+
for (let i = 0; i < NUM_PILLS; i++) {
// Normalize position (0 to 1)
const normalizedPos = i / NUM_PILLS;
const normalizedNextPos = (i + 1) / NUM_PILLS;
-
+
// Apply exponential curve: earlier positions map to smaller dB ranges
// Use power curve: position^1.4 for less aggressive distribution
// Goal: ambient noise (-3 to -2 dB) should fill pills 7-8, not pill 9
// Pill 9 should only fill for very loud sounds close to 0 dB
const logStart = Math.pow(normalizedPos, 1.4);
const logEnd = Math.pow(normalizedNextPos, 1.4);
-
+
// Map back to dB range
const minDb = DB_MIN + logStart * (DB_MAX - DB_MIN);
const maxDb = DB_MIN + logEnd * (DB_MAX - DB_MIN);
-
+
// Color gradient: Blue -> Green -> Yellow -> Red
const colorProgress = i / (NUM_PILLS - 1);
let color: string;
@@ -107,14 +107,14 @@ const generateInputPills = (): InputPill[] => {
const t = (colorProgress - 0.66) / 0.34;
color = `rgba(${234 + (239 - 234) * t}, ${179 + (68 - 179) * t}, ${8 + (68 - 8) * t}, ${0.9 + 0.1 * t})`;
}
-
+
pills.push({
minDb,
maxDb,
color
});
}
-
+
return pills;
};
@@ -210,10 +210,12 @@ export function VADSettingsDrawer({
> | null>(null);
// Ref to track latest energy value for calibration sampling
const latestEnergyRef = React.useRef(0);
-
+
// Refs for logging statistics
const energySamplesRef = React.useRef([]);
- const loggingIntervalRef = React.useRef | null>(null);
+ const loggingIntervalRef = React.useRef | null>(null);
const currentEnergy = energyResult?.energy ?? 0;
@@ -251,24 +253,25 @@ export function VADSettingsDrawer({
// Since pills are equal width, each pill is 10% of the visual space
const dbToVisualPosition = (db: number): number => {
const clampedDb = Math.max(DB_MIN, Math.min(DB_MAX, db));
-
+
// Find which pill contains this dB value
for (let i = 0; i < INPUT_PILLS.length; i++) {
const pill = INPUT_PILLS[i]!;
-
+
if (clampedDb >= pill.minDb && clampedDb <= pill.maxDb) {
// This is the pill - calculate position within it
- const positionInPill = (clampedDb - pill.minDb) / (pill.maxDb - pill.minDb);
+ const positionInPill =
+ (clampedDb - pill.minDb) / (pill.maxDb - pill.minDb);
const pillStartPercent = i * 10; // Each pill is 10% wide
return pillStartPercent + positionInPill * 10;
}
}
-
+
// If above max, return 100%
if (clampedDb >= INPUT_PILLS[INPUT_PILLS.length - 1]!.maxDb) {
return 100;
}
-
+
// Fallback (shouldn't happen)
return 0;
};
@@ -278,7 +281,7 @@ export function VADSettingsDrawer({
// Cumulative fill: all pills below current dB are fully filled, current pill is partially filled
const getActivePills = (db: number) => {
const clampedDb = Math.max(DB_MIN, Math.min(DB_MAX, db));
-
+
return INPUT_PILLS.map((pill, index) => {
if (clampedDb < pill.minDb) {
// Below this pill's range - empty
@@ -288,8 +291,13 @@ export function VADSettingsDrawer({
return { ...pill, isActive: true, fillPercent: 100 };
} else {
// Within this pill's range - calculate fill percentage
- const fillPercent = ((clampedDb - pill.minDb) / (pill.maxDb - pill.minDb)) * 100;
- return { ...pill, isActive: true, fillPercent: Math.min(100, Math.max(0, fillPercent)) };
+ const fillPercent =
+ ((clampedDb - pill.minDb) / (pill.maxDb - pill.minDb)) * 100;
+ return {
+ ...pill,
+ isActive: true,
+ fillPercent: Math.min(100, Math.max(0, fillPercent))
+ };
}
});
};
@@ -298,20 +306,23 @@ export function VADSettingsDrawer({
// Since pills are equal width, each pill is 10% of the visual space
const visualPositionToDb = (percent: number): number => {
const clampedPercent = Math.max(0, Math.min(100, percent));
-
+
// Each pill is 10% wide
const pillIndex = Math.floor(clampedPercent / 10);
const positionInPill = (clampedPercent % 10) / 10;
-
+
// Clamp to valid pill range
- const safePillIndex = Math.max(0, Math.min(INPUT_PILLS.length - 1, pillIndex));
+ const safePillIndex = Math.max(
+ 0,
+ Math.min(INPUT_PILLS.length - 1, pillIndex)
+ );
const pill = INPUT_PILLS[safePillIndex]!;
-
+
// If at the end of the last pill, use maxDb
if (pillIndex >= INPUT_PILLS.length - 1 && positionInPill >= 0.99) {
return pill.maxDb;
}
-
+
// Calculate dB within this pill's range
const dbInPill = pill.minDb + positionInPill * (pill.maxDb - pill.minDb);
return dbInPill;
@@ -352,21 +363,22 @@ export function VADSettingsDrawer({
if (isOpen && isActive) {
// Collect energy samples
energySamplesRef.current = [];
-
+
// Log statistics every 3 seconds
loggingIntervalRef.current = setInterval(() => {
const samples = energySamplesRef.current;
if (samples.length > 0) {
const min = Math.min(...samples);
const max = Math.max(...samples);
- const avg = samples.reduce((sum, val) => sum + val, 0) / samples.length;
+ const avg =
+ samples.reduce((sum, val) => sum + val, 0) / samples.length;
const currentDb = energyToDb(avg);
const thresholdDb = energyToDb(threshold);
-
+
const normalizedMin = normalizeEnergy(min);
const normalizedMax = normalizeEnergy(max);
const normalizedAvg = normalizeEnergy(avg);
-
+
// Find which pill the current dB falls into
let activePillIndex = -1;
let activePillFill = 0;
@@ -374,32 +386,36 @@ export function VADSettingsDrawer({
const pill = INPUT_PILLS[i]!;
if (currentDb >= pill.minDb && currentDb < pill.maxDb) {
activePillIndex = i;
- activePillFill = ((currentDb - pill.minDb) / (pill.maxDb - pill.minDb)) * 100;
+ activePillFill =
+ ((currentDb - pill.minDb) / (pill.maxDb - pill.minDb)) * 100;
break;
}
}
-
+
// Log pill ranges for debugging
- const pillRanges = INPUT_PILLS.map((p, i) =>
- `P${i}: ${p.minDb.toFixed(1)} to ${p.maxDb.toFixed(1)} dB`
+ const pillRanges = INPUT_PILLS.map(
+ (p, i) => `P${i}: ${p.minDb.toFixed(1)} to ${p.maxDb.toFixed(1)} dB`
).join(', ');
-
+
console.log('📊 VAD Energy Stats:', {
samples: samples.length,
rawAvg: avg.toFixed(4),
normalizedAvg: normalizedAvg.toFixed(4),
avgDb: currentDb.toFixed(1),
- activePill: activePillIndex >= 0 ? `Pill ${activePillIndex} (${activePillFill.toFixed(1)}% filled)` : 'None',
+ activePill:
+ activePillIndex >= 0
+ ? `Pill ${activePillIndex} (${activePillFill.toFixed(1)}% filled)`
+ : 'None',
threshold: threshold.toFixed(4),
thresholdDb: thresholdDb.toFixed(1),
pillRanges
});
-
+
// Reset samples for next interval
energySamplesRef.current = [];
}
}, 3000); // Every 3 seconds
-
+
return () => {
if (loggingIntervalRef.current) {
clearInterval(loggingIntervalRef.current);
@@ -469,7 +485,11 @@ export function VADSettingsDrawer({
const sampleInterval = setInterval(() => {
// Use ref to get latest energy value (updated from useEffect)
const currentEnergy = latestEnergyRef.current;
- if (currentEnergy !== undefined && !isNaN(currentEnergy) && currentEnergy > 0) {
+ if (
+ currentEnergy !== undefined &&
+ !isNaN(currentEnergy) &&
+ currentEnergy > 0
+ ) {
calibrationSamplesRef.current.push(currentEnergy);
}
}, CALIBRATION_SAMPLE_INTERVAL_MS);
@@ -499,7 +519,10 @@ export function VADSettingsDrawer({
// Normalize the average energy to 0-1 range (matching visualization)
// Swift/Android send raw RMS energy values that need normalization
const MAX_ENERGY = 20.0;
- const normalizedAverage = Math.min(1.0, Math.max(0, average / MAX_ENERGY));
+ const normalizedAverage = Math.min(
+ 1.0,
+ Math.max(0, average / MAX_ENERGY)
+ );
// Check for reasonable noise level (only check if too quiet - allow loud environments)
if (normalizedAverage < 0.0001) {
@@ -528,13 +551,7 @@ export function VADSettingsDrawer({
}, totalDuration);
calibrationTimeoutRef.current = timeout;
- }, [
- isCalibrating,
- isActive,
- startEnergyDetection,
- onThresholdChange,
- t
- ]);
+ }, [isCalibrating, isActive, startEnergyDetection, onThresholdChange, t]);
// Cleanup calibration on unmount
React.useEffect(() => {
@@ -691,9 +708,9 @@ export function VADSettingsDrawer({
-
+
{t('vadCurrentLevel')}
-
+
{/* Input Level Pills - Horizontal Row (10 equal-width pills) */}
@@ -704,9 +721,9 @@ export function VADSettingsDrawer({
{/* Pill background */}
{/* Pill fill */}
-
);
})}
-
+
{/* Threshold marker spanning all pills vertically */}
-
- {t('vadThreshold')}
+
+ {t('vadThreshold')}