feat: add headless LibreOffice Excel to PDF conversion for Windows and Android

This commit is contained in:
ssbg04 committed 2026-08-21 15:58:43 +08:00
1 parent 7517d81489
commit e100d6bdf4
10 files changed
+429 -23

No files matched your search

@@ -1,4 +1,3 @@
import 'dart:io';
import 'dart:convert';
import 'dart:typed_data';
import 'package:dio/dio.dart';
@@ -322,6 +321,21 @@ class DocumentRepository {
}
}
Future<DocumentModel> convertExcelToPdf(int id) async {
try {
final options = await _getAuthOptions();
final response = await _dio.post('/documents/$id/convert-to-pdf', options: options);
final docData = response.data['document'] as Map<String, dynamic>?;
if (docData != null) {
return DocumentModel.fromJson(docData);
}
throw Exception(response.data['message'] ?? 'Failed to convert document.');
} on DioException catch (e) {
final msg = e.response?.data?['message'] ?? 'Failed to convert Excel to PDF.';
throw Exception(msg);
}
}
/// Uploads in-memory bytes (e.g. a converted PDF) as a new document in the
/// student's folder.
Future<void> uploadDocumentBytes({
@@ -0,0 +1,47 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../domain/entities/document_model.dart';
import '../../domain/repositories/document_repository.dart';
import 'archives_provider.dart';
import 'document_provider.dart';
final conversionProvider =
StateNotifierProvider<ConversionNotifier, AsyncValue<void>>((ref) {
final repository = ref.watch(documentRepositoryProvider);
return ConversionNotifier(repository, ref);
});
class ConversionNotifier extends StateNotifier<AsyncValue<void>> {
final DocumentRepository _repository;
final Ref _ref;
int? _convertingDocId;
ConversionNotifier(this._repository, this._ref)
: super(const AsyncValue.data(null));
int? get convertingDocId => _convertingDocId;
bool isConverting(int docId) =>
state.isLoading && _convertingDocId == docId;
Future<DocumentModel> convertToPdf(int documentId) async {
_convertingDocId = documentId;
state = const AsyncValue.loading();
try {
final convertedDoc = await _repository.convertExcelToPdf(documentId);
// Invalidate relevant providers to automatically refresh lists
_ref.invalidate(documentPageProvider);
_ref.invalidate(studentFoldersProvider);
_ref.invalidate(archiveDocumentPageProvider);
_ref.invalidate(archiveStudentFoldersProvider);
state = const AsyncValue.data(null);
return convertedDoc;
} catch (e, st) {
state = AsyncValue.error(e, st);
rethrow;
} finally {
_convertingDocId = null;
}
}
}
@@ -20,6 +20,7 @@ import '../../shared/dialogs/error_dialog.dart';
import '../../shared/inputs/custom_text_field.dart';
import '../../providers/auth_provider.dart';
import '../../providers/navigation_provider.dart';
import '../../providers/conversion_provider.dart';
import '../documents/widgets/bulk_operations_bar.dart';
import '../documents/widgets/document_preview_modal.dart';
import '../documents/widgets/file_folder_card.dart';
@@ -512,6 +513,24 @@ class _ArchivesScreenState extends ConsumerState<ArchivesScreen>
if (!mounted) return;
showErrorDialog(context, 'Download Failed', e.toString());
}
} else if (action == 'convert_pdf') {
try {
final converted = await ref
.read(conversionProvider.notifier)
.convertToPdf(doc.id);
if (!mounted) return;
showSuccessDialog(
context,
message: 'Excel converted to PDF successfully as "${converted.fileName}".',
);
} catch (e) {
if (!mounted) return;
showErrorDialog(
context,
'Conversion Failed',
e.toString().replaceFirst('Exception: ', ''),
);
}
} else if (action == 'view_profile' && doc.studentId != null) {
showStudentProfileModal(
context,
@@ -13,6 +13,7 @@ import '../../providers/auth_provider.dart';
import '../../shared/widgets/app_pagination.dart';
import '../../shared/widgets/app_error_state.dart';
import '../../providers/navigation_provider.dart';
import '../../providers/conversion_provider.dart';
import '../../shared/dialogs/success_dialog.dart';
import '../../shared/dialogs/error_dialog.dart';
@@ -342,6 +343,24 @@ class _DocumentsScreenState extends ConsumerState<DocumentsScreen>
if (!context.mounted) return;
showErrorDialog(context, 'Download Failed', e.toString());
}
} else if (action == 'convert_pdf') {
try {
final converted = await ref
.read(conversionProvider.notifier)
.convertToPdf(documentId);
if (!mounted) return;
showSuccessDialog(
context,
message: 'Excel converted to PDF successfully as "${converted.fileName}".',
);
} catch (e) {
if (!mounted) return;
showErrorDialog(
context,
'Conversion Failed',
e.toString().replaceFirst('Exception: ', ''),
);
}
}
}
@@ -12,6 +12,7 @@ import '../../../../core/network/api_constants.dart';
import '../../../../core/utils/download_service.dart';
import '../../../../domain/entities/document_model.dart';
import '../../../providers/document_provider.dart';
import '../../../providers/conversion_provider.dart';
import '../../../shared/dialogs/error_dialog.dart';
import '../../../shared/dialogs/success_dialog.dart';
import 'excel_viewer_widget.dart';
@@ -252,6 +253,32 @@ class _DocumentPreviewDialogState
}
}
bool _isConverting = false;
Future<void> _convertToPdf() async {
if (widget.document == null || _isConverting) return;
setState(() => _isConverting = true);
try {
final converted = await ref
.read(conversionProvider.notifier)
.convertToPdf(widget.document!.id);
if (!mounted) return;
showSuccessDialog(
context,
message: 'Excel converted to PDF successfully as "${converted.fileName}".',
);
} catch (e) {
if (!mounted) return;
showErrorDialog(
context,
'Conversion Failed',
e.toString().replaceFirst('Exception: ', ''),
);
} finally {
if (mounted) setState(() => _isConverting = false);
}
}
@override
Widget build(BuildContext context) {
final screenW = MediaQuery.of(context).size.width;
@@ -296,6 +323,21 @@ class _DocumentPreviewDialogState
],
),
actions: [
if (_isExcel && widget.document != null)
IconButton(
icon: _isConverting
? const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(
color: Colors.white,
strokeWidth: 2,
),
)
: const Icon(Icons.picture_as_pdf_outlined, color: Colors.white),
tooltip: 'Convert to PDF',
onPressed: _isConverting ? null : _convertToPdf,
),
if (_isExcel)
IconButton(
icon: _isOpeningExternal
@@ -37,10 +37,12 @@ class FileFolderCard extends StatefulWidget {
class _FileFolderCardState extends State<FileFolderCard> {
// True on Android/iOS — long press is the context menu trigger.
// Desktop uses the ⋮ icon + right-click.
static bool get _isMobile =>
defaultTargetPlatform == TargetPlatform.android ||
defaultTargetPlatform == TargetPlatform.iOS;
bool get _isExcel {
final name = widget.document.fileName.toLowerCase();
return name.endsWith('.xlsx') ||
name.endsWith('.xls') ||
name.endsWith('.csv');
}
IconData get _fileIcon {
final name = widget.document.fileName.toLowerCase();
@@ -50,6 +52,9 @@ class _FileFolderCardState extends State<FileFolderCard> {
name.endsWith('.jpeg')) {
return Icons.image;
}
if (_isExcel) {
return Icons.table_chart;
}
return Icons.insert_drive_file;
}
@@ -61,6 +66,9 @@ class _FileFolderCardState extends State<FileFolderCard> {
name.endsWith('.jpeg')) {
return Colors.blueAccent;
}
if (_isExcel) {
return Colors.green;
}
return AppColors.primaryGreen;
}
@@ -87,6 +95,21 @@ class _FileFolderCardState extends State<FileFolderCard> {
],
),
),
if (_isExcel)
const PopupMenuItem(
value: 'convert_pdf',
child: Row(
children: [
Icon(
Icons.picture_as_pdf_outlined,
size: 18,
color: Colors.deepOrangeAccent,
),
SizedBox(width: 12),
Text('Convert to PDF', style: TextStyle(fontSize: 14)),
],
),
),
const PopupMenuItem(
value: 'queue',
child: Row(
@@ -180,14 +203,12 @@ class _FileFolderCardState extends State<FileFolderCard> {
// ════════════════════════════════════════
Widget _buildGridCard(BuildContext context) {
return GestureDetector(
// Desktop: right-click opens context menu (disabled for teachers)
onSecondaryTapDown: widget.userRole == 'teacher'
? null
: (details) => _showContextMenu(context, details.globalPosition),
// Mobile: long press opens context menu (disabled for teachers)
onLongPressStart: (widget.userRole == 'teacher' || !_isMobile)
? null
: (details) => _showContextMenu(context, details.globalPosition),
// Desktop: right-click opens context menu
onSecondaryTapDown: (details) =>
_showContextMenu(context, details.globalPosition),
// Mobile: long press opens context menu
onLongPressStart: (details) =>
_showContextMenu(context, details.globalPosition),
child: InkWell(
onTap: widget.isMultiSelectMode
? () => widget.onSelectedChanged?.call(!widget.isSelected)
@@ -262,8 +283,8 @@ class _FileFolderCardState extends State<FileFolderCard> {
],
),
),
// ⋮ button only on desktop and not for teachers — on mobile, long press triggers the menu
if (!widget.isMultiSelectMode && !_isMobile && widget.userRole != 'teacher')
// ⋮ button visible on both mobile and desktop
if (!widget.isMultiSelectMode)
Positioned(
top: 2,
right: 2,
@@ -289,12 +310,10 @@ class _FileFolderCardState extends State<FileFolderCard> {
// ════════════════════════════════════════
Widget _buildListRow(BuildContext context) {
return GestureDetector(
onSecondaryTapDown: widget.userRole == 'teacher'
? null
: (details) => _showContextMenu(context, details.globalPosition),
onLongPressStart: (widget.userRole == 'teacher' || !_isMobile)
? null
: (details) => _showContextMenu(context, details.globalPosition),
onSecondaryTapDown: (details) =>
_showContextMenu(context, details.globalPosition),
onLongPressStart: (details) =>
_showContextMenu(context, details.globalPosition),
child: InkWell(
onTap: widget.isMultiSelectMode
? () => widget.onSelectedChanged?.call(!widget.isSelected)
@@ -378,10 +397,10 @@ class _FileFolderCardState extends State<FileFolderCard> {
// Status
Expanded(child: _buildStatusBadge()),
// ⋮ Actions — desktop only; on mobile use long press; hidden for teachers
// ⋮ Actions — visible on desktop & mobile
SizedBox(
width: 40,
child: (widget.isMultiSelectMode || _isMobile || widget.userRole == 'teacher')
child: widget.isMultiSelectMode
? const SizedBox.shrink()
: PopupMenuButton<String>(
icon: Icon(