Files
b0esche_cloud/b0esche_cloud/lib/pages/document_viewer.dart
Leon Bösche 99419748bb Fix: Pass WOPISrc directly to form submission, don't build URL with query params
- Remove URL building with query parameters that was causing GET requests
- Pass WOPISrc directly to _buildCollaboraIframe function
- JavaScript now creates form with POST method and WOPISrc in body
- This ensures Collabora receives POST not GET request
2026-01-12 15:28:50 +01:00

880 lines
31 KiB
Dart

import 'package:flutter/material.dart';
import 'dart:convert';
import 'dart:html' as html;
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;
// 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) {
return Container(
height: 30,
alignment: Alignment.centerLeft,
padding: const EdgeInsets.symmetric(horizontal: 16),
decoration: BoxDecoration(
color: AppTheme.primaryBackground.withValues(alpha: 0.3),
),
child: const Text(
'Last modified: Unknown by Unknown (v1)',
style: 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) {},
);
} 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),
),
),
);
}
final wopiSession = snapshot.data!;
// Don't build URL with query parameters - pass WOPISrc separately to JavaScript
return _buildWebView(wopiSession.wopisrc);
},
);
}
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 wopisrc) {
// For Collabora Online, we need to POST the WOPISrc, not GET it
// Use JavaScript to create and submit the form properly
final String viewType = 'collabora-form-${DateTime.now().millisecondsSinceEpoch}';
ui.platformViewRegistry.registerViewFactory(viewType, (int viewId) {
// Create a container for the form and iframe
final container = html.DivElement()
..style.width = '100%'
..style.height = '100%'
..style.margin = '0'
..style.padding = '0'
..style.overflow = 'hidden';
// Create the iframe first
final iframe = html.IFrameElement()
..name = 'collabora-iframe-$viewId'
..style.border = 'none'
..style.width = '100%'
..style.height = '100%'
..style.margin = '0'
..style.padding = '0'
..setAttribute(
'allow',
'microphone; camera; usb; autoplay; clipboard-read; clipboard-write',
)
..setAttribute(
'sandbox',
'allow-scripts allow-popups allow-forms allow-pointer-lock allow-presentation allow-modals allow-downloads allow-popups-to-escape-sandbox',
);
container.append(iframe);
// Use JavaScript to create and submit the form
// This is more reliable than Dart's form.submit()
final jsCode = '''
(function() {
var form = document.createElement('form');
form.method = 'POST';
form.action = 'https://of.b0esche.cloud/loleaflet/dist/loleaflet.html';
form.target = 'collabora-iframe-$viewId';
form.style.display = 'none';
var input = document.createElement('input');
input.type = 'hidden';
input.name = 'WOPISrc';
input.value = '$wopisrc';
form.appendChild(input);
document.body.appendChild(form);
// Submit the form
setTimeout(function() {
form.submit();
}, 50);
})();
''';
final script = html.ScriptElement()
..type = 'text/javascript'
..text = jsCode;
html.document.body!.append(script);
return container;
});
return HtmlElementView(viewType: viewType);
}
Widget _buildWebView(String wopisrc) {
// Embed Collabora Online in an iframe for web platform
return _buildCollaboraIframe(wopisrc);
}
@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) {
// Placeholder for meta
return Container(
height: 30,
alignment: Alignment.centerLeft,
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Text(
'Last modified: Unknown by Unknown (v1)',
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) {},
);
} 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);
},
),
],
),
),
);
}
@override
void dispose() {
_viewerBloc.close();
super.dispose();
}
}