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: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 {
@@ -205,6 +206,8 @@ class _DocumentViewerModalState extends State<DocumentViewerModal> {
headers: {'Authorization': 'Bearer ${state.token}'},
onDocumentLoadFailed: (details) {},
onDocumentLoaded: (PdfDocumentLoadedDetails details) {},
onHyperlinkClicked: (details) =>
_handleHyperlink(details.uri),
);
} else if (state.caps.isImage) {
// Image viewer
@@ -523,6 +526,75 @@ class _DocumentViewerModalState extends State<DocumentViewerModal> {
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
void dispose() {
_viewerBloc.close();
@@ -700,6 +772,8 @@ class _DocumentViewerState extends State<DocumentViewer> {
: {},
onDocumentLoadFailed: (details) {},
onDocumentLoaded: (PdfDocumentLoadedDetails details) {},
onHyperlinkClicked: (details) =>
_handleHyperlink(details.uri),
);
} else if (state.caps.isImage) {
// 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
void dispose() {
_viewerBloc.close();

View File

@@ -122,8 +122,9 @@ class _FileExplorerState extends State<FileExplorer> {
String _formatFileSize(int bytes) {
if (bytes < 1024) return '$bytes B';
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 * 1024)).toStringAsFixed(2)} GB';
}
@@ -365,92 +366,95 @@ class _FileExplorerState extends State<FileExplorer> {
void _createAndOpenDocument(String currentPath) async {
final docName = await _showCreateDocumentDialog(context);
if (docName == null || docName.isEmpty || !context.mounted) return;
// Show creating snackbar
ScaffoldFeatureController<SnackBar, SnackBarClosedReason>? snackController;
snackController = ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Row(
children: [
SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
color: AppTheme.accentColor,
backgroundColor: AppTheme.accentColor.withValues(alpha: 0.3),
if (mounted) {
// Show creating snackbar
ScaffoldFeatureController<SnackBar, SnackBarClosedReason>?
snackController;
snackController = ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Row(
children: [
SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
color: AppTheme.accentColor,
backgroundColor: AppTheme.accentColor.withValues(alpha: 0.3),
),
),
),
const SizedBox(width: 12),
Expanded(
child: Text(
'Creating $docName.docx...',
overflow: TextOverflow.ellipsis,
style: const TextStyle(color: AppTheme.primaryText),
const SizedBox(width: 12),
Expanded(
child: Text(
'Creating $docName.docx...',
overflow: TextOverflow.ellipsis,
style: const TextStyle(color: AppTheme.primaryText),
),
),
),
],
],
),
duration: const Duration(days: 1),
backgroundColor: AppTheme.primaryBackground,
),
duration: const Duration(days: 1),
backgroundColor: AppTheme.primaryBackground,
),
);
final fileService = getIt<FileService>();
// If orgId is 'personal', treat as empty string for personal workspace
// Always treat 'personal' or empty orgId as personal workspace
final effectiveOrgId = (widget.orgId == 'personal' || widget.orgId.isEmpty)
? ''
: widget.orgId;
try {
final fileId = await fileService.createDocument(
effectiveOrgId,
currentPath,
docName,
);
snackController.close();
// Refresh file list
if (context.mounted) {
context.read<FileBrowserBloc>().add(
LoadDirectory(orgId: widget.orgId, path: currentPath),
final fileService = getIt<FileService>();
// If orgId is 'personal', treat as empty string for personal workspace
// Always treat 'personal' or empty orgId as personal workspace
final effectiveOrgId =
(widget.orgId == 'personal' || widget.orgId.isEmpty)
? ''
: widget.orgId;
try {
final fileId = await fileService.createDocument(
effectiveOrgId,
currentPath,
docName,
);
// Open document viewer/editor with the new document
_showDocumentViewer(widget.orgId, fileId);
}
} catch (e) {
snackController.close();
String errorMsg = e.toString();
// If DioError, try to extract backend error message
if (e is DioException) {
final data = e.response?.data;
if (data is Map && data.containsKey('message')) {
errorMsg = data['message'].toString();
} else if (data is String) {
errorMsg = data;
snackController.close();
// Refresh file list
if (mounted) {
context.read<FileBrowserBloc>().add(
LoadDirectory(orgId: widget.orgId, path: currentPath),
);
// Open document viewer/editor with the new document
_showDocumentViewer(widget.orgId, fileId);
}
}
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Row(
children: [
const Icon(Icons.error_outline, color: Colors.red, size: 20),
const SizedBox(width: 12),
Expanded(
child: Text(
'Failed to create document: $errorMsg',
overflow: TextOverflow.ellipsis,
style: const TextStyle(color: AppTheme.primaryText),
} catch (e) {
snackController.close();
String errorMsg = e.toString();
// If DioError, try to extract backend error message
if (e is DioException) {
final data = e.response?.data;
if (data is Map && data.containsKey('message')) {
errorMsg = data['message'].toString();
} else if (data is String) {
errorMsg = data;
}
}
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Row(
children: [
const Icon(Icons.error_outline, color: Colors.red, size: 20),
const SizedBox(width: 12),
Expanded(
child: Text(
'Failed to create document: $errorMsg',
overflow: TextOverflow.ellipsis,
style: const TextStyle(color: AppTheme.primaryText),
),
),
),
],
],
),
backgroundColor: AppTheme.primaryBackground,
),
backgroundColor: AppTheme.primaryBackground,
),
);
);
}
}
}
}
@@ -647,7 +651,7 @@ class _FileExplorerState extends State<FileExplorer> {
// Dismiss preparing snackbar and show success
snackController?.close();
if (context.mounted) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Row(
@@ -674,7 +678,7 @@ class _FileExplorerState extends State<FileExplorer> {
}
} catch (e) {
snackController?.close();
if (context.mounted) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Row(
@@ -762,7 +766,7 @@ class _FileExplorerState extends State<FileExplorer> {
);
},
);
if (confirmed == true) {
if (confirmed == true && mounted) {
context.read<FileBrowserBloc>().add(
DeleteFile(orgId: widget.orgId, path: file.path),
);
@@ -1438,7 +1442,9 @@ class _FileExplorerState extends State<FileExplorer> {
onPressed: () async {
final result = await FilePicker.platform
.pickFiles(withData: true);
if (result != null && result.files.isNotEmpty) {
if (result != null &&
result.files.isNotEmpty &&
context.mounted) {
final files = result.files
.map(
(file) => FileItem(
@@ -1488,7 +1494,8 @@ class _FileExplorerState extends State<FileExplorer> {
final folderName =
await _showCreateFolderDialog(context);
if (folderName != null &&
folderName.isNotEmpty) {
folderName.isNotEmpty &&
context.mounted) {
context.read<FileBrowserBloc>().add(
CreateFolder(
orgId: widget.orgId,

View File

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

View File

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