diff --git a/android/app/src/main/res/xml/provider_paths.xml b/android/app/src/main/res/xml/provider_paths.xml new file mode 100644 index 0000000..ccf8baa --- /dev/null +++ b/android/app/src/main/res/xml/provider_paths.xml @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + diff --git a/src/layout/navigation/Tabs/CustomTabBar.tsx b/src/layout/navigation/Tabs/CustomTabBar.tsx index e46b83e..b657a47 100644 --- a/src/layout/navigation/Tabs/CustomTabBar.tsx +++ b/src/layout/navigation/Tabs/CustomTabBar.tsx @@ -119,9 +119,31 @@ const CustomTabBar: React.FC = ({state, navigation}) => { navigation.navigate('Ai'); }; - const handleImportSubjects = () => { - // Navigate to import subjects screen when implemented - console.log('Import Subjects pressed'); + const handleImportSubjects = async () => { + try { + const { pickCSVFileRaw } = require('../../../utils/csv-picker'); + const { importAndAddToRegisterFromContent } = require('../../../utils/csv-import'); + + console.log('Import Subjects pressed - starting CSV picker...'); + + // Pick CSV file and get raw content + const csvContent = await pickCSVFileRaw(); + + if (csvContent) { + console.log('CSV file selected, starting import...'); + // Import and add to current register + await importAndAddToRegisterFromContent(csvContent); + } else { + console.log('No CSV file selected'); + } + } catch (error) { + console.error('Error importing subjects:', error); + const { Alert } = require('react-native'); + Alert.alert( + 'Import Failed', + `Failed to import subjects: ${error instanceof Error ? error.message : 'Unknown error'}` + ); + } }; return ( diff --git a/src/screens/user-settings/SettingsScreen.tsx b/src/screens/user-settings/SettingsScreen.tsx index d721c85..28fec26 100644 --- a/src/screens/user-settings/SettingsScreen.tsx +++ b/src/screens/user-settings/SettingsScreen.tsx @@ -18,6 +18,8 @@ import Slider from '@react-native-community/slider'; import MultiSelect from 'react-native-multiple-select'; import useStore from '../../store/store'; import { saveScheduleToDevice, shareSchedule } from '../../utils/exportSchedule'; +import pickCSVFile, { pickCSVFileRaw } from '../../utils/csv-picker'; +import { importAndAddToRegisterFromContent } from '../../utils/csv-import'; // Constants const packageJson = require('../../../package.json'); @@ -41,6 +43,7 @@ const SettingsScreen: React.FC = () => { setSelectedSchedules, notificationLeadTime, setNotificationLeadTime, + addMultipleCards, } = useStore(); const [darkMode, setDarkMode] = useState(true); @@ -158,6 +161,40 @@ const SettingsScreen: React.FC = () => { } }; + const handleImportSchedule = async () => { + try { + console.log('Starting CSV import...'); + + if (!registers[activeRegister]) { + Alert.alert('Error', 'No active register found. Please create a register first.'); + return; + } + + // Use raw CSV content instead of parsed data + const csvContent = await pickCSVFileRaw(); + console.log('Raw CSV Content received:', csvContent); + + if (!csvContent) { + console.log('No CSV content received (user cancelled or error)'); + return; + } + + // Use the import utility with the current active register + const currentCards = registers[activeRegister]?.cards || []; + await importAndAddToRegisterFromContent( + csvContent, + activeRegister, + currentCards, + addMultipleCards + ); + + } catch (error) { + console.error('Import schedule error:', error); + const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + Alert.alert('Error', `Failed to import schedule: ${errorMessage}`); + } + }; + const clearAllData = () => { Alert.alert( 'Clear All Data', @@ -326,6 +363,19 @@ const SettingsScreen: React.FC = () => { + + + + + + Import Schedule from CSV + Import subjects from a CSV file + + + {/* Data Management */} diff --git a/src/store/store.ts b/src/store/store.ts index 67cd2b5..b0cd34e 100644 --- a/src/store/store.ts +++ b/src/store/store.ts @@ -59,6 +59,7 @@ interface StoreState { removeCard: (registerId: number, cardIndex: number) => void; addAiCard: (registerId: number, aiCard: AiCardInterface) => void; addMultipleAiCards: (registerId: number, aiCards: AiCardInterface[]) => void; + addMultipleCards: (registerId: number, cards: CardInterface[]) => void; setRegisterColor: (registerId: number, color: string) => void; } @@ -643,6 +644,22 @@ export const useStore = create()( }; }), + addMultipleCards: (registerId: number, cards: CardInterface[]) => + set(state => { + const currentCards = state.registers[registerId]?.cards || []; + + return { + registers: { + ...state.registers, + [registerId]: { + ...state.registers[registerId], + cards: [...currentCards, ...cards], + }, + }, + updatedAt: new Date(), + }; + }), + setRegisterColor: (registerId: number, color: string) => set(state => ({ registers: { diff --git a/src/utils/csv-export.ts b/src/utils/csv-export.ts index d2a2836..91f3889 100644 --- a/src/utils/csv-export.ts +++ b/src/utils/csv-export.ts @@ -12,6 +12,7 @@ export function generateRegisterCSV(registers: { name: string, cards: CardInterf const register = registers[0]; csv += `${register.name}\n\n`; + // First, add all subjects with their scheduled time slots dayKeys.forEach((dayKey, i) => { const subjectRows: string[] = []; register.cards.forEach(card => { @@ -27,7 +28,25 @@ export function generateRegisterCSV(registers: { name: string, cards: CardInterf csv += subjectRows.join('\n') + '\n\n'; } }); + + // Add a section for subjects without any time slots + const subjectsWithoutSlots = register.cards.filter(card => { + return dayKeys.every(dayKey => { + const slots = card.days[dayKey] || []; + return slots.length === 0; + }); + }); + + if (subjectsWithoutSlots.length > 0) { + csv += `Subjects Without Time Slots\n`; + csv += `Subject,Start Time,End Time,Room\n`; + subjectsWithoutSlots.forEach(card => { + csv += `${card.title},Not Scheduled,Not Scheduled,Not Assigned\n`; + }); + csv += '\n'; + } } else { + // Handle multiple registers dayKeys.forEach((dayKey, i) => { let daySection = `,Day: ${dayNames[i]}\n`; @@ -50,6 +69,25 @@ export function generateRegisterCSV(registers: { name: string, cards: CardInterf csv += daySection; } }); + + // Add sections for subjects without time slots for each register + registers.forEach(register => { + const subjectsWithoutSlots = register.cards.filter(card => { + return dayKeys.every(dayKey => { + const slots = card.days[dayKey] || []; + return slots.length === 0; + }); + }); + + if (subjectsWithoutSlots.length > 0) { + csv += `,${register.name} - Subjects Without Time Slots\n`; + csv += `,Subject,Start Time,End Time,Room\n`; + subjectsWithoutSlots.forEach(card => { + csv += `,${card.title},Not Scheduled,Not Scheduled,Not Assigned\n`; + }); + csv += '\n'; + } + }); } return csv; diff --git a/src/utils/csv-import.ts b/src/utils/csv-import.ts new file mode 100644 index 0000000..9847d7a --- /dev/null +++ b/src/utils/csv-import.ts @@ -0,0 +1,396 @@ +import Papa from 'papaparse'; +import RNFS from 'react-native-fs'; +import { Alert } from 'react-native'; +import { CardInterface, Days, Slots } from '../types/cards'; + +export interface ImportResult { + success: boolean; + cards: Partial[]; + errors: string[]; +} + +export interface ParsedCSVRow { + subject: string; + startTime: string; + endTime: string; + room: string; + day?: string; +} + +export class CSVImportUtility { + private static dayMapping: { [key: string]: keyof Days } = { + 'sunday': 'sun', + 'monday': 'mon', + 'tuesday': 'tue', + 'wednesday': 'wed', + 'thursday': 'thu', + 'friday': 'fri', + 'saturday': 'sat', + }; + + private static parseTimeSlot(startTime: string, endTime: string, room: string): Slots | null { + // Handle "Not Scheduled" case + if (startTime === 'Not Scheduled' || endTime === 'Not Scheduled') { + return null; + } + + // Clean and trim the time values, handle potential formatting issues + let cleanStartTime = startTime.trim(); + let cleanEndTime = endTime.trim(); + + // Fix common time format issues (e.g., "10:0" -> "10:00") + cleanStartTime = this.normalizeTimeFormat(cleanStartTime); + cleanEndTime = this.normalizeTimeFormat(cleanEndTime); + + console.log(`Parsing time slot: "${cleanStartTime}" - "${cleanEndTime}" (original: "${startTime}" - "${endTime}")`); + + // Validate time format (HH:MM or H:MM) + const timeRegex = /^([0-1]?[0-9]|2[0-3]):([0-5][0-9])$/; + if (!timeRegex.test(cleanStartTime) || !timeRegex.test(cleanEndTime)) { + console.log(`Time format validation failed for: "${cleanStartTime}" - "${cleanEndTime}"`); + return null; + } + + return { + start: cleanStartTime, + end: cleanEndTime, + roomName: room === 'Not Assigned' || room === '' ? null : room + }; + } + + // Normalize time format to ensure proper HH:MM format + private static normalizeTimeFormat(timeStr: string): string { + // Remove any non-digit and non-colon characters + const cleaned = timeStr.replace(/[^\d:]/g, ''); + + // Handle cases like "10:0" -> "10:00" + const parts = cleaned.split(':'); + if (parts.length === 2) { + const hours = parts[0].padStart(2, '0'); + const minutes = parts[1].padEnd(2, '0').substring(0, 2); + return `${hours}:${minutes}`; + } + + return cleaned; + } + + private static initializeEmptyDays(): Days { + return { + sun: [], + mon: [], + tue: [], + wed: [], + thu: [], + fri: [], + sat: [] + }; + } + + private static parseCSVContent(csvContent: string): ImportResult { + const errors: string[] = []; + const cardsMap = new Map>(); + let currentDay: keyof Days | null = null; + let isUnscheduledSection = false; + + console.log('Starting CSV import process...'); + + // Split into lines and process + const lines = csvContent.split('\n').map(line => line.trim()).filter(line => line.length > 0); + console.log(`Processing ${lines.length} lines`); + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + + // Skip register name lines (first line without commas) + if (i === 0 && !line.includes(',')) { + console.log('Skipping register name line'); + continue; + } + + // Check for day headers + const dayMatch = line.match(/Day:\s*(\w+)/i); + if (dayMatch) { + const dayName = dayMatch[1].toLowerCase(); + currentDay = this.dayMapping[dayName] || null; + console.log(`Found day header: ${dayName} -> ${currentDay}`); + if (!currentDay) { + errors.push(`Unknown day: ${dayMatch[1]}`); + } + continue; + } + + // Check for "Subjects Without Time Slots" section + if (line.toLowerCase().includes('subjects without time slots')) { + isUnscheduledSection = true; + currentDay = null; + console.log('Entering unscheduled section'); + continue; + } + + // Skip header lines + if (line.toLowerCase().includes('subject,start time,end time,room') || + (line.toLowerCase().includes('subject') && line.toLowerCase().includes('start time'))) { + console.log('Skipping header line'); + continue; + } + + // Parse CSV row using a more robust approach + const columns = this.parseCSVLine(line); + + // Handle both single register and multiple register formats + let subject: string, startTime: string, endTime: string, room: string; + + if (columns.length >= 4) { + // Check if this is a multiple register format (starts with register name) + if (columns[0] && !columns[0].toLowerCase().includes('day:') && + columns[0] !== 'Subject' && currentDay === null && !isUnscheduledSection) { + continue; + } + + // Single register format or multiple register data line + if (columns[0] === '' && columns.length >= 5) { + // Multiple register format: ,Subject,Start,End,Room + [, subject, startTime, endTime, room] = columns; + } else { + // Single register format: Subject,Start,End,Room + [subject, startTime, endTime, room] = columns; + } + + console.log(`Raw parsed data: Subject="${subject}", Start="${startTime}", End="${endTime}", Room="${room}"`); + + if (!subject || subject === 'Subject') continue; + + // Get or create card + if (!cardsMap.has(subject)) { + console.log(`Creating new card for subject: ${subject}`); + cardsMap.set(subject, { + title: subject, + days: this.initializeEmptyDays(), + target_percentage: 75, + tagColor: '#3b82f6', + present: 0, + total: 0, + markedAt: [], + hasLimit: false, + limit: 0, + limitType: 'with-absent' + }); + } + + const card = cardsMap.get(subject)!; + + if (isUnscheduledSection) { + // For unscheduled subjects, we just add them without time slots + continue; + } + + if (currentDay) { + // Parse time slot + const slot = this.parseTimeSlot(startTime, endTime, room); + if (slot) { + card.days![currentDay].push(slot); + console.log(`Added time slot to ${subject} on ${currentDay}: ${slot.start}-${slot.end}`); + } else if (startTime !== 'Not Scheduled') { + const error = `Invalid time format for ${subject}: ${startTime}-${endTime}`; + errors.push(error); + console.log('Error:', error); + } + } + } + } + + // Convert map to array and assign IDs + const cards = Array.from(cardsMap.values()).map((card, index) => ({ + ...card, + id: index + 1 + })); + + console.log(`CSV Import completed: ${cards.length} cards created`); + if (errors.length > 0) { + console.log('Import errors:', errors); + } + + return { + success: errors.length === 0, + cards, + errors + }; + } + + // More robust CSV line parsing to handle quoted values and preserve formatting + private static parseCSVLine(line: string): string[] { + const result: string[] = []; + let current = ''; + let inQuotes = false; + + for (let i = 0; i < line.length; i++) { + const char = line[i]; + + if (char === '"') { + inQuotes = !inQuotes; + } else if (char === ',' && !inQuotes) { + result.push(current.trim()); + current = ''; + } else { + current += char; + } + } + + result.push(current.trim()); + return result; + } + + static async importFromFile(fileUri: string): Promise { + try { + // Read file content + const fileContent = await RNFS.readFile(fileUri, 'utf8'); + return this.parseCSVContent(fileContent); + } catch (error) { + return { + success: false, + cards: [], + errors: [`Failed to read file: ${error instanceof Error ? error.message : 'Unknown error'}`] + }; + } + } + + static async importFromCSVString(csvContent: string): Promise { + return this.parseCSVContent(csvContent); + } + + static validateImportData(cards: Partial[]): string[] { + const errors: string[] = []; + const usedTitles = new Set(); + + cards.forEach((card, index) => { + // Check for duplicate titles + if (card.title) { + if (usedTitles.has(card.title)) { + errors.push(`Duplicate subject found: ${card.title}`); + } else { + usedTitles.add(card.title); + } + } + + // Validate time slots don't overlap within same day + if (card.days) { + Object.entries(card.days).forEach(([dayKey, slots]) => { + if (slots && slots.length > 1) { + // Sort slots by start time + const sortedSlots = [...slots].sort((a, b) => a.start.localeCompare(b.start)); + + for (let i = 0; i < sortedSlots.length - 1; i++) { + const current = sortedSlots[i]; + const next = sortedSlots[i + 1]; + + if (current.end > next.start) { + errors.push(`Overlapping time slots for ${card.title} on ${dayKey}: ${current.start}-${current.end} and ${next.start}-${next.end}`); + } + } + } + }); + } + }); + + return errors; + } + + // Convert partial cards to full CardInterface for store integration + static prepareCardsForStore(cards: Partial[], existingCards: CardInterface[]): CardInterface[] { + const startId = existingCards.length > 0 + ? Math.max(...existingCards.map(c => c.id)) + 1 + : 0; + + return cards.map((card, index) => ({ + id: startId + index, + title: card.title || `Untitled Subject ${index + 1}`, + present: card.present || 0, + total: card.total || 0, + target_percentage: card.target_percentage || 75, + tagColor: card.tagColor || '#3b82f6', + days: card.days || { + sun: [], mon: [], tue: [], wed: [], thu: [], fri: [], sat: [] + }, + markedAt: card.markedAt || [], + hasLimit: card.hasLimit || false, + limit: card.limit || 0, + limitType: card.limitType || 'with-absent', + })); + } +} + +// Convenience function for direct use +export const importScheduleFromCSV = async (fileUri: string): Promise => { + return CSVImportUtility.importFromFile(fileUri); +}; + +// Function to handle complete import process with user interaction from CSV content +export const importAndAddToRegisterFromContent = async ( + csvContent: string, + registerId: number, + existingCards: CardInterface[], + addMultipleCards: (registerId: number, cards: CardInterface[]) => void +): Promise => { + try { + const importResult = await CSVImportUtility.importFromCSVString(csvContent); + + if (!importResult.success) { + Alert.alert( + 'Import Failed', + `Could not import CSV:\n${importResult.errors.join('\n')}`, + [{ text: 'OK' }] + ); + return; + } + + // Validate imported data + const validationErrors = CSVImportUtility.validateImportData(importResult.cards); + if (validationErrors.length > 0) { + Alert.alert( + 'Import Warning', + `Some issues were found:\n${validationErrors.join('\n')}\n\nDo you want to continue?`, + [ + { text: 'Cancel', style: 'cancel' }, + { + text: 'Continue', + onPress: () => { + const cards = CSVImportUtility.prepareCardsForStore(importResult.cards, existingCards); + addMultipleCards(registerId, cards); + Alert.alert('Success', `Imported ${cards.length} subjects from CSV`); + } + } + ] + ); + return; + } + + // Convert and add cards + const cards = CSVImportUtility.prepareCardsForStore(importResult.cards, existingCards); + + if (cards.length === 0) { + Alert.alert('Import Failed', 'No valid subjects found in CSV file'); + return; + } + + Alert.alert( + 'Import Schedule', + `Found ${cards.length} subjects in CSV. Do you want to add them to the current register?`, + [ + { text: 'Cancel', style: 'cancel' }, + { + text: 'Import', + onPress: () => { + addMultipleCards(registerId, cards); + Alert.alert('Success', `Imported ${cards.length} subjects successfully!`); + } + } + ] + ); + + } catch (error) { + Alert.alert( + 'Import Error', + `Failed to import CSV: ${error instanceof Error ? error.message : 'Unknown error'}` + ); + } +}; diff --git a/src/utils/csv-picker.ts b/src/utils/csv-picker.ts index bd2cd55..2517e4e 100644 --- a/src/utils/csv-picker.ts +++ b/src/utils/csv-picker.ts @@ -47,4 +47,38 @@ const pickCSVFile = async () => { return csvdata; }; +// New function to get raw CSV content without parsing +export const pickCSVFileRaw = async (): Promise => { + try { + const res = await pick({ + allowMultiSelection: false, + type: [types.csv], + }); + + if (!res.every(file => file.hasRequestedType)) { + console.error('Some selected files are not csv.'); + return null; + } + + console.log('Selected files:', res); + + for (const file of res) { + const fileUri = file.uri; + console.log('File URI:', fileUri); + + // Read raw file content + const fileContent = await RNFS.readFile(fileUri, 'utf8'); + console.log('Raw CSV Content:', fileContent); + return fileContent; + } + } catch (err: any) { + if (err?.code === 'DOCUMENT_PICKER_CANCELED') { + console.log('User cancelled the picker'); + } else { + console.error('Error picking document:', err); + } + } + return null; +}; + export default pickCSVFile; diff --git a/src/utils/permissions.ts b/src/utils/permissions.ts new file mode 100644 index 0000000..7ff25dd --- /dev/null +++ b/src/utils/permissions.ts @@ -0,0 +1,141 @@ +import { Platform, PermissionsAndroid, Alert } from 'react-native'; + +export interface PermissionResult { + granted: boolean; + canRequestAgain: boolean; +} + +export class PermissionsHelper { + + /** + * Check if we need to request storage permissions based on Android version + * Android 11+ (API 30+) uses scoped storage, so we don't need WRITE_EXTERNAL_STORAGE + * for app-specific directories + */ + static needsStoragePermission(): boolean { + if (Platform.OS !== 'android') return false; + + // For Android 11+ (API 30+), we use app-scoped storage which doesn't require permissions + const androidVersion = Platform.Version as number; + return androidVersion < 30; + } + + /** + * Request storage permissions for Android + */ + static async requestStoragePermission(forPublicStorage: boolean = false): Promise { + if (Platform.OS !== 'android') { + return { granted: true, canRequestAgain: false }; + } + + // Android 11+ doesn't need storage permissions for app-scoped directories + if (!this.needsStoragePermission() && !forPublicStorage) { + return { granted: true, canRequestAgain: false }; + } + + try { + // For Android 10 and below, or if explicitly requesting public storage + if (this.needsStoragePermission() || forPublicStorage) { + const writeResult = await PermissionsAndroid.request( + PermissionsAndroid.PERMISSIONS.WRITE_EXTERNAL_STORAGE, + { + title: 'Storage Permission', + message: 'App needs storage permission to save CSV files', + buttonNeutral: 'Ask Me Later', + buttonNegative: 'Cancel', + buttonPositive: 'OK', + } + ); + + const readResult = await PermissionsAndroid.request( + PermissionsAndroid.PERMISSIONS.READ_EXTERNAL_STORAGE, + { + title: 'Storage Permission', + message: 'App needs storage permission to access files', + buttonNeutral: 'Ask Me Later', + buttonNegative: 'Cancel', + buttonPositive: 'OK', + } + ); + + const allGranted = writeResult === PermissionsAndroid.RESULTS.GRANTED && + readResult === PermissionsAndroid.RESULTS.GRANTED; + + const canRequestAgain = writeResult !== PermissionsAndroid.RESULTS.NEVER_ASK_AGAIN && + readResult !== PermissionsAndroid.RESULTS.NEVER_ASK_AGAIN; + + return { + granted: allGranted, + canRequestAgain + }; + } + + return { granted: true, canRequestAgain: false }; + + } catch (error) { + console.warn('Permission request error:', error); + return { granted: false, canRequestAgain: true }; + } + } + + /** + * Check if storage permissions are currently granted + */ + static async checkStoragePermission(): Promise { + if (Platform.OS !== 'android') return true; + + if (!this.needsStoragePermission()) return true; + + try { + const writePermission = await PermissionsAndroid.check( + PermissionsAndroid.PERMISSIONS.WRITE_EXTERNAL_STORAGE + ); + + return writePermission; + } catch (error) { + console.warn('Permission check error:', error); + return false; + } + } + + /** + * Show permission explanation dialog + */ + static showPermissionExplanation(onRetry: () => void, onCancel: () => void) { + Alert.alert( + 'Storage Permission Required', + 'This app needs storage permission to save CSV files to your device. You can still use the share feature without this permission.', + [ + { + text: 'Cancel', + style: 'cancel', + onPress: onCancel, + }, + { + text: 'Grant Permission', + onPress: onRetry, + }, + ] + ); + } + + /** + * Show permission denied dialog + */ + static showPermissionDenied(onOpenSettings: () => void, onUseAppStorage: () => void) { + Alert.alert( + 'Permission Denied', + 'Storage permission was denied. You can either:\n\n1. Enable it in Settings\n2. Use app storage (files will be saved in app folder)', + [ + { + text: 'Use App Storage', + onPress: onUseAppStorage, + }, + { + text: 'Open Settings', + onPress: onOpenSettings, + }, + ] + ); + } +}