Add url_launcher dependency and implement hyperlink handling in document viewer
This commit is contained in:
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -1334,7 +1334,7 @@ packages:
|
||||
source: hosted
|
||||
version: "1.1.0"
|
||||
url_launcher:
|
||||
dependency: transitive
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: url_launcher
|
||||
sha256: f6a7e5c4835bb4e3026a04793a4199ca2d14c739ec378fdfe23fc8075d0439f8
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user