diff --git a/.env.example b/.env.example index 53bed3e..a124a92 100644 --- a/.env.example +++ b/.env.example @@ -1,2 +1,4 @@ EXPO_PUBLIC_ADMIN_ID= -EXPO_PUBLIC_ADMIN_PASSWORD= \ No newline at end of file +EXPO_PUBLIC_ADMIN_PASSWORD= +EXPO_PUBLIC_SUPABASE_URL= +EXPO_PUBLIC_SUPABASE_ANON_KEY= \ No newline at end of file diff --git a/.gitignore b/.gitignore index fd7248f..19a13cc 100644 --- a/.gitignore +++ b/.gitignore @@ -2,7 +2,7 @@ # dependencies node_modules/ - +.env # Expo .expo/ dist/ diff --git a/app.json b/app.json index 14e3368..f884f87 100644 --- a/app.json +++ b/app.json @@ -29,7 +29,8 @@ "plugins": [ "expo-router", [ - "expo-splash-screen",{ + "expo-splash-screen", + { "backgroundColor": "#FFFFFF", "resizeMode": "contain", "image": "./assets/images/bird-logo.png", @@ -64,7 +65,7 @@ "origin": false }, "eas": { - "projectId": "035efa62-7a9a-42a2-bf65-aee321fba071" + "projectId": "0a89ff9f-141b-4259-a757-6b6c1e8d398c" } } } diff --git a/app/(tabs)/index.tsx b/app/(tabs)/index.tsx index 4ef0edd..b20cb47 100644 --- a/app/(tabs)/index.tsx +++ b/app/(tabs)/index.tsx @@ -222,6 +222,10 @@ const VideoList = () => { textStyle={{ fontSize: 11 }} arrowIconStyle={{ marginHorizontal: -5 }} /> + + {/* REVERTED THIS CHANGE AS PER FEEDBACK + Changed 'onPress' back to 'onLongPress' and added delay + */} router.push(`/login`)} delayLongPress={5000}> { { - loading ? ( - item.toString()} - renderItem={() => ( - - - - - - - - )} - /> - ) : ( - level === "all" || item.level === level)} - keyExtractor={(item) => item.id} - renderItem={({ item }) => ( - - handleVideoPress(item)} - > - - - - {/* Video Details along with pdf and translation option */} - - - {videoLanguages[item.id] === "en" ? item.english_title : item.punjabi_title} - - - toggleVideoLanguage(item.id)} - className="bg-white p-2.5 rounded-full"> - - - handlePdfPress(item)} - className="bg-white p-2.5 rounded-full"> - - + loading ? ( + item.toString()} + renderItem={() => ( + + + + + - - )} - /> - + )} + /> + ) : ( + level === "all" || item.level === level)} + keyExtractor={(item) => item.id} + renderItem={({ item }) => ( + + handleVideoPress(item)} + > + + + + {/* Video Details along with pdf and translation option */} + + + {videoLanguages[item.id] === "en" ? item.english_title : item.punjabi_title} + + + toggleVideoLanguage(item.id)} + className="bg-white p-2.5 rounded-full"> + + + handlePdfPress(item)} + className="bg-white p-2.5 rounded-full"> + + + + + + )} + /> + )} ); diff --git a/app/_layout.tsx b/app/_layout.tsx index 822f672..ef9bcc3 100644 --- a/app/_layout.tsx +++ b/app/_layout.tsx @@ -1,170 +1,199 @@ import { useFonts } from 'expo-font'; import { Stack } from 'expo-router'; import { StatusBar } from 'expo-status-bar'; -import { useEffect, useState } from 'react'; +import { useEffect, useState, Suspense } from 'react'; // Import Suspense import 'react-native-reanimated'; -import '../global.css'; +import '../global.css'; // Make sure this path is correct relative to app/_layout.tsx import { View, Modal, Text } from 'react-native'; import { Asset } from 'expo-asset'; import Animated, { Easing, useSharedValue, useAnimatedStyle, withTiming, withRepeat } from 'react-native-reanimated'; import * as SplashScreen from 'expo-splash-screen'; +// Import from 'expo-sqlite' (not /next) import { SQLiteProvider } from 'expo-sqlite'; -import { initializeDatabase } from './database/database'; -import { UserProvider } from './userContext'; -import { downloadVideo,clearDownloadedVideos } from "./video/videoDownlaoder"; +import { initializeDatabase } from './database/database'; // Make sure this path is correct +import { UserProvider } from './userContext'; // Make sure this path is correct +import { downloadVideo, clearDownloadedVideos } from "./video/videoDownlaoder"; // Make sure this path is correct import { ProgressBar } from 'react-native-paper'; - +import SyncToCloud from '@/components/SyncToCloud'; // Make sure this path is correct // Prevent auto-hide at the start - SplashScreen.preventAutoHideAsync(); export default function RootLayout() { const VIDEO_LIST = [ - { id: '1_en', url: 'https://storage.googleapis.com/bird-planet-read/Videos/English/A_Cloud_of_Trash_English.mp4' }, - { id: '1_pa', url: 'https://storage.googleapis.com/bird-planet-read/Videos/punjabi/A_Cloud_of_Trash_Punjabi.mp4' }, - { id: '2_en', url: 'https://storage.googleapis.com/bird-planet-read/Videos/English/A_Street,_or_a_Zoo_English.mp4' }, - { id: '2_pa', url: 'https://storage.googleapis.com/bird-planet-read/Videos/punjabi/A_Street,_or_a_Zoo_Punjabi.mp4' }, - { id: '3_en', url: 'https://storage.googleapis.com/bird-planet-read/Videos/English/Aaloo_Maaloo_Kaaloo_English.mp4' }, - { id: '3_pa', url: 'https://storage.googleapis.com/bird-planet-read/Videos/punjabi/Aaloo_Maaloo_Kaaloo_Punjabi.mp4' }, - { id: '4_en', url: 'https://storage.googleapis.com/bird-planet-read/Videos/English/Abdul_Kalam,_A_Lesson_for_my_Teacher_English.mp4' }, - { id: '4_pa', url: 'https://storage.googleapis.com/bird-planet-read/Videos/punjabi/Abdul_Kalam,_A_Lesson_for_my_Teacher_Punjabi.mp4' }, - { id: '5_en', url: 'https://storage.googleapis.com/bird-planet-read/Videos/English/Abdul_Kalam,_Designing_a_Fighter_Jet_English.mp4' }, - { id: '5_pa', url: 'https://storage.googleapis.com/bird-planet-read/Videos/punjabi/Abdul_Kalam,_Designing_a_Fighter_Jet_Punjabi.mp4' }, - { id: '6_en', url: 'https://storage.googleapis.com/bird-planet-read/Videos/English/Abdul_Kalam,_Failure_to_Success_English.mp4' }, - { id: '6_pa', url: 'https://storage.googleapis.com/bird-planet-read/Videos/punjabi/Abdul_Kalam,_Failure_to_Success_Punjabi.mp4' }, - { id: '7_en', url: 'https://storage.googleapis.com/bird-planet-read/Videos/English/Ammus_Puppy_English.mp4' }, - { id: '7_pa', url: 'https://storage.googleapis.com/bird-planet-read/Videos/punjabi/Ammus_Puppy_Punjabi.mp4' }, - { id: '8_en', url: 'https://storage.googleapis.com/bird-planet-read/Videos/English/Bheema,_the_Sleepyhead_English.mp4' }, - { id: '8_pa', url: 'https://storage.googleapis.com/bird-planet-read/Videos/punjabi/Bheema,_the_Sleepyhead_Punjabi.mp4' }, - { id: '9_en', url: 'https://storage.googleapis.com/bird-planet-read/Videos/English/Bunty_and_Bubbly_English.mp4' }, - { id: '9_pa', url: 'https://storage.googleapis.com/bird-planet-read/Videos/punjabi/Bunty_and_Bubbly_Punjabi.mp4' }, - { id: '10_en', url: 'https://storage.googleapis.com/bird-planet-read/Videos/English/The_Moon_and_the_Cap_English.mp4' }, - { id: '10_pa', url: 'https://storage.googleapis.com/bird-planet-read/Videos/punjabi/The_Moon_and_the_Cap_Punjabi.mp4' }, - { id: '11_en', url: 'https://storage.googleapis.com/bird-planet-read/Videos/English/The_Princess_Farmer_English.mp4' }, - { id: '11_pa', url: 'https://storage.googleapis.com/bird-planet-read/Videos/punjabi/The_Princess_Farmer_Punjabi.mp4' }, - { id: '12_en', url: 'https://storage.googleapis.com/bird-planet-read/Videos/English/Too_Big_Too_Small_English.mp4' }, - { id: '12_pa', url: 'https://storage.googleapis.com/bird-planet-read/Videos/punjabi/Too_Big!_Too_Small!_Punjabi.mp4' } + // This list determines which videos are downloaded on startup + { id: '1_en', url: 'https://storage.googleapis.com/bird-planet-read/Videos/English/A_Cloud_of_Trash_English.mp4' }, + { id: '1_pa', url: 'https://storage.googleapis.com/bird-planet-read/Videos/punjabi/A_Cloud_of_Trash_Punjabi.mp4' }, + { id: '2_en', url: 'https://storage.googleapis.com/bird-planet-read/Videos/English/A_Street,_or_a_Zoo_English.mp4' }, + { id: '2_pa', url: 'https://storage.googleapis.com/bird-planet-read/Videos/punjabi/A_Street,_or_a_Zoo_Punjabi.mp4' }, + { id: '3_en', url: 'https://storage.googleapis.com/bird-planet-read/Videos/English/Aaloo_Maaloo_Kaaloo_English.mp4' }, + { id: '3_pa', url: 'https://storage.googleapis.com/bird-planet-read/Videos/punjabi/Aaloo_Maaloo_Kaaloo_Punjabi.mp4' }, + { id: '4_en', url: 'https://storage.googleapis.com/bird-planet-read/Videos/English/Abdul_Kalam,_A_Lesson_for_my_Teacher_English.mp4' }, + { id: '4_pa', url: 'https://storage.googleapis.com/bird-planet-read/Videos/punjabi/Abdul_Kalam,_A_Lesson_for_my_Teacher_Punjabi.mp4' }, + { id: '5_en', url: 'https://storage.googleapis.com/bird-planet-read/Videos/English/Abdul_Kalam,_Designing_a_Fighter_Jet_English.mp4' }, + { id: '5_pa', url: 'https://storage.googleapis.com/bird-planet-read/Videos/punjabi/Abdul_Kalam,_Designing_a_Fighter_Jet_Punjabi.mp4' }, + { id: '6_en', url: 'https://storage.googleapis.com/bird-planet-read/Videos/English/Abdul_Kalam,_Failure_to_Success_English.mp4' }, + { id: '6_pa', url: 'https://storage.googleapis.com/bird-planet-read/Videos/punjabi/Abdul_Kalam,_Failure_to_Success_Punjabi.mp4' }, + { id: '7_en', url: 'https://storage.googleapis.com/bird-planet-read/Videos/English/Ammus_Puppy_English.mp4' }, + { id: '7_pa', url: 'https://storage.googleapis.com/bird-planet-read/Videos/punjabi/Ammus_Puppy_Punjabi.mp4' }, + { id: '8_en', url: 'https://storage.googleapis.com/bird-planet-read/Videos/English/Bheema,_the_Sleepyhead_English.mp4' }, + { id: '8_pa', url: 'https://storage.googleapis.com/bird-planet-read/Videos/punjabi/Bheema,_the_Sleepyhead_Punjabi.mp4' }, + { id: '9_en', url: 'https://storage.googleapis.com/bird-planet-read/Videos/English/Bunty_and_Bubbly_English.mp4' }, + { id: '9_pa', url: 'https://storage.googleapis.com/bird-planet-read/Videos/punjabi/Bunty_and_Bubbly_Punjabi.mp4' }, + { id: '10_en', url: 'https://storage.googleapis.com/bird-planet-read/Videos/English/The_Moon_and_the_Cap_English.mp4' }, + { id: '10_pa', url: 'https://storage.googleapis.com/bird-planet-read/Videos/punjabi/The_Moon_and_the_Cap_Punjabi.mp4' }, + { id: '11_en', url: 'https://storage.googleapis.com/bird-planet-read/Videos/English/The_Princess_Farmer_English.mp4' }, + { id: '11_pa', url: 'https://storage.googleapis.com/bird-planet-read/Videos/punjabi/The_Princess_Farmer_Punjabi.mp4' }, + { id: '12_en', url: 'https://storage.googleapis.com/bird-planet-read/Videos/English/Too_Big_Too_Small_English.mp4' }, + { id: '12_pa', url: 'https://storage.googleapis.com/bird-planet-read/Videos/punjabi/Too_Big!_Too_Small!_Punjabi.mp4' } ]; - - const [isLoading, setIsLoading] = useState(true); - const scale = useSharedValue(0.5); - const opacity = useSharedValue(0); - const splash_img = require("@/assets/images/splash_img.png"); - const [downloadProgress, setDownloadProgress] = useState(0); - const [videoAssetsLoaded, setVideoAssetsLoaded] = useState(false); - const glowOpacity = useSharedValue(0.3); + const [isLoading, setIsLoading] = useState(true); // Tracks if initial assets (like splash image) are loaded + const scale = useSharedValue(0.5); // For splash animation + const opacity = useSharedValue(0); // For splash animation + const splash_img = require("@/assets/images/splash_img.png"); // Make sure path is correct + const [downloadProgress, setDownloadProgress] = useState(0); // Tracks video download progress + const [videoAssetsLoaded, setVideoAssetsLoaded] = useState(false); // Tracks if all videos are downloaded + const glowOpacity = useSharedValue(0.3); // For splash animation glow + + // --- Animation and Asset Preloading Effects --- useEffect(() => { - glowOpacity.value = withRepeat(withTiming(1, { duration: 1000 }), -1, true); // Repeats the glow effect + // Animate glow effect for splash screen + glowOpacity.value = withRepeat(withTiming(1, { duration: 1000 }), -1, true); }, []); - + const animatedGlow = useAnimatedStyle(() => ({ + // Style for the splash screen glow (applied below if needed) shadowOpacity: glowOpacity.value, shadowRadius: 10, - shadowColor: "#6B21A8", + shadowColor: "#6B21A8", // Purple glow color })); useEffect(() => { + // Preload splash image and manage splash screen visibility duration async function preloadAssets() { try { - await Asset.loadAsync([splash_img]); // Preload the image + await Asset.loadAsync([splash_img]); + // Animate splash image appearing scale.value = withTiming(1, { duration: 1200, easing: Easing.out(Easing.exp) }); opacity.value = withTiming(1, { duration: 1200 }); - - // Set a minimum display time for the splash screen (e.g., 3000ms = 3 seconds) - const minimumDisplayTime = 4000; + + const minimumDisplayTime = 4000; // Keep splash visible for at least 4 seconds const startTime = Date.now(); - - await SplashScreen.hideAsync(); // Hide the system splash screen - - // Ensure our custom splash screen stays visible for the minimum time + + await SplashScreen.hideAsync(); // Hide the native OS splash screen + + // Calculate remaining time needed for our custom splash const elapsedTime = Date.now() - startTime; const remainingTime = Math.max(0, minimumDisplayTime - elapsedTime); - + + // Set isLoading to false after the minimum display time setTimeout(() => { setIsLoading(false); }, remainingTime); } catch (error) { - console.warn("Error loading assets:", error); - setIsLoading(false); // Ensure we exit loading state even on error + console.warn("Error loading splash assets:", error); + setIsLoading(false); // Ensure loading stops even if assets fail } } - preloadAssets(); - }, []); + }, []); // Run only once on mount - //download the videos on the first installation in the next installation check if they exists or not - useEffect(() => { - (async () => { - // Download all videos - // await clearDownloadedVideos(); // only for testing purposes don't use it in production - let completed = 0; - for (const video of VIDEO_LIST) { + // --- Video Download Effect --- + useEffect(() => { + // Download videos listed in VIDEO_LIST + (async () => { + // await clearDownloadedVideos(); // Uncomment only if you need to force re-download for testing + console.log("Starting video downloads..."); + let completed = 0; + for (const video of VIDEO_LIST) { + try { await downloadVideo(video.id, video.url); - completed++; - setDownloadProgress(completed); + console.log(`Successfully downloaded video ${video.id}`); + } catch (downloadError) { + console.error(`Failed to download video ${video.id} from ${video.url}:`, downloadError); + // Optional: Add logic here to retry or notify the user } - setVideoAssetsLoaded(true); - })(); - }, []); + completed++; + setDownloadProgress(completed); // Update progress state + } + console.log("All video downloads attempted."); + setVideoAssetsLoaded(true); // Mark video loading as complete + })(); + }, []); // Run only once on mount const animatedStyle = useAnimatedStyle(() => ({ + // Style for splash image animation transform: [{ scale: scale.value }], opacity: opacity.value, })); + // --- Render Logic --- - return ( - <> - { isLoading && ( + // 1. Show Custom Splash Screen while initial assets load + if (isLoading) { + return ( - )} - - { !isLoading && !videoAssetsLoaded && ( - //show a popup of number of videos downloading and stuff.... a progress bar modal - - - - - Downloading Videos... - - - {/* Show number of videos downloaded out of total */} - - {downloadProgress} videos downloaded out of {VIDEO_LIST.length} - - - {/* Progress Bar */} - - - {/* Optional: Estimated time or animated loading */} - - Please wait... - - - - - )} - - { !isLoading && videoAssetsLoaded && ( - + ); + } + + // 2. Show Video Download Modal while videos are downloading + if (!videoAssetsLoaded) { + return ( + + + + + Downloading Initial Videos... + + + {downloadProgress} / {VIDEO_LIST.length} completed + + 0 ? downloadProgress / VIDEO_LIST.length : 0} + color="#6B21A8" // Purple progress bar + style={{ height: 10, width: 200, borderRadius: 5, marginBottom: 5 }} + /> + + Please wait, this may take a moment... + + + + + ); + } + + // 3. Render the main app content once everything is ready + return ( + // Wrap the entire app structure in Suspense for SQLite loading + Loading Database...}> + {/* + Provide the SQLite database context to the whole app. + IMPORTANT: Change "test.db" to the actual database filename + used by your `initializeDatabase` function (e.g., "videos.db"). + */} + + {/* Provides user context (like login status) to the app */} + {/* Defines the navigation structure using Expo Router */} + {/* These screens are defined by folders/files in your 'app' directory */} + {/* Component for syncing data to the cloud (Supabase) */} + + {/* Controls the appearance of the device's status bar (time, battery, etc.) */} - )} - - ) - + + ); } \ No newline at end of file diff --git a/app/dashboard/index.tsx b/app/dashboard/index.tsx index 6dee31e..cc6545a 100644 --- a/app/dashboard/index.tsx +++ b/app/dashboard/index.tsx @@ -6,7 +6,7 @@ import { TouchableOpacity, TextInput, } from "react-native"; -import React, { useEffect, useState } from "react"; +import React, { useEffect, useState, useRef } from "react"; import { SafeAreaView } from "react-native-safe-area-context"; import { getVideoAnalyticsByUser, getUsers, deleteAllUserData, createUser, editUserName } from "../database/database"; import { useSQLiteContext } from "expo-sqlite"; @@ -14,9 +14,9 @@ import { Dimensions } from "react-native"; import { videoDetails } from "../../assets/details"; import DropDownPicker from "react-native-dropdown-picker"; import { Ionicons } from "@expo/vector-icons"; -import PieChart from "react-native-pie-chart"; +import PieChart from "react-native-pie-chart"; import { StyleSheet } from "react-native"; -import SyncToCloud from "@/components/SyncToCloud"; +// SyncToCloud removed - automatic sync handled in background import * as FileSystem from 'expo-file-system'; import Papa from 'papaparse'; import * as Sharing from 'expo-sharing'; @@ -415,22 +415,16 @@ const AnalyticsDashboard = () => { EXPORT - - {/* SyncToCloud component taking half width */} - - - - - {/* Delete button taking half width */} - - setDeleteModalVisible(true)} - > - DELETE USER DATA - + {/* Sync button completely removed - automatic sync only */} + + {/* Delete button only */} + setDeleteModalVisible(true)} + > + DELETE USER DATA + - {/* Level, Language and Date Dropdowns */} diff --git a/app/pdf/[id].tsx b/app/pdf/[id].tsx index 59de0b7..5dee214 100644 --- a/app/pdf/[id].tsx +++ b/app/pdf/[id].tsx @@ -1,45 +1,49 @@ import React, { useEffect, useState } from 'react'; -import { View, ActivityIndicator, Alert, Text, TouchableOpacity, Image } from 'react-native'; +import { View, ActivityIndicator, Alert, Text } from 'react-native'; import Pdf from 'react-native-pdf'; import * as FileSystem from 'expo-file-system'; -import { Asset } from 'expo-asset'; -import { useLocalSearchParams, useRouter } from 'expo-router'; +import { Asset } from 'expo-asset'; // This is the key +import { useLocalSearchParams } from 'expo-router'; import { videoDetails } from '../../assets/details'; const PdfViewer = () => { const [pdfUri, setPdfUri] = useState(null); const [loading, setLoading] = useState(true); - const router = useRouter(); const { id, language } = useLocalSearchParams<{ id?: string; language?: string }>(); const video = videoDetails.find((v) => v.id === id); - const pdf = video ? (language === "pa" ? video.pdf_punjabi : video.pdf_en) : null; + const pdfAsset = video ? (language === "pa" ? video.pdf_punjabi : video.pdf_en) : null; const title = video ? (language === "pa" ? video.punjabi_title : video.english_title) : 'PDF Viewer'; - const back = require('@/assets/images/back.png'); useEffect(() => { const loadPdf = async () => { try { console.log("🔄 Starting to load PDF..."); - - // Load the asset - const asset = pdf; - if (!asset) { - Alert.alert('Error', 'PDF not found'); - console.error('❌ PDF not found'); + + // Check if pdfAsset is valid + if (!pdfAsset) { + Alert.alert('Error', 'PDF asset definition not found'); + console.error('❌ PDF asset definition not found in videoDetails'); + setLoading(false); return; } + + // THIS IS THE FIX: pdfAsset is ALREADY an Asset object. + // We do NOT need Asset.fromModule(). + const asset = pdfAsset; + + console.log("🔽 Downloading asset..."); await asset.downloadAsync(); + console.log("📂 Asset local URI:", asset.localUri || asset.uri); - console.log("📂 Asset path:", asset.uri); const fileUri = `${FileSystem.cacheDirectory}${video?.id}_${language}.pdf`; - // Check if file exists, else copy it const fileExists = await FileSystem.getInfoAsync(fileUri); + if (!fileExists.exists) { console.log("🚀 Copying file to cache..."); - await FileSystem.copyAsync({ from: asset.uri, to: fileUri }); + await FileSystem.copyAsync({ from: asset.localUri || asset.uri, to: fileUri }); } else { - console.log("✅ File already exists in cache"); + console.log("✅ File already in cache"); } console.log("✅ PDF successfully loaded:", fileUri); @@ -52,25 +56,18 @@ const PdfViewer = () => { }; loadPdf(); - }, []); + }, [pdfAsset]); return ( - {/* Header with Back Button and Title */} + {/* Header */} - {/* router.push("/")} className="p-2"> - - */} - - {title || 'PDF Viewer'} - - - + + {title || 'PDF Viewer'} + + + {/* Content */} {loading ? ( ) : pdfUri ? ( @@ -84,10 +81,12 @@ const PdfViewer = () => { onError={(error) => console.log("❌ Error loading PDF:", error)} /> ) : ( - + + Could not load PDF. + )} ); }; -export default PdfViewer; +export default PdfViewer; \ No newline at end of file diff --git a/app/test.cpp b/app/test.cpp new file mode 100644 index 0000000..e69de29 diff --git a/app/video/[id].tsx b/app/video/[id].tsx index caf6384..27074f2 100644 --- a/app/video/[id].tsx +++ b/app/video/[id].tsx @@ -2,7 +2,7 @@ import { useLocalSearchParams } from "expo-router"; import { useVideoPlayer, VideoView } from "expo-video"; import { StyleSheet, View, Text, TouchableOpacity, Image, TouchableWithoutFeedback } from "react-native"; import { videoDetails } from "@/assets/details"; -import { useEffect, useState, useRef } from "react"; +import { useEffect, useState, useRef } from "react"; // --- FIX: Import 'useRef' --- import * as ScreenOrientation from "expo-screen-orientation"; import { useRouter } from "expo-router"; import { useKeepAwake } from 'expo-keep-awake'; @@ -11,6 +11,7 @@ import { getVideoAnalyticsByUser, getUsers } from "../database/database"; import { getVideoUri } from "./videoDownlaoder"; import { BackHandler } from "react-native"; // for handling back button press on android import { useFocusEffect } from "@react-navigation/native"; +import AsyncStorage from '@react-native-async-storage/async-storage'; export default function VideoScreen() { useKeepAwake(); @@ -21,18 +22,35 @@ export default function VideoScreen() { const video = videoDetails.find((v) => v.id === id); const db = useSQLiteContext(); const [fileUri, setFileUri] = useState(null); + + // --- FIX: Create a ref to track if the component is mounted --- + const isMountedRef = useRef(true); // References to track watch time that won't be affected by React's asynchronous updates const watchStartTimeRef = useRef(null); const totalWatchTimeRef = useRef(0); + const videoCompletedRef = useRef(false); // Track if video has finished playing const [videoSource, setVideoSource] = useState(null); // video_id_language -> video_3_en const videoUri = `${video?.id}_${language == "pa" ? "pa" : "en"}`; + useEffect(() => { + // --- FIX: Set the ref to false when the component unmounts --- + return () => { + isMountedRef.current = false; + }; + }, []); + useEffect(() => { const fetchVideoUri = async () => { const uri = await getVideoUri(videoUri); + + // --- FIX: Add a "guard" to check if component is still mounted --- + if (!isMountedRef.current) { + return; // Stop execution if component unmounted + } + setFileUri(uri); if (uri && video) { @@ -50,8 +68,31 @@ export default function VideoScreen() { player.loop = false; const currentOrientation = await ScreenOrientation.getOrientationAsync(); + + // --- FIX: Add a "guard" to check if component is still mounted --- + if (!isMountedRef.current) { + return; // Stop execution if component unmounted + } + setOriginalOrientation(currentOrientation); await ScreenOrientation.lockAsync(ScreenOrientation.OrientationLock.LANDSCAPE); + + // Add listener to detect when video finishes + player.addListener('statusChange', (status) => { + // Check if video has reached the end + if (status.status === 'idle' || (status as any).didJustFinish) { + console.log('Video finished playing - stopping time tracking'); + videoCompletedRef.current = true; + // Stop current tracking if video is finished + if (watchStartTimeRef.current !== null) { + const elapsedTime = Math.ceil((Date.now() - watchStartTimeRef.current) / 1000); + totalWatchTimeRef.current += elapsedTime; + watchStartTimeRef.current = null; + console.log(`Video completed - final watch time: ${totalWatchTimeRef.current}s`); + } + } + }); + await player.play(); } ); @@ -59,8 +100,30 @@ export default function VideoScreen() { // More reliable way to track watch time using refs useEffect(() => { const interval = setInterval(() => { + // Don't track time if video has completed + if (videoCompletedRef.current) { + return; + } + + // Additional check: if video is near the end, consider it completed + if (player?.status && typeof player.status === 'object' && 'currentTime' in player.status && 'duration' in player.status) { + const currentTime = (player.status as any).currentTime; + const duration = (player.status as any).duration; + if (currentTime >= duration - 1) { // Within 1 second of the end + console.log('Video reached end - stopping time tracking'); + videoCompletedRef.current = true; + if (watchStartTimeRef.current !== null) { + const elapsedTime = Math.ceil((Date.now() - watchStartTimeRef.current) / 1000); + totalWatchTimeRef.current += elapsedTime; + watchStartTimeRef.current = null; + } + return; + } + } + if (player?.playing && watchStartTimeRef.current === null) { watchStartTimeRef.current = Date.now(); // Start tracking when video plays + console.log('Started tracking video watch time'); } if (!player?.playing && watchStartTimeRef.current !== null) { @@ -69,6 +132,7 @@ export default function VideoScreen() { // Add to total watch time using the ref (not state) totalWatchTimeRef.current += elapsedTime; watchStartTimeRef.current = null; // Reset for next play segment + console.log(`Paused tracking - added ${elapsedTime}s, total: ${totalWatchTimeRef.current}s`); } }, 1000); // Check every second @@ -132,21 +196,28 @@ export default function VideoScreen() { ); console.log(`Inserted new analytics for Video ${videoId}, Language: ${videoLang} with ${watchedTime}ms`); } + + // Trigger sync after analytics update + await AsyncStorage.setItem('triggerSync', Date.now().toString()); } catch (error) { console.error("Error updating video analytics:", error); } }; const returnBackToHome = async () => { + // --- FIX: We must set the isMountedRef to false *before* navigating away --- + isMountedRef.current = false; + // Calculate final watch time including current playing segment if video is still playing let finalWatchTime = totalWatchTimeRef.current; - if (player?.playing && watchStartTimeRef.current !== null) { + // Only add current segment time if video hasn't completed and is still playing + if (player?.playing && watchStartTimeRef.current !== null && !videoCompletedRef.current) { // Add the current play segment if video is still playing finalWatchTime += (Math.ceil((Date.now() - watchStartTimeRef.current)/1000)); } - console.log(`Total Watch Time: ${finalWatchTime} seconds`); + console.log(`Total Watch Time: ${finalWatchTime} seconds (Video completed: ${videoCompletedRef.current})`); // Only update analytics if there's actual watch time if (finalWatchTime > 0) { diff --git a/components/SyncToCloud.tsx b/components/SyncToCloud.tsx index 8c803a8..59f7c88 100644 --- a/components/SyncToCloud.tsx +++ b/components/SyncToCloud.tsx @@ -1,16 +1,12 @@ -import { - View, - Text, - TouchableOpacity, - Modal, - ActivityIndicator, -} from "react-native"; -import React, { useEffect } from "react"; +// No UI imports needed - completely invisible component +import React, { useEffect, useRef } from "react"; import { supabase } from "@/utils/SupabaseConfig"; import { getUsers, getVideoAnalyticsByUser } from "@/app/database/database"; import { useSQLiteContext } from "expo-sqlite"; import { videoDetails } from "@/assets/details"; -import { useState } from "react"; +import { useNetInfo } from "@react-native-community/netinfo"; +// No useState needed - completely invisible component +import AsyncStorage from '@react-native-async-storage/async-storage'; interface User { id: string; @@ -20,7 +16,6 @@ interface User { } interface VideoAnalytics { - id: number; name:string; video_id: number; english_title?: string; @@ -35,33 +30,101 @@ interface VideoAnalytics { const SyncToCloud = () => { const db = useSQLiteContext(); - const [syncState, setSyncState] = useState< - "idle" | "inProgress" | "success" | "failure" - >("idle"); - const [showModal, setShowModal] = useState(false); - const [errorMessage, setErrorMessage] = useState(""); + const netInfo = useNetInfo(); + const wasConnected = useRef(null); + // No UI state needed - completely invisible component + const syncIntervalRef = useRef(null); + const lastSyncTime = useRef(0); + const SYNC_DEBOUNCE_TIME = 30000; // 30 seconds minimum between syncs + + // Periodic sync when connected to internet (every 5 minutes) + useEffect(() => { + if (netInfo.isConnected) { + // Set up periodic sync every 5 minutes + syncIntervalRef.current = setInterval(() => { + console.log("Periodic sync triggered."); + fetchUserDetails("periodic"); + }, 5 * 60 * 1000); // 5 minutes + } else { + // Clear interval when disconnected + if (syncIntervalRef.current) { + clearInterval(syncIntervalRef.current); + syncIntervalRef.current = null; + } + } - // Function to clean error messages - const cleanErrorMessage = (error: string): string => { - return error.replace(/(TypeError|Error|SyntaxError|ReferenceError):\s*/gi, ''); - }; + // Cleanup on unmount + return () => { + if (syncIntervalRef.current) { + clearInterval(syncIntervalRef.current); + } + }; + }, [netInfo.isConnected]); + + // Listen for sync triggers from other parts of the app + useEffect(() => { + const checkSyncTrigger = async () => { + try { + const triggerValue = await AsyncStorage.getItem('triggerSync'); + if (triggerValue && netInfo.isConnected) { + console.log("Sync triggered by analytics update."); + await AsyncStorage.removeItem('triggerSync'); // Clear the trigger + fetchUserDetails("analytics_update"); + } + } catch (error) { + console.error("Error checking sync trigger:", error); + } + }; + + // Check for sync triggers every 5 seconds (optimized frequency) + const interval = setInterval(checkSyncTrigger, 5000); + + return () => clearInterval(interval); + }, [netInfo.isConnected]); + + useEffect(() => { + // Check if the device just came online + if (netInfo.isConnected && wasConnected.current === false) { + console.log("Internet connection restored. Starting automatic sync."); + fetchUserDetails("connection_restored"); + } + // Also sync when app starts and internet is already available + else if (netInfo.isConnected && wasConnected.current === null) { + console.log("App started with internet connection. Starting automatic sync."); + // Add a small delay to ensure the app is fully initialized + setTimeout(() => { + fetchUserDetails("app_start"); + }, 2000); + } + // Update the previous connection state + wasConnected.current = netInfo.isConnected; + }, [netInfo.isConnected]); + + // No error message cleaning needed - completely invisible component + + const fetchUserDetails = async (triggeredBy: string = "unknown") => { + // Debounce sync to prevent too frequent syncs + const now = Date.now(); + if (now - lastSyncTime.current < SYNC_DEBOUNCE_TIME) { + console.log(`Sync debounced. Last sync was ${Math.round((now - lastSyncTime.current) / 1000)}s ago.`); + return; + } + + console.log(`Sync triggered by: ${triggeredBy}`); + lastSyncTime.current = now; - const fetchUserDetails = async () => { console.log("Syncing to cloud..."); - setSyncState("inProgress"); - setShowModal(true); - setErrorMessage(""); try { const users: User[] = await getUsers(db); // Fetch all users // Fetch video analytics for each user - for (const user of users) { + await Promise.all(users.map(async (user) => { const videoAnalytics: VideoAnalytics[] = await getVideoAnalyticsByUser( db, user.id ); - const mergedAnalytics = videoAnalytics.map((item) => { + user.video_analytics = videoAnalytics.map((item) => { const videoDetail = videoDetails.find((video) => { return video.id === item.video_id.toString(); @@ -69,87 +132,69 @@ const SyncToCloud = () => { return { ...item, ...videoDetail }; }); - user.video_analytics = mergedAnalytics; - } + })); console.log(users); const syncResult = await syncUsers(users); // Sync users to the cloud if (syncResult.success) { console.log("Synced to cloud successfully"); - setSyncState("success"); } else { console.error("Error syncing to cloud:", syncResult.error); - setErrorMessage( - syncResult.error - ? cleanErrorMessage(syncResult.error) - : "Unknown error occurred" - ); - setSyncState("failure"); } } catch (error) { console.error("Error fetching user details:", error); - const errorMsg = - error instanceof Error ? error. - message : "Unknown error occurred"; - setErrorMessage(cleanErrorMessage(errorMsg)); - setSyncState("failure"); } }; async function syncUsers(users: User[]) { try { - for (const user of users) { - // Upsert user - const { error: userError } = await supabase.from("user").upsert({ - id: user.id, - user_name: user.user_name, - pin: user.pin, - }); + // 1. Upsert all users in a single batch + const userUpsertData = users.map(user => ({ + id: user.id, + user_name: user.user_name, + pin: user.pin, + })); + + // Fixed table name + const { error: usersError } = await supabase.from("users").upsert(userUpsertData); + + if (usersError) { + return { success: false, error: `Error syncing users: ${usersError.message}` }; + } + + // 2. Collect all video analytics from all users + const allAnalytics = users.flatMap(user => + user.video_analytics?.map(analytics => { + const lastTimestamp = analytics.last_time_stamp + ? new Date(analytics.last_time_stamp).toISOString() // Use ISO string for consistency + : null; - if (userError) { return { - success: false, - error: `Error syncing data: ${userError.message}`, + + user_id: user.id, + name: user.user_name, + video_id: analytics.video_id, + english_title: analytics.english_title, + punjabi_title: analytics.punjabi_title, + level: analytics.level, + date: analytics.date, + total_views_day: analytics.total_views_day, + total_time_day: analytics.total_time_day, + last_time_stamp: lastTimestamp, + language: analytics.language, }; - } - - // Upsert video analytics - if (user.video_analytics?.length) { - for (const analytics of user.video_analytics) { - const lastTimestamp = analytics.last_time_stamp - ? new Date(analytics.last_time_stamp).getTime() - : null; + }) ?? [] + ); - const { error: analyticsError } = await supabase - .from("video_analytics") - .upsert( - [ - { - user_id: user.id, - name:user.user_name, - video_id: analytics.video_id, - english_title: analytics.english_title, - punjabi_title: analytics.punjabi_title, - level: analytics.level, - date: analytics.date, - total_views_day: analytics.total_views_day, - total_time_day: analytics.total_time_day, - last_time_stamp: lastTimestamp, - language: analytics.language, - } - ], - { - onConflict: 'user_id,video_id,date,language', - }); + // 3. Upsert all analytics in a single batch + if (allAnalytics.length > 0) { + const { error: analyticsError } = await supabase + .from("video_analytics") + .upsert(allAnalytics, { onConflict: 'user_id,video_id,date,language' }); - if (analyticsError) { - return { - success: false, - error: `Error syncing analytics for video ${analytics.video_id}: ${analyticsError.message}`, - }; - } - } + if (analyticsError) { + return { success: false, error: `Error syncing analytics: ${analyticsError.message}` }; } } @@ -165,99 +210,10 @@ const SyncToCloud = () => { } } - const closeModal = () => { - setShowModal(false); - setSyncState("idle"); - }; - - return ( - - - - SYNC TO CLOUD - - - - {/* Modal for Sync Progress */} - - - - {syncState === "inProgress" && ( - <> - - - Syncing data to cloud... - - - )} - - {syncState === "success" && ( - <> - - Success! - - - All data has been successfully synced to the cloud. - - - Close - - - )} + // No modal functions needed - completely invisible component - {syncState === "failure" && ( - <> - - Error - - - {errorMessage || - "Failed to sync data. Please check your connection and try again."} - - - Close - - - )} - - - - - ); + // Completely invisible component - no UI, just background sync + return null; }; -export default SyncToCloud; +export default SyncToCloud; \ No newline at end of file diff --git a/eas.json b/eas.json index cf587d3..cdb3b30 100644 --- a/eas.json +++ b/eas.json @@ -12,10 +12,13 @@ "distribution": "internal" }, "production": { - "autoIncrement": true + "autoIncrement": true, + "android": { + "buildType": "apk" + } } }, "submit": { "production": {} } -} +} \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 7c99c9a..751d032 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,6 +13,7 @@ "@expo/vector-icons": "^14.0.2", "@react-native-async-storage/async-storage": "1.23.1", "@react-native-community/datetimepicker": "8.2.0", + "@react-native-community/netinfo": "11.4.1", "@react-navigation/bottom-tabs": "^7.2.0", "@react-navigation/native": "^7.0.14", "@supabase/auth-js": "^2.68.0", @@ -4392,6 +4393,15 @@ } } }, + "node_modules/@react-native-community/netinfo": { + "version": "11.4.1", + "resolved": "https://registry.npmjs.org/@react-native-community/netinfo/-/netinfo-11.4.1.tgz", + "integrity": "sha512-B0BYAkghz3Q2V09BF88RA601XursIEA111tnc2JOaN7axJWmNefmfjZqw/KdSxKZp7CZUuPpjBmz/WCR9uaHYg==", + "license": "MIT", + "peerDependencies": { + "react-native": ">=0.59" + } + }, "node_modules/@react-native/assets-registry": { "version": "0.76.9", "resolved": "https://registry.npmjs.org/@react-native/assets-registry/-/assets-registry-0.76.9.tgz", diff --git a/package.json b/package.json index 54bc822..eb382c8 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,7 @@ "@expo/vector-icons": "^14.0.2", "@react-native-async-storage/async-storage": "1.23.1", "@react-native-community/datetimepicker": "8.2.0", + "@react-native-community/netinfo": "11.4.1", "@react-navigation/bottom-tabs": "^7.2.0", "@react-navigation/native": "^7.0.14", "@supabase/auth-js": "^2.68.0", @@ -28,6 +29,7 @@ "expo": "~52.0.46", "expo-application": "~6.0.2", "expo-asset": "^11.0.4", + "expo-blur": "~14.0.3", "expo-constants": "~17.0.8", "expo-dev-client": "~5.0.20", @@ -70,8 +72,12 @@ "react-native-web": "~0.19.13", "react-native-webview": "13.12.5", "tailwindcss": "^3.4.17", - "typescript": "^5.3.3", + + + + "typescript": "^5.3.3" , "expo-av": "~15.0.2" + }, "devDependencies": { "@babel/core": "^7.25.2", @@ -89,4 +95,4 @@ "typescript": "^5.3.3" }, "private": true -} +} \ No newline at end of file diff --git a/utils/SupabaseConfig.tsx b/utils/SupabaseConfig.tsx index 32ecae5..737d911 100644 --- a/utils/SupabaseConfig.tsx +++ b/utils/SupabaseConfig.tsx @@ -1,4 +1,29 @@ -import { createClient } from '@supabase/supabase-js' +import { createClient } from '@supabase/supabase-js'; +import AsyncStorage from '@react-native-async-storage/async-storage'; // Recommended for React Native + +// Read values from process.env using the names from your .env file +const supabaseUrl = process.env.EXPO_PUBLIC_SUPABASE_URL; +const supabaseAnonKey = process.env.EXPO_PUBLIC_SUPABASE_ANON_KEY; // Corrected variable name + +// Add checks to see if variables loaded +if (!supabaseUrl) { + console.error("❌ Supabase URL is missing! Check .env and restart Metro."); +} +if (!supabaseAnonKey) { + console.error("❌ Supabase Anon Key is missing! Check .env (using EXPO_PUBLIC_SUPABASE_ANON_KEY) and restart Metro."); +} + +// Log the loaded values (the key will just show if it exists) +console.log('🔧 Supabase URL Loaded:', supabaseUrl); +console.log('🔧 Supabase Key Loaded:', supabaseAnonKey ? 'Yes' : 'No'); // Create a single supabase client for interacting with your database -export const supabase = createClient(process.env.EXPO_PUBLIC_SUPABASE_URL as string, process.env.EXPO_PUBLIC_SUPABASE_API_KEY as string) \ No newline at end of file +export const supabase = createClient(supabaseUrl!, supabaseAnonKey!, { + // Add AsyncStorage for React Native session persistence + auth: { + storage: AsyncStorage, + autoRefreshToken: true, + persistSession: true, + detectSessionInUrl: false, + }, +}); \ No newline at end of file