Add url_launcher dependency and implement hyperlink handling in document viewer

This commit is contained in:
Leon Bösche
2026-01-15 22:28:36 +01:00
parent 2aaf611edb
commit 344395cb2d
4 changed files with 234 additions and 83 deletions

View File

@@ -14,6 +14,7 @@ import '../injection.dart';
import 'package:syncfusion_flutter_pdfviewer/pdfviewer.dart'; import 'package:syncfusion_flutter_pdfviewer/pdfviewer.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'package:http/http.dart' as http; import 'package:http/http.dart' as http;
import 'package:url_launcher/url_launcher.dart';
// Modal version for overlay display // Modal version for overlay display
class DocumentViewerModal extends StatefulWidget { class DocumentViewerModal extends StatefulWidget {
@@ -205,6 +206,8 @@ class _DocumentViewerModalState extends State<DocumentViewerModal> {
headers: {'Authorization': 'Bearer ${state.token}'}, headers: {'Authorization': 'Bearer ${state.token}'},
onDocumentLoadFailed: (details) {}, onDocumentLoadFailed: (details) {},
onDocumentLoaded: (PdfDocumentLoadedDetails details) {}, onDocumentLoaded: (PdfDocumentLoadedDetails details) {},
onHyperlinkClicked: (details) =>
_handleHyperlink(details.uri),
); );
} else if (state.caps.isImage) { } else if (state.caps.isImage) {
// Image viewer // Image viewer
@@ -523,6 +526,75 @@ class _DocumentViewerModalState extends State<DocumentViewerModal> {
return _buildCollaboraIframe(proxyUrl); return _buildCollaboraIframe(proxyUrl);
} }
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(
onPressed: () => Navigator.of(context).pop(false),
child: const Text(
'Cancel',
style: TextStyle(color: AppTheme.primaryText),
),
),
TextButton(
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 @override
void dispose() { void dispose() {
_viewerBloc.close(); _viewerBloc.close();
@@ -700,6 +772,8 @@ class _DocumentViewerState extends State<DocumentViewer> {
: {}, : {},
onDocumentLoadFailed: (details) {}, onDocumentLoadFailed: (details) {},
onDocumentLoaded: (PdfDocumentLoadedDetails details) {}, onDocumentLoaded: (PdfDocumentLoadedDetails details) {},
onHyperlinkClicked: (details) =>
_handleHyperlink(details.uri),
); );
} else if (state.caps.isImage) { } else if (state.caps.isImage) {
// Image viewer // Image viewer
@@ -886,6 +960,75 @@ class _DocumentViewerState extends State<DocumentViewer> {
); );
} }
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(
onPressed: () => Navigator.of(context).pop(false),
child: const Text(
'Cancel',
style: TextStyle(color: AppTheme.primaryText),
),
),
TextButton(
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 @override
void dispose() { void dispose() {
_viewerBloc.close(); _viewerBloc.close();

View File

@@ -122,8 +122,9 @@ class _FileExplorerState extends State<FileExplorer> {
String _formatFileSize(int bytes) { String _formatFileSize(int bytes) {
if (bytes < 1024) return '$bytes B'; if (bytes < 1024) return '$bytes B';
if (bytes < 1024 * 1024) return '${(bytes / 1024).toStringAsFixed(1)} KB'; if (bytes < 1024 * 1024) return '${(bytes / 1024).toStringAsFixed(1)} KB';
if (bytes < 1024 * 1024 * 1024) if (bytes < 1024 * 1024 * 1024) {
return '${(bytes / (1024 * 1024)).toStringAsFixed(1)} MB'; return '${(bytes / (1024 * 1024)).toStringAsFixed(1)} MB';
}
return '${(bytes / (1024 * 1024 * 1024)).toStringAsFixed(2)} GB'; return '${(bytes / (1024 * 1024 * 1024)).toStringAsFixed(2)} GB';
} }
@@ -365,9 +366,10 @@ class _FileExplorerState extends State<FileExplorer> {
void _createAndOpenDocument(String currentPath) async { void _createAndOpenDocument(String currentPath) async {
final docName = await _showCreateDocumentDialog(context); final docName = await _showCreateDocumentDialog(context);
if (docName == null || docName.isEmpty || !context.mounted) return; if (docName == null || docName.isEmpty || !context.mounted) return;
if (mounted) {
// Show creating snackbar // Show creating snackbar
ScaffoldFeatureController<SnackBar, SnackBarClosedReason>? snackController; ScaffoldFeatureController<SnackBar, SnackBarClosedReason>?
snackController;
snackController = ScaffoldMessenger.of(context).showSnackBar( snackController = ScaffoldMessenger.of(context).showSnackBar(
SnackBar( SnackBar(
content: Row( content: Row(
@@ -399,7 +401,8 @@ class _FileExplorerState extends State<FileExplorer> {
final fileService = getIt<FileService>(); final fileService = getIt<FileService>();
// If orgId is 'personal', treat as empty string for personal workspace // If orgId is 'personal', treat as empty string for personal workspace
// Always treat 'personal' or empty orgId as personal workspace // Always treat 'personal' or empty orgId as personal workspace
final effectiveOrgId = (widget.orgId == 'personal' || widget.orgId.isEmpty) final effectiveOrgId =
(widget.orgId == 'personal' || widget.orgId.isEmpty)
? '' ? ''
: widget.orgId; : widget.orgId;
try { try {
@@ -412,7 +415,7 @@ class _FileExplorerState extends State<FileExplorer> {
snackController.close(); snackController.close();
// Refresh file list // Refresh file list
if (context.mounted) { if (mounted) {
context.read<FileBrowserBloc>().add( context.read<FileBrowserBloc>().add(
LoadDirectory(orgId: widget.orgId, path: currentPath), LoadDirectory(orgId: widget.orgId, path: currentPath),
); );
@@ -432,7 +435,7 @@ class _FileExplorerState extends State<FileExplorer> {
errorMsg = data; errorMsg = data;
} }
} }
if (context.mounted) { if (mounted) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar( SnackBar(
content: Row( content: Row(
@@ -454,6 +457,7 @@ class _FileExplorerState extends State<FileExplorer> {
} }
} }
} }
}
Future<void> _showRenameDialog(FileItem file) async { Future<void> _showRenameDialog(FileItem file) async {
final TextEditingController controller = TextEditingController( final TextEditingController controller = TextEditingController(
@@ -647,7 +651,7 @@ class _FileExplorerState extends State<FileExplorer> {
// Dismiss preparing snackbar and show success // Dismiss preparing snackbar and show success
snackController?.close(); snackController?.close();
if (context.mounted) { if (mounted) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar( SnackBar(
content: Row( content: Row(
@@ -674,7 +678,7 @@ class _FileExplorerState extends State<FileExplorer> {
} }
} catch (e) { } catch (e) {
snackController?.close(); snackController?.close();
if (context.mounted) { if (mounted) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar( SnackBar(
content: Row( content: Row(
@@ -762,7 +766,7 @@ class _FileExplorerState extends State<FileExplorer> {
); );
}, },
); );
if (confirmed == true) { if (confirmed == true && mounted) {
context.read<FileBrowserBloc>().add( context.read<FileBrowserBloc>().add(
DeleteFile(orgId: widget.orgId, path: file.path), DeleteFile(orgId: widget.orgId, path: file.path),
); );
@@ -1438,7 +1442,9 @@ class _FileExplorerState extends State<FileExplorer> {
onPressed: () async { onPressed: () async {
final result = await FilePicker.platform final result = await FilePicker.platform
.pickFiles(withData: true); .pickFiles(withData: true);
if (result != null && result.files.isNotEmpty) { if (result != null &&
result.files.isNotEmpty &&
context.mounted) {
final files = result.files final files = result.files
.map( .map(
(file) => FileItem( (file) => FileItem(
@@ -1488,7 +1494,8 @@ class _FileExplorerState extends State<FileExplorer> {
final folderName = final folderName =
await _showCreateFolderDialog(context); await _showCreateFolderDialog(context);
if (folderName != null && if (folderName != null &&
folderName.isNotEmpty) { folderName.isNotEmpty &&
context.mounted) {
context.read<FileBrowserBloc>().add( context.read<FileBrowserBloc>().add(
CreateFolder( CreateFolder(
orgId: widget.orgId, orgId: widget.orgId,

View File

@@ -1334,7 +1334,7 @@ packages:
source: hosted source: hosted
version: "1.1.0" version: "1.1.0"
url_launcher: url_launcher:
dependency: transitive dependency: "direct main"
description: description:
name: url_launcher name: url_launcher
sha256: f6a7e5c4835bb4e3026a04793a4199ca2d14c739ec378fdfe23fc8075d0439f8 sha256: f6a7e5c4835bb4e3026a04793a4199ca2d14c739ec378fdfe23fc8075d0439f8

View File

@@ -48,6 +48,7 @@ dependencies:
path_provider: ^2.1.2 path_provider: ^2.1.2
connectivity_plus: ^7.0.0 connectivity_plus: ^7.0.0
provider: ^6.1.1 provider: ^6.1.1
url_launcher: ^6.2.2
file_picker: ^10.3.7 file_picker: ^10.3.7
flutter_dropzone: ^4.0.0 flutter_dropzone: ^4.0.0
desktop_drop: ^0.7.0 desktop_drop: ^0.7.0