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,
+ },
+ ]
+ );
+ }
+}