From 344395cb2d9330a5cc896720f113ebe1dd09e9ed Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Leon=20B=C3=B6sche?= Date: Thu, 15 Jan 2026 22:28:36 +0100 Subject: [PATCH] Add url_launcher dependency and implement hyperlink handling in document viewer --- b0esche_cloud/lib/pages/document_viewer.dart | 143 ++++++++++++++++ b0esche_cloud/lib/pages/file_explorer.dart | 171 ++++++++++--------- b0esche_cloud/pubspec.lock | 2 +- b0esche_cloud/pubspec.yaml | 1 + 4 files changed, 234 insertions(+), 83 deletions(-) diff --git a/b0esche_cloud/lib/pages/document_viewer.dart b/b0esche_cloud/lib/pages/document_viewer.dart index 456a20d..a3579c4 100644 --- a/b0esche_cloud/lib/pages/document_viewer.dart +++ b/b0esche_cloud/lib/pages/document_viewer.dart @@ -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 { 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 { return _buildCollaboraIframe(proxyUrl); } + Future _handleHyperlink(String url) async { + final shouldOpen = await showDialog( + 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 { : {}, 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 { ); } + Future _handleHyperlink(String url) async { + final shouldOpen = await showDialog( + 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(); diff --git a/b0esche_cloud/lib/pages/file_explorer.dart b/b0esche_cloud/lib/pages/file_explorer.dart index a493eed..4aba7c3 100644 --- a/b0esche_cloud/lib/pages/file_explorer.dart +++ b/b0esche_cloud/lib/pages/file_explorer.dart @@ -122,8 +122,9 @@ class _FileExplorerState extends State { 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 { void _createAndOpenDocument(String currentPath) async { final docName = await _showCreateDocumentDialog(context); if (docName == null || docName.isEmpty || !context.mounted) return; - - // Show creating snackbar - ScaffoldFeatureController? 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? + 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(); - // 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().add( - LoadDirectory(orgId: widget.orgId, path: currentPath), + final fileService = getIt(); + // 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().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 { // 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 { } } catch (e) { snackController?.close(); - if (context.mounted) { + if (mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Row( @@ -762,7 +766,7 @@ class _FileExplorerState extends State { ); }, ); - if (confirmed == true) { + if (confirmed == true && mounted) { context.read().add( DeleteFile(orgId: widget.orgId, path: file.path), ); @@ -1438,7 +1442,9 @@ class _FileExplorerState extends State { 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 { final folderName = await _showCreateFolderDialog(context); if (folderName != null && - folderName.isNotEmpty) { + folderName.isNotEmpty && + context.mounted) { context.read().add( CreateFolder( orgId: widget.orgId, diff --git a/b0esche_cloud/pubspec.lock b/b0esche_cloud/pubspec.lock index 57d93c6..2112f33 100644 --- a/b0esche_cloud/pubspec.lock +++ b/b0esche_cloud/pubspec.lock @@ -1334,7 +1334,7 @@ packages: source: hosted version: "1.1.0" url_launcher: - dependency: transitive + dependency: "direct main" description: name: url_launcher sha256: f6a7e5c4835bb4e3026a04793a4199ca2d14c739ec378fdfe23fc8075d0439f8 diff --git a/b0esche_cloud/pubspec.yaml b/b0esche_cloud/pubspec.yaml index 5f9104a..02465bd 100644 --- a/b0esche_cloud/pubspec.yaml +++ b/b0esche_cloud/pubspec.yaml @@ -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