Files
b0esche_cloud/b0esche_cloud/lib/pages/document_viewer.dart
Leon Bösche f94f36350f idle6000
2026-01-16 02:45:44 +01:00

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();
}
}