299 lines
11 KiB
Dart
299 lines
11 KiB
Dart
import 'dart:async';
|
|
import 'dart:io';
|
|
import 'package:flutter/foundation.dart';
|
|
import 'package:flutter/material.dart';
|
|
import 'package:file_picker/file_picker.dart';
|
|
import 'package:google_mlkit_document_scanner/google_mlkit_document_scanner.dart';
|
|
import 'package:desktop_drop/desktop_drop.dart';
|
|
import '../../../core/constants/app_colors.dart';
|
|
import '../../../core/constants/app_sizes.dart';
|
|
import 'package:flutter/services.dart';
|
|
|
|
class DocumentSourcePicker extends StatefulWidget {
|
|
// Callbacks to pass data back to whatever screen is using this widget
|
|
final Function(File file, String fileName, String fileSize) onFileSelected;
|
|
final Function(String error)? onError;
|
|
final List<String>? allowedExtensions;
|
|
|
|
const DocumentSourcePicker({
|
|
super.key,
|
|
required this.onFileSelected,
|
|
this.onError,
|
|
this.allowedExtensions,
|
|
});
|
|
|
|
@override
|
|
State<DocumentSourcePicker> createState() => _DocumentSourcePickerState();
|
|
}
|
|
|
|
class _DocumentSourcePickerState extends State<DocumentSourcePicker> {
|
|
bool _isDragOver = false;
|
|
Timer? _dragResetTimer;
|
|
|
|
@override
|
|
void dispose() {
|
|
_dragResetTimer?.cancel();
|
|
super.dispose();
|
|
}
|
|
|
|
int _safeFileSize(File file) {
|
|
try {
|
|
if (file.existsSync()) {
|
|
return file.lengthSync();
|
|
}
|
|
} catch (_) {}
|
|
return 0;
|
|
}
|
|
|
|
Future<void> _pickFile() async {
|
|
try {
|
|
final allowed =
|
|
widget.allowedExtensions ??
|
|
['pdf', 'jpg', 'png', 'jpeg', 'doc', 'docx', 'xls', 'xlsx', 'csv'];
|
|
FilePickerResult? result = await FilePicker.pickFiles(
|
|
type: FileType.custom,
|
|
allowedExtensions: allowed,
|
|
);
|
|
|
|
if (result != null && result.files.single.path != null) {
|
|
final file = File(result.files.single.path!);
|
|
final bytes = _safeFileSize(file);
|
|
final size = (bytes / (1024 * 1024)).toStringAsFixed(2);
|
|
// Send the data back to the parent screen
|
|
widget.onFileSelected(file, result.files.single.name, '$size MB');
|
|
}
|
|
} catch (e) {
|
|
widget.onError?.call('Failed to pick file: $e');
|
|
}
|
|
}
|
|
|
|
Future<void> _takePhoto() async {
|
|
if (!Platform.isAndroid && !Platform.isIOS) return;
|
|
try {
|
|
final scanner = DocumentScanner(
|
|
options: DocumentScannerOptions(
|
|
documentFormats: const {DocumentFormat.jpeg},
|
|
mode: ScannerMode.full, // Allows edge detection, cropping, filters
|
|
isGalleryImport:
|
|
false, // Prevents importing from gallery since we have a separate button for that
|
|
pageLimit: 1, // Only one document per upload field
|
|
),
|
|
);
|
|
|
|
final result = await scanner.scanDocument();
|
|
scanner.close();
|
|
|
|
final images = result.images;
|
|
if (images != null && images.isNotEmpty) {
|
|
final file = File(images.first);
|
|
final bytes = _safeFileSize(file);
|
|
final size = (bytes / (1024 * 1024)).toStringAsFixed(2);
|
|
final name =
|
|
'Scanned_Doc_${DateTime.now().millisecondsSinceEpoch}.jpg';
|
|
|
|
widget.onFileSelected(file, name, '$size MB');
|
|
}
|
|
} catch (e) {
|
|
if (e is PlatformException) {
|
|
final msg = e.message?.toLowerCase() ?? '';
|
|
final code = e.code.toLowerCase();
|
|
final details = e.details?.toString().toLowerCase() ?? '';
|
|
if (msg.contains('cancel') || code.contains('cancel') || details.contains('cancel') || msg.contains('operation cancelled')) {
|
|
return;
|
|
}
|
|
}
|
|
final str = e.toString().toLowerCase();
|
|
if (str.contains('operation cancelled') || str.contains('cancelled') || str.contains('canceled')) {
|
|
return;
|
|
}
|
|
widget.onError?.call('Failed to scan document: $e');
|
|
}
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final isMobile = Platform.isAndroid || Platform.isIOS;
|
|
final isWindows = defaultTargetPlatform == TargetPlatform.windows;
|
|
final allowed =
|
|
widget.allowedExtensions ??
|
|
['pdf', 'jpg', 'png', 'jpeg', 'doc', 'docx', 'xls', 'xlsx', 'csv'];
|
|
|
|
return LayoutBuilder(
|
|
builder: (context, constraints) {
|
|
final isDark = Theme.of(context).brightness == Brightness.dark;
|
|
final narrow = constraints.maxWidth < 300;
|
|
final iconSize = narrow ? 44.0 : 56.0;
|
|
|
|
Widget content = Container(
|
|
width: double.infinity,
|
|
decoration: BoxDecoration(
|
|
color: _isDragOver
|
|
? AppColors.primaryGreen.withValues(alpha: 0.15)
|
|
: AppColors.primaryGreen.withValues(alpha: 0.05),
|
|
border: Border.all(
|
|
color: _isDragOver
|
|
? AppColors.primaryGreen
|
|
: AppColors.primaryGreen.withValues(alpha: 0.3),
|
|
width: _isDragOver ? 3 : 2,
|
|
),
|
|
borderRadius: BorderRadius.circular(AppSizes.radiusLarge),
|
|
),
|
|
padding: EdgeInsets.symmetric(
|
|
horizontal: AppSizes.p16,
|
|
vertical: narrow ? AppSizes.p16 : AppSizes.p24,
|
|
),
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
children: [
|
|
Container(
|
|
padding: const EdgeInsets.all(AppSizes.p12),
|
|
decoration: BoxDecoration(
|
|
color: AppColors.primaryGreen.withValues(alpha: 0.1),
|
|
shape: BoxShape.circle,
|
|
),
|
|
child: Icon(
|
|
_isDragOver
|
|
? Icons.file_download_outlined
|
|
: Icons.document_scanner_outlined,
|
|
size: iconSize,
|
|
color: AppColors.primaryGreen,
|
|
),
|
|
),
|
|
const SizedBox(height: AppSizes.p12),
|
|
Text(
|
|
_isDragOver
|
|
? 'Drop File Here'
|
|
: (isWindows
|
|
? 'Select Document or Drag & Drop'
|
|
: 'Select Document Source'),
|
|
textAlign: TextAlign.center,
|
|
style: TextStyle(
|
|
fontSize: 16,
|
|
fontWeight: FontWeight.bold,
|
|
color: isDark
|
|
? AppColors.darkTextPrimary
|
|
: AppColors.textPrimary,
|
|
),
|
|
),
|
|
const SizedBox(height: AppSizes.p4),
|
|
Text(
|
|
'Supports ${allowed.map((e) => e.toUpperCase()).join(', ')} (Max 10MB)',
|
|
textAlign: TextAlign.center,
|
|
style: TextStyle(
|
|
color: isDark
|
|
? AppColors.darkTextSecondary
|
|
: AppColors.textSecondary,
|
|
fontSize: 12,
|
|
),
|
|
),
|
|
const SizedBox(height: AppSizes.p16),
|
|
|
|
Wrap(
|
|
alignment: WrapAlignment.center,
|
|
spacing: AppSizes.p12,
|
|
runSpacing: AppSizes.p8,
|
|
children: [
|
|
if (isMobile)
|
|
SizedBox(
|
|
width: 155,
|
|
height: 44,
|
|
child: ElevatedButton.icon(
|
|
onPressed: _takePhoto,
|
|
icon: const Icon(Icons.camera_alt_outlined, size: 18),
|
|
label: const Text('Use Camera'),
|
|
style: ElevatedButton.styleFrom(
|
|
backgroundColor: AppColors.primaryGreen,
|
|
foregroundColor: Colors.white,
|
|
padding: EdgeInsets.zero,
|
|
shape: RoundedRectangleBorder(
|
|
borderRadius:
|
|
BorderRadius.circular(AppSizes.radiusMedium),
|
|
),
|
|
elevation: 0,
|
|
),
|
|
),
|
|
),
|
|
|
|
SizedBox(
|
|
width: 155,
|
|
height: 44,
|
|
child: ElevatedButton.icon(
|
|
onPressed: _pickFile,
|
|
icon: const Icon(Icons.folder_open, size: 18),
|
|
label: const Text('Browse Files'),
|
|
style: ElevatedButton.styleFrom(
|
|
backgroundColor: isMobile
|
|
? (isDark
|
|
? AppColors.darkSurface2
|
|
: Colors.white)
|
|
: AppColors.primaryGreen,
|
|
foregroundColor: isMobile
|
|
? AppColors.primaryGreen
|
|
: Colors.white,
|
|
side: isMobile
|
|
? const BorderSide(color: AppColors.primaryGreen)
|
|
: BorderSide.none,
|
|
padding: EdgeInsets.zero,
|
|
shape: RoundedRectangleBorder(
|
|
borderRadius:
|
|
BorderRadius.circular(AppSizes.radiusMedium),
|
|
),
|
|
elevation: 0,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
);
|
|
|
|
if (isWindows) {
|
|
content = DropTarget(
|
|
onDragEntered: (details) {
|
|
_dragResetTimer?.cancel();
|
|
if (mounted) setState(() => _isDragOver = true);
|
|
_dragResetTimer = Timer(const Duration(seconds: 3), () {
|
|
if (mounted && _isDragOver) {
|
|
setState(() => _isDragOver = false);
|
|
}
|
|
});
|
|
},
|
|
onDragExited: (details) {
|
|
_dragResetTimer?.cancel();
|
|
if (mounted) setState(() => _isDragOver = false);
|
|
},
|
|
onDragDone: (details) {
|
|
_dragResetTimer?.cancel();
|
|
if (mounted) setState(() => _isDragOver = false);
|
|
try {
|
|
if (details.files.isNotEmpty) {
|
|
final xfile = details.files.first;
|
|
final ext = xfile.path.split('.').last.toLowerCase();
|
|
if (allowed.contains(ext)) {
|
|
final file = File(xfile.path);
|
|
final bytes = _safeFileSize(file);
|
|
final size =
|
|
(bytes / (1024 * 1024)).toStringAsFixed(2);
|
|
widget.onFileSelected(file, xfile.name, '$size MB');
|
|
} else {
|
|
widget.onError?.call(
|
|
'Unsupported file format: .$ext. Allowed: ${allowed.join(', ')}');
|
|
}
|
|
}
|
|
} catch (e) {
|
|
widget.onError?.call('Failed to read dropped file: $e');
|
|
}
|
|
},
|
|
child: content,
|
|
);
|
|
}
|
|
|
|
return content;
|
|
},
|
|
);
|
|
}
|
|
}
|
|
|