Multi-format document viewer: add image/text/office file type support

This commit is contained in:
Leon Bösche
2026-01-12 00:22:12 +01:00
parent 80411d9231
commit 923b0ede86
3 changed files with 229 additions and 27 deletions

View File

@@ -4,21 +4,28 @@ class DocumentCapabilities extends Equatable {
final bool canEdit;
final bool canAnnotate;
final bool isPdf;
final String mimeType;
const DocumentCapabilities({
required this.canEdit,
required this.canAnnotate,
required this.isPdf,
required this.mimeType,
});
@override
List<Object?> get props => [canEdit, canAnnotate, isPdf];
List<Object?> get props => [canEdit, canAnnotate, isPdf, mimeType];
factory DocumentCapabilities.fromJson(Map<String, dynamic> json) {
return DocumentCapabilities(
canEdit: json['canEdit'],
canAnnotate: json['canAnnotate'],
isPdf: json['isPdf'],
mimeType: json['mimeType'] ?? 'application/octet-stream',
);
}
bool get isImage => mimeType.startsWith('image/');
bool get isText => mimeType.startsWith('text/');
bool get isOffice => mimeType.contains('word') || mimeType.contains('spreadsheet') || mimeType.contains('presentation');
}

View File

@@ -10,6 +10,7 @@ import '../services/file_service.dart';
import '../injection.dart';
import 'package:syncfusion_flutter_pdfviewer/pdfviewer.dart';
import 'package:go_router/go_router.dart';
import 'package:http/http.dart' as http;
// Modal version for overlay display
class DocumentViewerModal extends StatefulWidget {
@@ -177,24 +178,159 @@ class _DocumentViewerModalState extends State<DocumentViewerModal> {
);
}
if (state is DocumentViewerReady) {
// Handle different file types based on MIME type
if (state.caps.isPdf) {
// Log the URL and auth being used for debugging
// Pass token via Authorization header - SfPdfViewer supports headers parameter
// PDF viewer using SfPdfViewer
return SfPdfViewer.network(
state.viewUrl.toString(),
headers: {'Authorization': 'Bearer ${state.token}'},
onDocumentLoadFailed: (details) {},
onDocumentLoaded: (PdfDocumentLoadedDetails details) {},
);
} else {
} else if (state.caps.isImage) {
// Image viewer
return Container(
color: AppTheme.secondaryText,
color: AppTheme.primaryBackground,
child: InteractiveViewer(
minScale: 0.5,
maxScale: 4.0,
child: Image.network(
state.viewUrl.toString(),
headers: {'Authorization': 'Bearer ${state.token}'},
fit: BoxFit.contain,
errorBuilder: (context, error, stackTrace) {
return Center(
child: Text(
'Failed to load image',
style: TextStyle(color: Colors.red[400]),
),
);
},
),
),
);
} else if (state.caps.isText) {
// Text file viewer
return FutureBuilder<String>(
future: _fetchTextContent(
state.viewUrl.toString(),
state.token,
),
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return Center(
child: CircularProgressIndicator(
valueColor: AlwaysStoppedAnimation<Color>(
AppTheme.accentColor,
),
),
);
}
if (snapshot.hasError) {
return Center(
child: Text(
'Error loading text: ${snapshot.error}',
style: TextStyle(color: Colors.red[400]),
),
);
}
return SingleChildScrollView(
child: Container(
color: AppTheme.primaryBackground,
padding: const EdgeInsets.all(16),
child: SelectableText(
snapshot.data ?? '',
style: TextStyle(
color: AppTheme.primaryText,
fontFamily: 'Courier New',
fontSize: 13,
height: 1.5,
),
),
),
);
},
);
} else if (state.caps.isOffice) {
// Office document viewer - show placeholder with download option
return Container(
color: AppTheme.primaryBackground,
child: Center(
child: Text(
'Office Document Viewer\\n(URL: ${state.viewUrl})',
textAlign: TextAlign.center,
style: const TextStyle(color: AppTheme.primaryText),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
state.caps.mimeType.contains('word')
? Icons.description
: state.caps.mimeType.contains('sheet')
? Icons.table_chart
: Icons.folder_open,
size: 64,
color: AppTheme.accentColor,
),
const SizedBox(height: 16),
Text(
'Office Document',
style: TextStyle(
color: AppTheme.primaryText,
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 8),
Text(
'Download to view in your office application',
style: TextStyle(
color: AppTheme.secondaryText,
fontSize: 14,
),
),
const SizedBox(height: 24),
ElevatedButton.icon(
icon: const Icon(Icons.download),
label: const Text('Download File'),
onPressed: () {
// Open file download
final Uri url = Uri.parse(state.viewUrl);
// In a real implementation, you'd use url_launcher
// launchUrl(url);
},
),
],
),
),
);
} else {
// Unknown file type
return Container(
color: AppTheme.primaryBackground,
child: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.description,
size: 64,
color: AppTheme.accentColor,
),
const SizedBox(height: 16),
Text(
'File Type Not Supported',
style: TextStyle(
color: AppTheme.primaryText,
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 8),
Text(
'MIME Type: ${state.caps.mimeType}',
style: TextStyle(
color: AppTheme.secondaryText,
fontSize: 12,
),
),
],
),
),
);
@@ -214,6 +350,23 @@ class _DocumentViewerModalState extends State<DocumentViewerModal> {
);
}
Future<String> _fetchTextContent(String url, String token) async {
try {
final response = await http.get(
Uri.parse(url),
headers: {'Authorization': 'Bearer $token'},
).timeout(const Duration(seconds: 30));
if (response.statusCode == 200) {
return response.body;
} else {
throw Exception('Failed to load text: ${response.statusCode}');
}
} catch (e) {
throw Exception('Error fetching text: $e');
}
}
@override
void dispose() {
_viewerBloc.close();