1053 lines
38 KiB
Dart
1053 lines
38 KiB
Dart
import 'package:flutter/material.dart';
|
|
import 'dart:convert';
|
|
import 'package:web/web.dart' as web;
|
|
import 'dart:ui_web' as ui;
|
|
import '../theme/app_theme.dart';
|
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
|
import '../blocs/document_viewer/document_viewer_bloc.dart';
|
|
import '../blocs/document_viewer/document_viewer_event.dart';
|
|
import '../blocs/document_viewer/document_viewer_state.dart';
|
|
import '../blocs/session/session_bloc.dart';
|
|
import '../blocs/session/session_state.dart';
|
|
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;
|
|
import 'package:url_launcher/url_launcher.dart';
|
|
|
|
// Modal version for overlay display
|
|
class DocumentViewerModal extends StatefulWidget {
|
|
final String orgId;
|
|
final String fileId;
|
|
final VoidCallback onClose;
|
|
final VoidCallback onEdit;
|
|
|
|
const DocumentViewerModal({
|
|
super.key,
|
|
required this.orgId,
|
|
required this.fileId,
|
|
required this.onClose,
|
|
required this.onEdit,
|
|
});
|
|
|
|
@override
|
|
State<DocumentViewerModal> createState() => _DocumentViewerModalState();
|
|
}
|
|
|
|
class _DocumentViewerModalState extends State<DocumentViewerModal> {
|
|
late DocumentViewerBloc _viewerBloc;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_viewerBloc = DocumentViewerBloc(getIt<FileService>());
|
|
_viewerBloc.add(DocumentOpened(orgId: widget.orgId, fileId: widget.fileId));
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return BlocProvider.value(
|
|
value: _viewerBloc,
|
|
child: Column(
|
|
children: [
|
|
// Custom AppBar
|
|
Container(
|
|
height: 56,
|
|
decoration: BoxDecoration(
|
|
color: AppTheme.primaryBackground.withValues(alpha: 0.9),
|
|
border: Border(
|
|
bottom: BorderSide(
|
|
color: AppTheme.accentColor.withValues(alpha: 0.3),
|
|
),
|
|
),
|
|
),
|
|
child: Row(
|
|
children: [
|
|
const SizedBox(width: 16),
|
|
const Text(
|
|
'Document Viewer',
|
|
style: TextStyle(
|
|
color: AppTheme.primaryText,
|
|
fontSize: 18,
|
|
fontWeight: FontWeight.bold,
|
|
),
|
|
),
|
|
const Spacer(),
|
|
BlocBuilder<DocumentViewerBloc, DocumentViewerState>(
|
|
builder: (context, state) {
|
|
if (state is DocumentViewerReady && state.caps.canEdit) {
|
|
return IconButton(
|
|
icon: const Icon(
|
|
Icons.edit,
|
|
color: AppTheme.primaryText,
|
|
),
|
|
onPressed: widget.onEdit,
|
|
);
|
|
}
|
|
return const SizedBox.shrink();
|
|
},
|
|
),
|
|
IconButton(
|
|
icon: const Icon(Icons.refresh, color: AppTheme.primaryText),
|
|
splashColor: Colors.transparent,
|
|
highlightColor: Colors.transparent,
|
|
onPressed: () {
|
|
_viewerBloc.add(DocumentReloaded());
|
|
},
|
|
),
|
|
IconButton(
|
|
icon: const Icon(Icons.close, color: AppTheme.primaryText),
|
|
splashColor: Colors.transparent,
|
|
highlightColor: Colors.transparent,
|
|
onPressed: () {
|
|
_viewerBloc.add(DocumentClosed());
|
|
widget.onClose();
|
|
},
|
|
),
|
|
const SizedBox(width: 8),
|
|
],
|
|
),
|
|
),
|
|
// Meta info bar
|
|
BlocBuilder<DocumentViewerBloc, DocumentViewerState>(
|
|
builder: (context, state) {
|
|
if (state is DocumentViewerReady) {
|
|
final fileInfo = state.fileInfo;
|
|
String lastModifiedText = 'Last modified: Unknown';
|
|
if (fileInfo != null) {
|
|
final modifiedDate = fileInfo.lastModified;
|
|
final modifiedBy = fileInfo.modifiedByName;
|
|
if (modifiedDate != null) {
|
|
final formattedDate =
|
|
'${modifiedDate.day.toString().padLeft(2, '0')}.${modifiedDate.month.toString().padLeft(2, '0')}.${modifiedDate.year} ${modifiedDate.hour.toString().padLeft(2, '0')}:${modifiedDate.minute.toString().padLeft(2, '0')}';
|
|
if (modifiedBy != null && modifiedBy.isNotEmpty) {
|
|
lastModifiedText =
|
|
'Last modified: $formattedDate by $modifiedBy';
|
|
} else {
|
|
lastModifiedText = 'Last modified: $formattedDate';
|
|
}
|
|
}
|
|
}
|
|
return Container(
|
|
height: 30,
|
|
alignment: Alignment.centerLeft,
|
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
|
decoration: BoxDecoration(
|
|
color: AppTheme.primaryBackground.withValues(alpha: 0.3),
|
|
),
|
|
child: Text(
|
|
lastModifiedText,
|
|
style: const TextStyle(
|
|
fontSize: 12,
|
|
color: AppTheme.secondaryText,
|
|
),
|
|
),
|
|
);
|
|
}
|
|
return const SizedBox.shrink();
|
|
},
|
|
),
|
|
// Document content
|
|
Expanded(
|
|
child: BlocBuilder<DocumentViewerBloc, DocumentViewerState>(
|
|
builder: (context, state) {
|
|
if (state is DocumentViewerLoading) {
|
|
return Container(
|
|
color: AppTheme.primaryBackground,
|
|
child: const Center(
|
|
child: CircularProgressIndicator(
|
|
valueColor: AlwaysStoppedAnimation<Color>(
|
|
AppTheme.accentColor,
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
if (state is DocumentViewerError) {
|
|
return Center(
|
|
child: Text(
|
|
'Error: ${state.message}',
|
|
style: const TextStyle(color: AppTheme.primaryText),
|
|
),
|
|
);
|
|
}
|
|
if (state is DocumentViewerSessionExpired) {
|
|
return Center(
|
|
child: Column(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
children: [
|
|
const Text(
|
|
'Your viewing session expired. Click to reopen.',
|
|
style: TextStyle(color: AppTheme.primaryText),
|
|
),
|
|
const SizedBox(height: 16),
|
|
ElevatedButton(
|
|
onPressed: () {
|
|
_viewerBloc.add(
|
|
DocumentOpened(
|
|
orgId: widget.orgId,
|
|
fileId: widget.fileId,
|
|
),
|
|
);
|
|
},
|
|
child: const Text('Reload'),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
if (state is DocumentViewerReady) {
|
|
// Handle different file types based on MIME type
|
|
if (state.caps.isPdf) {
|
|
// PDF viewer using SfPdfViewer
|
|
return SfPdfViewer.network(
|
|
state.viewUrl.toString(),
|
|
headers: {'Authorization': 'Bearer ${state.token}'},
|
|
onDocumentLoadFailed: (details) {},
|
|
onDocumentLoaded: (PdfDocumentLoadedDetails details) {},
|
|
canShowHyperlinkDialog: false,
|
|
onHyperlinkClicked: (details) =>
|
|
_handleHyperlink(details.uri),
|
|
);
|
|
} else if (state.caps.isImage) {
|
|
// Image viewer
|
|
return Container(
|
|
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 using Collabora Online
|
|
return _buildCollaboraViewer(
|
|
state.viewUrl.toString(),
|
|
state.token,
|
|
);
|
|
} 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,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
return const Center(
|
|
child: Text(
|
|
'No document loaded',
|
|
style: TextStyle(color: AppTheme.primaryText),
|
|
),
|
|
);
|
|
},
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
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');
|
|
}
|
|
}
|
|
|
|
Widget _buildCollaboraViewer(String documentUrl, String token) {
|
|
// Create WOPI session to get WOPISrc URL
|
|
return FutureBuilder<WOPISession>(
|
|
future: _createWOPISession(token),
|
|
builder: (context, snapshot) {
|
|
if (snapshot.connectionState == ConnectionState.waiting) {
|
|
return Container(
|
|
color: AppTheme.primaryBackground,
|
|
child: Center(
|
|
child: Column(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
children: [
|
|
CircularProgressIndicator(
|
|
valueColor: AlwaysStoppedAnimation<Color>(
|
|
AppTheme.accentColor,
|
|
),
|
|
),
|
|
const SizedBox(height: 16),
|
|
Text(
|
|
'Loading document in Collabora Online...',
|
|
style: TextStyle(
|
|
color: AppTheme.secondaryText,
|
|
fontSize: 12,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
if (snapshot.hasError) {
|
|
return Container(
|
|
color: AppTheme.primaryBackground,
|
|
child: Center(
|
|
child: Column(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
children: [
|
|
Icon(Icons.error_outline, size: 64, color: Colors.red[400]),
|
|
const SizedBox(height: 16),
|
|
Text(
|
|
'Failed to load document',
|
|
style: TextStyle(
|
|
color: AppTheme.primaryText,
|
|
fontSize: 18,
|
|
fontWeight: FontWeight.bold,
|
|
),
|
|
),
|
|
const SizedBox(height: 8),
|
|
Text(
|
|
'${snapshot.error}',
|
|
style: TextStyle(
|
|
color: AppTheme.secondaryText,
|
|
fontSize: 12,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
if (!snapshot.hasData) {
|
|
return Container(
|
|
color: AppTheme.primaryBackground,
|
|
child: const Center(
|
|
child: Text(
|
|
'No session data',
|
|
style: TextStyle(color: AppTheme.primaryText),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
// ignore: unused_local_variable
|
|
final wopiSession = snapshot.data!;
|
|
|
|
// Use backend proxy endpoint to serve the Collabora form
|
|
final proxyUrl = _buildProxyUrl(token);
|
|
return _buildWebView(proxyUrl);
|
|
},
|
|
);
|
|
}
|
|
|
|
String _buildProxyUrl(String token) {
|
|
// Build the proxy URL based on whether we're in org or user workspace
|
|
String baseUrl = 'https://go.b0esche.cloud';
|
|
String endpoint;
|
|
|
|
if (widget.orgId.isNotEmpty && widget.orgId != 'personal') {
|
|
endpoint = '/orgs/${widget.orgId}/files/${widget.fileId}/collabora-proxy';
|
|
} else {
|
|
endpoint = '/user/files/${widget.fileId}/collabora-proxy';
|
|
}
|
|
|
|
// Pass token as query parameter for iframe (which cannot send Authorization header)
|
|
return '$baseUrl$endpoint?token=$token';
|
|
}
|
|
|
|
Future<WOPISession> _createWOPISession(String token) async {
|
|
try {
|
|
// Use default base URL from backend
|
|
String baseUrl = 'https://go.b0esche.cloud';
|
|
|
|
// Determine endpoint based on whether we're in org or user workspace
|
|
String endpoint;
|
|
if (widget.orgId.isNotEmpty && widget.orgId != 'personal') {
|
|
endpoint = '/orgs/${widget.orgId}/files/${widget.fileId}/wopi-session';
|
|
} else {
|
|
endpoint = '/user/files/${widget.fileId}/wopi-session';
|
|
}
|
|
|
|
final response = await http
|
|
.post(
|
|
Uri.parse('$baseUrl$endpoint'),
|
|
headers: {
|
|
'Authorization': 'Bearer $token',
|
|
'Content-Type': 'application/json',
|
|
},
|
|
)
|
|
.timeout(const Duration(seconds: 10));
|
|
|
|
if (response.statusCode == 200) {
|
|
final json = jsonDecode(response.body) as Map<String, dynamic>;
|
|
return WOPISession(
|
|
wopisrc: json['wopi_src'] as String,
|
|
accessToken: json['access_token'] as String,
|
|
);
|
|
} else {
|
|
throw Exception(
|
|
'Failed to create WOPI session: ${response.statusCode}',
|
|
);
|
|
}
|
|
} catch (e) {
|
|
throw Exception('Error creating WOPI session: $e');
|
|
}
|
|
}
|
|
|
|
Widget _buildCollaboraIframe(String proxyUrl) {
|
|
// Load the backend proxy page which handles Collabora form submission
|
|
final String viewType =
|
|
'collabora-${DateTime.now().millisecondsSinceEpoch}';
|
|
|
|
ui.platformViewRegistry.registerViewFactory(viewType, (int viewId) {
|
|
// Create iframe pointing to the proxy endpoint
|
|
final iframe = web.HTMLIFrameElement()
|
|
..style.border = 'none'
|
|
..style.width = '100%'
|
|
..style.height = '100%'
|
|
..style.margin = '0'
|
|
..style.padding = '0'
|
|
..src = proxyUrl
|
|
..setAttribute(
|
|
'allow',
|
|
'microphone; camera; usb; autoplay; clipboard-read; clipboard-write; fullscreen',
|
|
)
|
|
..setAttribute(
|
|
'sandbox',
|
|
'allow-same-origin allow-scripts allow-popups allow-popups-to-escape-sandbox allow-forms allow-presentation',
|
|
);
|
|
|
|
final container = web.HTMLDivElement()
|
|
..style.width = '100%'
|
|
..style.height = '100%'
|
|
..style.margin = '0'
|
|
..style.padding = '0'
|
|
..style.overflow = 'hidden'
|
|
..append(iframe);
|
|
|
|
return container;
|
|
});
|
|
|
|
return HtmlElementView(viewType: viewType);
|
|
}
|
|
|
|
Widget _buildWebView(String proxyUrl) {
|
|
// Embed Collabora Online via proxy endpoint
|
|
return _buildCollaboraIframe(proxyUrl);
|
|
}
|
|
|
|
Future<void> _handleHyperlink(String url) async {
|
|
final shouldOpen = await showDialog<bool>(
|
|
context: context,
|
|
builder: (BuildContext context) {
|
|
return Dialog(
|
|
backgroundColor: AppTheme.primaryBackground.withValues(alpha: 0.65),
|
|
child: ConstrainedBox(
|
|
constraints: const BoxConstraints(maxWidth: 400),
|
|
child: Container(
|
|
decoration: AppTheme.glassDecoration,
|
|
padding: const EdgeInsets.all(16),
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
const Text(
|
|
'Open Link',
|
|
style: TextStyle(
|
|
color: AppTheme.primaryText,
|
|
fontSize: 18,
|
|
fontWeight: FontWeight.bold,
|
|
),
|
|
),
|
|
const SizedBox(height: 16),
|
|
Text(
|
|
'Open this link in your browser?\n\n$url',
|
|
style: const TextStyle(color: AppTheme.primaryText),
|
|
textAlign: TextAlign.center,
|
|
),
|
|
const SizedBox(height: 24),
|
|
Row(
|
|
mainAxisAlignment: MainAxisAlignment.end,
|
|
children: [
|
|
TextButton(
|
|
style: ButtonStyle(
|
|
splashFactory: NoSplash.splashFactory,
|
|
),
|
|
onPressed: () => Navigator.of(context).pop(false),
|
|
child: const Text(
|
|
'Cancel',
|
|
style: TextStyle(color: Colors.red),
|
|
),
|
|
),
|
|
TextButton(
|
|
style: ButtonStyle(
|
|
splashFactory: NoSplash.splashFactory,
|
|
),
|
|
onPressed: () => Navigator.of(context).pop(true),
|
|
child: const Text(
|
|
'Open',
|
|
style: TextStyle(
|
|
color: AppTheme.accentColor,
|
|
decoration: TextDecoration.underline,
|
|
decorationColor: AppTheme.accentColor,
|
|
decorationThickness: 1.5,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
},
|
|
);
|
|
|
|
if (shouldOpen == true) {
|
|
final uri = Uri.parse(url);
|
|
if (await canLaunchUrl(uri)) {
|
|
await launchUrl(uri, mode: LaunchMode.externalApplication);
|
|
}
|
|
}
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_viewerBloc.close();
|
|
super.dispose();
|
|
}
|
|
}
|
|
|
|
// WOPI Session model for Collabora Online integration
|
|
class WOPISession {
|
|
final String wopisrc;
|
|
final String accessToken;
|
|
|
|
WOPISession({required this.wopisrc, required this.accessToken});
|
|
|
|
factory WOPISession.fromJson(Map<String, dynamic> json) {
|
|
return WOPISession(
|
|
wopisrc: json['wopi_src'] as String,
|
|
accessToken: json['access_token'] as String,
|
|
);
|
|
}
|
|
}
|
|
|
|
// Original page version (for routing if needed)
|
|
class DocumentViewer extends StatefulWidget {
|
|
final String orgId;
|
|
final String fileId;
|
|
|
|
const DocumentViewer({super.key, required this.orgId, required this.fileId});
|
|
|
|
@override
|
|
State<DocumentViewer> createState() => _DocumentViewerState();
|
|
}
|
|
|
|
class _DocumentViewerState extends State<DocumentViewer> {
|
|
late DocumentViewerBloc _viewerBloc;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_viewerBloc = DocumentViewerBloc(getIt<FileService>());
|
|
_viewerBloc.add(DocumentOpened(orgId: widget.orgId, fileId: widget.fileId));
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return BlocProvider.value(
|
|
value: _viewerBloc,
|
|
child: Scaffold(
|
|
appBar: AppBar(
|
|
title: const Text('Document Viewer'),
|
|
bottom: PreferredSize(
|
|
preferredSize: const Size.fromHeight(30),
|
|
child: BlocBuilder<DocumentViewerBloc, DocumentViewerState>(
|
|
builder: (context, state) {
|
|
if (state is DocumentViewerReady) {
|
|
final fileInfo = state.fileInfo;
|
|
String lastModifiedText = 'Last modified: Unknown';
|
|
if (fileInfo != null) {
|
|
final modifiedDate = fileInfo.lastModified;
|
|
final modifiedBy = fileInfo.modifiedByName;
|
|
if (modifiedDate != null) {
|
|
final formattedDate =
|
|
'${modifiedDate.day.toString().padLeft(2, '0')}.${modifiedDate.month.toString().padLeft(2, '0')}.${modifiedDate.year} ${modifiedDate.hour.toString().padLeft(2, '0')}:${modifiedDate.minute.toString().padLeft(2, '0')}';
|
|
if (modifiedBy != null && modifiedBy.isNotEmpty) {
|
|
lastModifiedText =
|
|
'Last modified: $formattedDate by $modifiedBy';
|
|
} else {
|
|
lastModifiedText = 'Last modified: $formattedDate';
|
|
}
|
|
}
|
|
}
|
|
return Container(
|
|
height: 30,
|
|
alignment: Alignment.centerLeft,
|
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
|
child: Text(
|
|
lastModifiedText,
|
|
style: const TextStyle(fontSize: 12),
|
|
),
|
|
);
|
|
}
|
|
return const SizedBox.shrink();
|
|
},
|
|
),
|
|
),
|
|
actions: [
|
|
BlocBuilder<DocumentViewerBloc, DocumentViewerState>(
|
|
builder: (context, state) {
|
|
if (state is DocumentViewerReady && state.caps.canEdit) {
|
|
return IconButton(
|
|
icon: const Icon(Icons.edit),
|
|
onPressed: () {
|
|
GoRouter.of(
|
|
context,
|
|
).go('/editor/${widget.orgId}/${widget.fileId}');
|
|
},
|
|
);
|
|
}
|
|
return const SizedBox.shrink();
|
|
},
|
|
),
|
|
IconButton(
|
|
icon: const Icon(Icons.refresh),
|
|
splashColor: Colors.transparent,
|
|
highlightColor: Colors.transparent,
|
|
onPressed: () {
|
|
_viewerBloc.add(DocumentReloaded());
|
|
},
|
|
),
|
|
IconButton(
|
|
icon: const Icon(Icons.close),
|
|
splashColor: Colors.transparent,
|
|
highlightColor: Colors.transparent,
|
|
onPressed: () {
|
|
_viewerBloc.add(DocumentClosed());
|
|
Navigator.of(context).pop();
|
|
},
|
|
),
|
|
],
|
|
),
|
|
body: BlocBuilder<DocumentViewerBloc, DocumentViewerState>(
|
|
builder: (context, state) {
|
|
if (state is DocumentViewerLoading) {
|
|
return Container(
|
|
color: AppTheme.primaryBackground,
|
|
child: const Center(
|
|
child: CircularProgressIndicator(
|
|
valueColor: AlwaysStoppedAnimation<Color>(
|
|
AppTheme.accentColor,
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
if (state is DocumentViewerError) {
|
|
return Center(child: Text('Error: ${state.message}'));
|
|
}
|
|
if (state is DocumentViewerSessionExpired) {
|
|
return Center(
|
|
child: Column(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
children: [
|
|
const Text(
|
|
'Your viewing session expired. Click to reopen.',
|
|
),
|
|
ElevatedButton(
|
|
onPressed: () {
|
|
_viewerBloc.add(
|
|
DocumentOpened(
|
|
orgId: widget.orgId,
|
|
fileId: widget.fileId,
|
|
),
|
|
);
|
|
},
|
|
child: const Text('Reload'),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
if (state is DocumentViewerReady) {
|
|
return BlocBuilder<SessionBloc, SessionState>(
|
|
builder: (context, sessionState) {
|
|
String? token;
|
|
if (sessionState is SessionActive) {
|
|
token = sessionState.token;
|
|
}
|
|
|
|
if (state.caps.isPdf) {
|
|
// PDF viewer using SfPdfViewer
|
|
return SfPdfViewer.network(
|
|
state.viewUrl.toString(),
|
|
headers: token != null
|
|
? {'Authorization': 'Bearer $token'}
|
|
: {},
|
|
onDocumentLoadFailed: (details) {},
|
|
onDocumentLoaded: (PdfDocumentLoadedDetails details) {},
|
|
canShowHyperlinkDialog: false,
|
|
enableHyperlinkNavigation: false,
|
|
onHyperlinkClicked: (details) =>
|
|
_handleHyperlink(details.uri),
|
|
);
|
|
} else if (state.caps.isImage) {
|
|
// Image viewer
|
|
return Container(
|
|
color: AppTheme.primaryBackground,
|
|
child: InteractiveViewer(
|
|
minScale: 0.5,
|
|
maxScale: 4.0,
|
|
child: Image.network(
|
|
state.viewUrl.toString(),
|
|
headers: token != null
|
|
? {'Authorization': 'Bearer $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(),
|
|
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 using Collabora Online
|
|
return _buildCollaboraViewerPage(
|
|
state.viewUrl.toString(),
|
|
token ?? '',
|
|
);
|
|
} 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,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
},
|
|
);
|
|
}
|
|
return const Center(child: Text('No document loaded'));
|
|
},
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
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');
|
|
}
|
|
}
|
|
|
|
Widget _buildCollaboraViewerPage(String documentUrl, String token) {
|
|
// Build HTML to embed Collabora Online viewer
|
|
// For now, we'll show the document download option with a link to open in Collabora
|
|
// A proper implementation would require WOPI protocol support
|
|
|
|
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(
|
|
'Office Document',
|
|
style: TextStyle(
|
|
color: AppTheme.primaryText,
|
|
fontSize: 18,
|
|
fontWeight: FontWeight.bold,
|
|
),
|
|
),
|
|
const SizedBox(height: 8),
|
|
Text(
|
|
'Collabora Online Viewer',
|
|
style: TextStyle(color: AppTheme.secondaryText, fontSize: 14),
|
|
),
|
|
const SizedBox(height: 16),
|
|
Text(
|
|
'Opening document in Collabora...',
|
|
style: TextStyle(color: AppTheme.secondaryText, fontSize: 12),
|
|
),
|
|
const SizedBox(height: 24),
|
|
CircularProgressIndicator(
|
|
valueColor: AlwaysStoppedAnimation<Color>(AppTheme.accentColor),
|
|
),
|
|
const SizedBox(height: 24),
|
|
ElevatedButton.icon(
|
|
icon: const Icon(Icons.download),
|
|
label: const Text('Download File'),
|
|
onPressed: () {
|
|
// Open file download
|
|
// In a real implementation, you'd use url_launcher
|
|
// launchUrl(state.viewUrl);
|
|
},
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Future<void> _handleHyperlink(String url) async {
|
|
final shouldOpen = await showDialog<bool>(
|
|
context: context,
|
|
builder: (BuildContext context) {
|
|
return Dialog(
|
|
backgroundColor: Colors.transparent,
|
|
child: ConstrainedBox(
|
|
constraints: const BoxConstraints(maxWidth: 400),
|
|
child: Container(
|
|
decoration: AppTheme.glassDecoration,
|
|
padding: const EdgeInsets.all(16),
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
const Text(
|
|
'Open Link',
|
|
style: TextStyle(
|
|
color: AppTheme.primaryText,
|
|
fontSize: 18,
|
|
fontWeight: FontWeight.bold,
|
|
),
|
|
),
|
|
const SizedBox(height: 16),
|
|
Text(
|
|
'Do you want to open this link in your browser?\n\n$url',
|
|
style: const TextStyle(color: AppTheme.primaryText),
|
|
textAlign: TextAlign.center,
|
|
),
|
|
const SizedBox(height: 24),
|
|
Row(
|
|
mainAxisAlignment: MainAxisAlignment.end,
|
|
children: [
|
|
TextButton(
|
|
style: ButtonStyle(
|
|
splashFactory: NoSplash.splashFactory,
|
|
),
|
|
onPressed: () => Navigator.of(context).pop(false),
|
|
child: const Text(
|
|
'Cancel',
|
|
style: TextStyle(color: AppTheme.primaryText),
|
|
),
|
|
),
|
|
TextButton(
|
|
style: ButtonStyle(
|
|
splashFactory: NoSplash.splashFactory,
|
|
),
|
|
onPressed: () => Navigator.of(context).pop(true),
|
|
child: const Text(
|
|
'Open',
|
|
style: TextStyle(
|
|
color: AppTheme.accentColor,
|
|
decoration: TextDecoration.underline,
|
|
decorationColor: AppTheme.accentColor,
|
|
decorationThickness: 1.5,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
},
|
|
);
|
|
|
|
if (shouldOpen == true) {
|
|
final uri = Uri.parse(url);
|
|
if (await canLaunchUrl(uri)) {
|
|
await launchUrl(uri, mode: LaunchMode.externalApplication);
|
|
}
|
|
}
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_viewerBloc.close();
|
|
super.dispose();
|
|
}
|
|
}
|