diff --git a/frontend/lib/devices/device_actions.dart b/frontend/lib/devices/device_actions.dart index 8f30de9..f651b8b 100644 --- a/frontend/lib/devices/device_actions.dart +++ b/frontend/lib/devices/device_actions.dart @@ -42,7 +42,10 @@ Future confirmForgetDevice( onRefresh(); } catch (e) { if (!context.mounted) return; - UISnackbars.showError(context, 'Failed to forget device: $e'); + UISnackbars.showError( + context, + 'Failed to forget device. ${dioErrorToUserMessage(e)}', + ); } } @@ -157,7 +160,10 @@ Future showEditDeviceDialog( onRefresh(); } catch (e) { if (!context.mounted) return; - UISnackbars.showError(context, 'Failed to update device: $e'); + UISnackbars.showError( + context, + 'Failed to update device. ${dioErrorToUserMessage(e)}', + ); } } @@ -260,6 +266,9 @@ Future showRegisterDeviceDialog( onRefresh(); } catch (e) { if (!context.mounted) return; - UISnackbars.showError(context, 'Failed to register device: $e'); + UISnackbars.showError( + context, + 'Failed to register device. ${dioErrorToUserMessage(e)}', + ); } } diff --git a/frontend/lib/devices/device_list.dart b/frontend/lib/devices/device_list.dart index e83d877..2fc46d4 100644 --- a/frontend/lib/devices/device_list.dart +++ b/frontend/lib/devices/device_list.dart @@ -102,7 +102,7 @@ class _DeviceListState extends State with RouteAware { } catch (e) { if (!mounted) return; setState(() { - _error = e.toString(); + _error = dioErrorToUserMessage(e); _isLoading = false; }); } @@ -235,7 +235,12 @@ class _DeviceListState extends State with RouteAware { return const Center(child: CircularProgressIndicator()); } if (_error != null) { - return Center(child: Text('Error: $_error')); + return Center( + child: Text( + 'Error: $_error', + style: TextStyle(color: Theme.of(context).colorScheme.error), + ), + ); } if (_devices.isEmpty) { final theme = Theme.of(context); diff --git a/frontend/lib/home/notifications_list.dart b/frontend/lib/home/notifications_list.dart index 6562ff5..9fa215f 100644 --- a/frontend/lib/home/notifications_list.dart +++ b/frontend/lib/home/notifications_list.dart @@ -44,6 +44,7 @@ class _NotificationsListState extends State with RouteAware { List _items = []; bool _isLoading = false; bool _hasNextPage = false; + String? _error; @override void initState() { @@ -78,10 +79,16 @@ class _NotificationsListState extends State with RouteAware { Future _fetchPage(int page) async { if (_isLoading) return; - setState(() => _isLoading = true); + setState(() { + _isLoading = true; + _error = null; + }); try { - final results = await BackendAPI.instance - .listNotifications(_filter.isNew, page * _pageSize, limit: _pageSize + 1); + final results = await BackendAPI.instance.listNotifications( + _filter.isNew, + page * _pageSize, + limit: _pageSize + 1, + ); if (!mounted) return; setState(() { _currentPage = page; @@ -89,9 +96,12 @@ class _NotificationsListState extends State with RouteAware { _items = _hasNextPage ? results.take(_pageSize).toList() : results; _isLoading = false; }); - } catch (_) { + } catch (e) { if (!mounted) return; - setState(() => _isLoading = false); + setState(() { + _error = dioErrorToUserMessage(e); + _isLoading = false; + }); } } @@ -205,6 +215,18 @@ class _NotificationsListState extends State with RouteAware { ), ]; } + if (_error != null) { + return [ + SliverFillRemaining( + child: Center( + child: Text( + 'Error: $_error', + style: TextStyle(color: Theme.of(context).colorScheme.error), + ), + ), + ), + ]; + } if (_items.isEmpty) { return [ SliverToBoxAdapter( diff --git a/frontend/lib/utils/oott_api.dart b/frontend/lib/utils/oott_api.dart index 6c65ced..c116cb8 100644 --- a/frontend/lib/utils/oott_api.dart +++ b/frontend/lib/utils/oott_api.dart @@ -12,6 +12,44 @@ import '../model/device_summary.dart'; import '../model/device_type.dart'; import '../model/notification.dart'; +const _connectTimeout = Duration(seconds: 5); +const _receiveTimeout = Duration(seconds: 15); +const _sendTimeout = Duration(seconds: 15); + +String dioErrorToUserMessage(Object error) { + if (error is! DioException) { + debugPrint('Non-Dio error from backend call: $error'); + return 'Unexpected error.'; + } + switch (error.type) { + case DioExceptionType.connectionTimeout: + case DioExceptionType.sendTimeout: + case DioExceptionType.receiveTimeout: + return 'Backend did not respond in time.'; + case DioExceptionType.connectionError: + return 'Cannot reach backend. Check the base URL and your network.'; + case DioExceptionType.badCertificate: + return 'Backend TLS certificate could not be verified.'; + case DioExceptionType.cancel: + return 'Request canceled.'; + case DioExceptionType.badResponse: + final status = error.response?.statusCode; + if (status == 401 || status == 403) { + return 'Authentication failed. Check your API key in Settings.'; + } + if (status == 404) { + return 'Not found.'; + } + if (status != null && status >= 500 && status < 600) { + return 'Backend error (status $status). Please try again.'; + } + return 'Unexpected response from backend (status ${status ?? 'unknown'}).'; + case DioExceptionType.unknown: + debugPrint('Unknown Dio error: ${error.message}'); + return 'Unexpected error contacting backend.'; + } +} + class BackendAPI { static final BackendAPI _instance = BackendAPI._internal(); static const _pageSize = 5; @@ -24,11 +62,14 @@ class BackendAPI { _apiKey = XOR().xorDecode(PrefUtil.getValue("api_key", "") as String); debugPrint('Base URL: $_baseUrl'); - debugPrint('API KEY: $_apiKey'); + debugPrint('API KEY: ${_apiKey.isEmpty ? "" : ""}'); _dio = Dio( BaseOptions( baseUrl: _baseUrl, + connectTimeout: _connectTimeout, + receiveTimeout: _receiveTimeout, + sendTimeout: _sendTimeout, headers: { HttpHeaders.contentTypeHeader: 'application/json', HttpHeaders.authorizationHeader: 'Bearer $_apiKey', @@ -39,10 +80,13 @@ class BackendAPI { // Returns null if the test was successful, and a String with a message about the issue if not static Future test(String baseUrl, String apiKey) async { - debugPrint('About to test API with baseUrl=$baseUrl and apiKey=$apiKey'); + debugPrint('About to test API with baseUrl=$baseUrl'); Dio dio = Dio( BaseOptions( baseUrl: baseUrl, + connectTimeout: _connectTimeout, + receiveTimeout: _receiveTimeout, + sendTimeout: _sendTimeout, headers: { HttpHeaders.contentTypeHeader: 'application/json', HttpHeaders.authorizationHeader: 'Bearer $apiKey', @@ -55,17 +99,8 @@ class BackendAPI { return response.toString().contains('OOTT_API_OK') ? null : "URL successfully called but didn't return the expected value. Check your URL and make sure it points to your OOTT backend base URL."; - } on DioException catch (e) { - if (e.response != null) { - if (e.response!.statusCode == 401) { - return "Authorization failed, check your API Key."; - } else { - return "Error querying the given URL (${e.response!.statusCode} - ${e.message ?? 'N/A'})"; - } - } else { - // Response was null, something happened while sending the message - return "Error sending message to provided URL (${e.message ?? 'no message'})"; - } + } catch (e) { + return dioErrorToUserMessage(e); } } diff --git a/frontend/lib/widgets/scanners_status_card.dart b/frontend/lib/widgets/scanners_status_card.dart index c2f63a4..94c8819 100644 --- a/frontend/lib/widgets/scanners_status_card.dart +++ b/frontend/lib/widgets/scanners_status_card.dart @@ -48,7 +48,7 @@ class _ScannersStatusCardState extends State { try { arp = await BackendAPI.instance.getArpScannerStatus(); } catch (e) { - arpError = e.toString(); + arpError = dioErrorToUserMessage(e); } MdnsScannerStatus? mdns; @@ -56,7 +56,7 @@ class _ScannersStatusCardState extends State { try { mdns = await BackendAPI.instance.getMdnsScannerStatus(); } catch (e) { - mdnsError = e.toString(); + mdnsError = dioErrorToUserMessage(e); } if (!mounted) return; @@ -99,9 +99,9 @@ class _ScannersStatusCardState extends State { children: [ Text('Status', style: Theme.of(context).textTheme.titleMedium), const SizedBox(height: 12), - _scannerRow(context, arpColor, 'ARP', arpText), + _scannerRow(context, arpColor, 'ARP', arpText, _arpError), const SizedBox(height: 8), - _scannerRow(context, mdnsColor, 'mDNS', mdnsText), + _scannerRow(context, mdnsColor, 'mDNS', mdnsText, _mdnsError), ], ), ), @@ -114,10 +114,15 @@ class _ScannersStatusCardState extends State { Color color, String name, String statusText, + String? errorMessage, ) { + Widget dot = Icon(Icons.circle, color: color, size: 12); + if (errorMessage != null) { + dot = Tooltip(message: errorMessage, child: dot); + } return Row( children: [ - Icon(Icons.circle, color: color, size: 12), + dot, const SizedBox(width: 10), Expanded( child: Text(name, style: Theme.of(context).textTheme.bodyMedium),