From 57d674e8ff546a64d0e873d34cc63d2a1b1f1f23 Mon Sep 17 00:00:00 2001 From: ryderwishart Date: Wed, 5 Nov 2025 23:26:25 -0700 Subject: [PATCH 01/11] Onboarding wizard ui --- components/LanguageListSkeleton.tsx | 21 + components/OnboardingProgressIndicator.tsx | 125 ++++ hooks/useLanguagesByRegion.ts | 92 +++ hooks/useProjectsByLanguage.ts | 126 ++++ hooks/useRegions.ts | 48 ++ services/localizations.ts | 182 ++++- views/new/NextGenProjectsView.tsx | 63 +- views/new/OnboardingFlow.tsx | 763 +++++++++++++++++++++ 8 files changed, 1407 insertions(+), 13 deletions(-) create mode 100644 components/LanguageListSkeleton.tsx create mode 100644 components/OnboardingProgressIndicator.tsx create mode 100644 hooks/useLanguagesByRegion.ts create mode 100644 hooks/useProjectsByLanguage.ts create mode 100644 hooks/useRegions.ts create mode 100644 views/new/OnboardingFlow.tsx diff --git a/components/LanguageListSkeleton.tsx b/components/LanguageListSkeleton.tsx new file mode 100644 index 000000000..a6e128896 --- /dev/null +++ b/components/LanguageListSkeleton.tsx @@ -0,0 +1,21 @@ +import { Skeleton } from '@/components/ui/skeleton'; +import React from 'react'; +import { View } from 'react-native'; + +// Loading skeleton for language list +export function LanguageListSkeleton() { + return ( + + {Array.from({ length: 6 }, (_, i) => ( + + + + + ))} + + ); +} + diff --git a/components/OnboardingProgressIndicator.tsx b/components/OnboardingProgressIndicator.tsx new file mode 100644 index 000000000..972d586ea --- /dev/null +++ b/components/OnboardingProgressIndicator.tsx @@ -0,0 +1,125 @@ +import { cn } from '@/utils/styleUtils'; +import { getThemeColor } from '@/utils/styleUtils'; +import Animated, { + useAnimatedStyle, + useSharedValue, + withSpring +} from 'react-native-reanimated'; +import { useEffect } from 'react'; +import { View } from 'react-native'; +import { Text } from '@/components/ui/text'; + +export type OnboardingStep = 'region' | 'language' | 'projects' | 'create-project'; + +interface OnboardingProgressIndicatorProps { + currentStep: OnboardingStep; + className?: string; +} + +const STEP_ORDER: OnboardingStep[] = ['region', 'language', 'projects', 'create-project']; + +const STEP_LABELS: Record = { + region: 'Region', + language: 'Language', + projects: 'Projects', + 'create-project': 'Create' +}; + +export function OnboardingProgressIndicator({ + currentStep, + className +}: OnboardingProgressIndicatorProps) { + const currentStepIndex = STEP_ORDER.indexOf(currentStep); + const progress = useSharedValue(currentStepIndex / (STEP_ORDER.length - 1)); + + // Animate progress when step changes + useEffect(() => { + const targetProgress = currentStepIndex / (STEP_ORDER.length - 1); + progress.value = withSpring(targetProgress, { + damping: 15, + stiffness: 100 + }); + }, [currentStepIndex, progress]); + + // Animated progress bar style + const progressBarStyle = useAnimatedStyle(() => { + return { + width: `${progress.value * 100}%` + }; + }); + + return ( + + {/* Steps container */} + + {/* Progress bar background */} + + {/* Animated progress fill */} + + + + {/* Step indicators */} + {STEP_ORDER.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/hooks/useLanguagesByRegion.ts b/hooks/useLanguagesByRegion.ts new file mode 100644 index 000000000..38e10ccf2 --- /dev/null +++ b/hooks/useLanguagesByRegion.ts @@ -0,0 +1,92 @@ +import { system } from '@/db/powersync/system'; +import { useHybridData } from '@/views/new/useHybridData'; +import { toCompilableQuery } from '@powersync/drizzle-driver'; +import { sql } from 'drizzle-orm'; + +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: toCompilableQuery( + sql`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/useProjectsByLanguage.ts b/hooks/useProjectsByLanguage.ts new file mode 100644 index 000000000..9834e5a5c --- /dev/null +++ b/hooks/useProjectsByLanguage.ts @@ -0,0 +1,126 @@ +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..46ab608f9 --- /dev/null +++ b/hooks/useRegions.ts @@ -0,0 +1,48 @@ +import { system } from '@/db/powersync/system'; +import { useHybridData } from '@/views/new/useHybridData'; +import { toCompilableQuery } from '@powersync/drizzle-driver'; +import { sql } from 'drizzle-orm'; + +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: toCompilableQuery( + sql`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/services/localizations.ts b/services/localizations.ts index 14553d029..0bbcfeb7e 100644 --- a/services/localizations.ts +++ b/services/localizations.ts @@ -899,6 +899,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...', @@ -913,6 +1067,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', @@ -1032,6 +1198,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', @@ -2155,7 +2328,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: { diff --git a/views/new/NextGenProjectsView.tsx b/views/new/NextGenProjectsView.tsx index 42a2af079..e6c3e8b97 100644 --- a/views/new/NextGenProjectsView.tsx +++ b/views/new/NextGenProjectsView.tsx @@ -29,6 +29,7 @@ import { FolderPenIcon, PlusIcon, SearchIcon } from 'lucide-react-native'; import React, { useEffect } from 'react'; import { ActivityIndicator, useWindowDimensions, View } from 'react-native'; import { ProjectListItem } from './ProjectListItem'; +import { OnboardingFlow } from './OnboardingFlow'; // New imports for bottom sheet + form import { LanguageCombobox } from '@/components/language-combobox'; @@ -526,6 +527,35 @@ export default function NextGenProjectsView() { // Use the appropriate query based on active tab const currentQuery = activeTab === 'my' ? myProjectsQuery : allProjects; const { data: projectData, isLoading } = currentQuery; + + // Check if user has no projects (for onboarding) + const hasNoProjects = React.useMemo(() => { + if (activeTab !== 'my') return false; + if (isLoading) return false; + if (!currentUser?.id) return false; + + // Check if myProjectsQuery has no data + if (Array.isArray(projectData)) { + return projectData.length === 0; + } + return false; + }, [activeTab, isLoading, currentUser?.id, projectData]); + + const [showOnboarding, setShowOnboarding] = React.useState(false); + + // Show onboarding when user has no projects + React.useEffect(() => { + if (hasNoProjects && !showOnboarding) { + setShowOnboarding(true); + } else if (!hasNoProjects && showOnboarding) { + setShowOnboarding(false); + } + }, [hasNoProjects, showOnboarding]); + + // Function to open onboarding wizard + const handleOpenOnboarding = () => { + setShowOnboarding(true); + }; // Get fetching state for search indicator const isFetchingProjects = React.useMemo(() => { @@ -622,15 +652,20 @@ export default function NextGenProjectsView() { const dimensions = useWindowDimensions(); return ( - { - setIsCreateOpen(open); - resetForm(); - }} - dismissible={!isCreatingProject} - > - + <> + setShowOnboarding(false)} + /> + { + setIsCreateOpen(open); + resetForm(); + }} + dismissible={!isCreatingProject} + > + {/* Tabs */} - + @@ -723,7 +761,7 @@ export default function NextGenProjectsView() { {activeTab === 'my' && !searchQuery && ( + ))} + + )} + + )} + + {/* 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' && ( + + + + )} + + + ); +} From 3f80e64cd18a210b0155d8817725963d8dbe19f0 Mon Sep 17 00:00:00 2001 From: ryderwishart Date: Thu, 13 Nov 2025 21:53:46 -0700 Subject: [PATCH 02/11] Add onboarding library --- package-lock.json | 70 +++++++++++++++++++++++++++++++++++++++++++++-- package.json | 1 + 2 files changed, 69 insertions(+), 2 deletions(-) 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", From c3ee4d523fe3581acbfc19e6806030ef03ef2494 Mon Sep 17 00:00:00 2001 From: ryderwishart Date: Fri, 14 Nov 2025 18:14:29 -0700 Subject: [PATCH 03/11] Further onboarding adjustments --- components/AppHeader.tsx | 18 +- components/OnboardingProgressIndicator.tsx | 73 +- services/localizations.ts | 217 ++++++ store/localStore.ts | 12 + views/AppView.tsx | 9 + views/new/NextGenProjectsView.tsx | 640 ++++++++++-------- views/new/SimpleOnboardingFlow.tsx | 554 +++++++++++++++ .../new/onboarding/AnimatedOnboardingIcon.tsx | 126 ++++ views/new/onboarding/AnimatedStepContent.tsx | 49 ++ .../new/onboarding/BibleBookListAnimation.tsx | 90 +++ views/new/onboarding/BibleChapterGrid.tsx | 87 +++ views/new/onboarding/InviteAnimation.tsx | 72 ++ .../onboarding/ProjectCreationAnimation.tsx | 54 ++ views/new/onboarding/QuestListAnimation.tsx | 56 ++ views/new/onboarding/RecordingAnimation.tsx | 237 +++++++ views/new/onboarding/VisionScreen.tsx | 149 ++++ 16 files changed, 2143 insertions(+), 300 deletions(-) create mode 100644 views/new/SimpleOnboardingFlow.tsx create mode 100644 views/new/onboarding/AnimatedOnboardingIcon.tsx create mode 100644 views/new/onboarding/AnimatedStepContent.tsx create mode 100644 views/new/onboarding/BibleBookListAnimation.tsx create mode 100644 views/new/onboarding/BibleChapterGrid.tsx create mode 100644 views/new/onboarding/InviteAnimation.tsx create mode 100644 views/new/onboarding/ProjectCreationAnimation.tsx create mode 100644 views/new/onboarding/QuestListAnimation.tsx create mode 100644 views/new/onboarding/RecordingAnimation.tsx create mode 100644 views/new/onboarding/VisionScreen.tsx diff --git a/components/AppHeader.tsx b/components/AppHeader.tsx index ac9053c97..6b37ea4d1 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 */} + - - - - {/* 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 && ( - + + )} - + )} - /> - )} - - - -
- - {t('newProject')} - - - ( - - - + + {/* 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 && ( + + - - - + + ) + } + ListEmptyComponent={() => ( + + + + {searchQuery + ? 'No projects found' + : activeTab === 'my' + ? 'No projects yet' + : 'No projects available'} + + {activeTab === 'my' && !searchQuery && ( + + )} + + )} /> + )} + - { - return ( + + + + {t('newProject')} + + + ( - field.onChange(lang.id)} + - ); - }} - /> + )} + /> - ( - - -