import 'dart:async'; import 'dart:io'; import 'package:dartx/dartx.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:http/http.dart'; import 'package:ollama_dart/ollama_dart.dart' as llama; import 'package:url_launcher/url_launcher.dart'; import '../l10n/gen/app_localizations.dart'; import 'markdown.dart'; import 'services.dart'; final _logIdRegex = RegExp(r"^[A-Z0-9]{2,16}$"); String _removeNewlines(String content) => content.replaceAll(RegExp(r"\s*\n\s*"), " "); typedef ErrorGuardErrorMessageGenerator = String Function(Object exception); typedef ErrorGuardDetailsMessageGenerator = String? Function(Object exception, StackTrace stackTrace); typedef ErrorGuardIgnoreIfGenerator = bool Function(Object exception); String _defaultErrorMessage(Object exception) => switch (exception) { llama.OllamaException _ => "Unknown client error", AssertionError _ => exception.toString().split(": ").elementAtOrNull(4) ?? "An assertion failed", TimeoutException _ => "Request timed out", SocketException _ || HttpException _ || ClientException _ => "Could not connect to server", TlsException _ => "Could not establish secure connection", StateError _ => "Invalid state encountered", _ => "An unknown error occurred", }; String? _defaultDetailsMessage( Object exception, StackTrace stackTrace, ) => switch (exception) { final llama.OllamaException e => [ SocketException, HttpException, ClientException, ].contains(e.cause.runtimeType) ? "A network error occurred while trying to connect to the server." "\n\nYou may check your network connection and server reachability and try again." : "The Ollama API client received a faulty response with code `${e.message.split(":").last.trim()}`." "\n\nPlease check your Ollama server or proxy configuration and try again.", AssertionError _ => "An assertion failed, meaning that the app is misconfigured or a bug occurred." "\n\nAssertions are used to check for conditions that should never happen." "\n\n", TimeoutException _ => "Time ran out while waiting for a response from the server." "\n\nThis might be caused by a slow or unresponsive server, or a network issue.\nYou may try increasing the Timeout Multiplier in the settings.", SocketException _ || HttpException _ || ClientException _ => "A ${exception.toString().split(":").first} might be caused by a slow or unresponsive server, or a network issue." "\n\nYou may check your network connection and server reachability and try again.", TlsException _ => "An error occurred while trying to establish a secure connection via TLS." "\n\nThis might be caused by an invalid or expired certificate, though this should not happen. Please report this issue to the developers.", StateError _ => "Now, this is not good.\n\nYou should report this issue to the developers. Try restarting the app.", _ => null, }; bool _defaultIgnoreIf(Object exception) => false; ErrorGuardErrorMessageGenerator errorGuardErrorMessageWithFallback( String? Function(Object exception) errorMessage, ) => (Object exception) { try { return errorMessage.call(exception)!; } catch (e) { return _defaultErrorMessage(exception); } }; ErrorGuardDetailsMessageGenerator errorGuardDetailsMessageWithFallback( ErrorGuardDetailsMessageGenerator detailsMessage, ) => (Object exception, StackTrace stackTrace) { try { // throws error if null, so catch block is called return detailsMessage.call(exception, stackTrace)!; } catch (_) { return _defaultDetailsMessage(exception, stackTrace); } }; ErrorGuardErrorMessageGenerator errorGuardErrorMessageWithFallbackSingle( Type exceptionType, String message, ) => errorGuardErrorMessageWithFallback( (exception) => (exception.runtimeType == exceptionType) ? message : null, ); ErrorGuardDetailsMessageGenerator errorGuardDetailsMessageWithFallbackSingle( Type exception, String message, ) => errorGuardDetailsMessageWithFallback( (e, _) => (e.runtimeType == exception) ? message : null, ); /// Runs the given [action] and catches any exceptions that occur. /// /// If the execution of [action] is successful, the result of the function is /// returned. /// /// If an exception occurs, a SnackBar is shown with the error message. The user /// can then view more details about the error and where is occurred and is able /// to report it. /// /// If this function returns `null`, all code flow following this call should /// be skipped. /// /// ***Important:*** You must catch all async functions carefully! Do this using /// [Future.catchError] with [Error.throwWithStackTrace] as the callback. /// /// [logId] is an optional identifier for this [errorGuard] call. It is /// displayed in the UI dialog and submitted with the report, if done. This /// should not be a word, but rather a random string. This should be constant /// across app restarts or re-compiles. It must be a string of 2 to 16 /// alphanumeric characters, starting with a letter. Other values will be /// ignored. The recommended length is 8 characters. /// /// To generate a random log ID, run: `dart run tool/logid.dart` /// /// The [errorMessage] function is used to generate the error message. This /// should be a short message that describes the error in a user-friendly way. /// It should not contain any technical details or stack traces, but rather /// a simple description of what task went wrong. The [errorMessage.exception] /// parameter should only be used to determine the type of error and should not /// be used to generate the message directly. The message should not be longer /// than about 60 characters, while it is recommended to keep it under 35 /// characters. /// An example would be "Unable to connect to the server" or "Server didn't /// return a valid response". /// /// The [detailsMessage] function is used to generate a more detailed message. /// This should contain more information about the error, such as the /// circumstances and maybe a prediction of the cause. This message should not /// include any exception or stack trace information, because those are printed /// separately, but rather a more detailed description of the error. The message /// can be longer, it is not limited to a specific length, but should still be /// direct and to the point. This message may contain a link to information /// about the error, such as a documentation page or a Wiki article separated /// by a new paragraph using the `` syntax. /// An example would be "The server might be down for maintenance" or "This /// might be because of an invalid server configuration". /// /// Both [errorMessage] and [detailsMessage] support a rudimentary Markdown-like /// formatting for inline code (`` `code` ``), italic (`*italic*`) and links /// (``). /// /// [enableReporting] can be used to disable the reporting feature. This can be /// useful if it's certain that the error is not caused by a bug in the app, /// but rather by a user error or a misconfiguration. [forceReporting] can be /// used to force the reporting of the error. /// /// The [onError] callback can be used to perform additional actions when an /// error occurs, such as logging the error to a remote server or performing /// additional cleanup. This callback is called before the error dialog or /// snackbar is shown. Future errorGuard( BuildContext context, String? logId, FutureOr Function() action, { ErrorGuardErrorMessageGenerator errorMessage = _defaultErrorMessage, ErrorGuardDetailsMessageGenerator detailsMessage = _defaultDetailsMessage, ErrorGuardIgnoreIfGenerator ignoreIf = _defaultIgnoreIf, bool instantDialog = false, bool enableDetails = true, bool enableReporting = true, bool forceReporting = false, void Function(Object exception, StackTrace stackTrace)? onError, }) async { assert( logId == null || _logIdRegex.hasMatch(logId), "`logId` must be a string of 2 to 16 alphanumeric characters", ); assert( enableDetails || !instantDialog, "`enableDetails` must be `true` if `instantDialog` is `true`", ); if (instantDialog) enableDetails = true; assert( enableReporting || !forceReporting, "`enableReporting` must be `true` if `forceReporting` is true", ); if (forceReporting) { enableReporting = true; instantDialog = true; } try { return await Future.value( action.call(), ).catchError(Error.throwWithStackTrace); } catch (exception, stackTrace) { onError?.call(exception, stackTrace); if (context.mounted && !ignoreIf.call(exception)) { final dateTime = DateTime.now(); final colorScheme = Theme.of(context).colorScheme; logId = logId?.toUpperCase(); if (logId != null && !_logIdRegex.hasMatch(logId)) { logId = null; } String? exceptionText; try { exceptionText = exception.toString().trim(); if (exceptionText.isEmpty) { exceptionText = null; } } catch (_) {} String errorMessageText; try { errorMessageText = errorMessage.call(exception); errorMessageText = errorMessageText.trim(); assert(errorMessageText.isNotEmpty, "Error message must not be empty"); if (!errorMessageText.endsWith(".")) errorMessageText += "."; } catch (_) { errorMessageText = _defaultErrorMessage(exception).trim(); } String? detailsMessageText; try { detailsMessageText = detailsMessage.call(exception, stackTrace); detailsMessageText = detailsMessageText?.trim(); if (detailsMessageText != null) { assert( detailsMessageText.isNotEmpty, "Details message must not be empty", ); if (detailsMessageText.isEmpty) { detailsMessageText = null; } else if (!detailsMessageText.endsWith(".") && !detailsMessageText.endsWith(">")) { detailsMessageText += "."; } } } catch (_) { detailsMessageText = _defaultDetailsMessage(exception, stackTrace); } void showErrorDialog() { if (!context.mounted) return; showDialog( context: context, barrierDismissible: false, builder: (_) => _ErrorGuardDetailsDialog( logId: logId, dateTime: dateTime, exception: exceptionText, stackTrace: stackTrace, errorMessage: errorMessageText, detailsMessage: detailsMessageText, enableReporting: enableReporting, forceReporting: forceReporting, ), ); } if (instantDialog) { showErrorDialog(); } else { ScaffoldMessenger.of(context) ..clearSnackBars() ..showSnackBar( SnackBar( content: Text( _removeNewlines( Markdown(errorMessageText).toTextSpan(context).toPlainText(), ), style: TextStyle(color: colorScheme.onErrorContainer), ), backgroundColor: colorScheme.errorContainer, action: !enableDetails ? null : SnackBarAction( label: AppLocalizations.of(context).errorGuardDetails, textColor: colorScheme.onErrorContainer, onPressed: showErrorDialog, ), ), ); } } return null; } } class _ErrorGuardDetailsDialog extends StatefulWidget { final String? logId; final DateTime dateTime; final String? exception; final StackTrace stackTrace; final String errorMessage; final String? detailsMessage; final bool enableReporting; final bool forceReporting; const _ErrorGuardDetailsDialog({ required this.logId, required this.dateTime, required this.exception, required this.stackTrace, required this.errorMessage, required this.detailsMessage, required this.enableReporting, required this.forceReporting, }); @override State<_ErrorGuardDetailsDialog> createState() => _ErrorGuardDetailsDialogState(); } class _ErrorGuardDetailsDialogState extends State<_ErrorGuardDetailsDialog> { @override Widget build(BuildContext context) { return PopScope( canPop: !widget.forceReporting, child: AlertDialog( constraints: const BoxConstraints(minWidth: 280, maxWidth: 560), title: Stack( children: [ Align( alignment: Alignment.centerRight, child: Transform.translate( offset: const Offset(0, 2), child: Text( widget.dateTime .toIso8601String() .split(".") .first .replaceFirst("T", "\n"), textAlign: TextAlign.end, style: Theme.of(context).textTheme.labelSmall, ), ), ), Builder( builder: (context) => Text( AppLocalizations.of(context).errorGuardTitle, style: DefaultTextStyle.of( context, ).style.copyWith(fontFamily: "GoogleSansCode"), ), ), ], ), content: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ if (widget.logId != null) Transform.translate( offset: const Offset(0, -20), child: Builder( builder: (context) => Text( "@${widget.logId}", style: DefaultTextStyle.of( context, ).style.copyWith(fontFamily: "GoogleSansCode"), ), ), ), Text.rich(Markdown(widget.errorMessage).toTextSpan(context)), const SizedBox(height: 8), if (widget.detailsMessage != null) ...[ _ErrorGuardDetailsPanel( icon: const Icon(Icons.announcement_outlined), title: AppLocalizations.of(context).errorGuardDetails, content: widget.detailsMessage!, isExpanded: true, monospaced: false, ), const SizedBox(height: 8), ], _ErrorGuardDetailsPanel( icon: const Icon(Icons.cancel_outlined), title: AppLocalizations.of(context).errorGuardException, content: widget.exception ?? "Could not retrieve exception message", isExpanded: kDebugMode, monospaced: widget.exception != null, italic: widget.exception == null, ), if (kDebugMode) ...[ const SizedBox(height: 8), _ErrorGuardDetailsPanel( icon: const Icon(Icons.format_list_numbered), title: AppLocalizations.of(context).errorGuardStackTrace, content: widget.stackTrace.toString(), ), ], ], ), actions: [ if (widget.enableReporting) TextButton.icon( onPressed: _report, onLongPress: () => Clipboard.setData(ClipboardData(text: _reportText())), icon: const Icon(Icons.bug_report), label: Text(AppLocalizations.of(context).errorGuardReport), ), if (!widget.forceReporting) TextButton( onPressed: () => Navigator.of(context).pop(), child: Text(MaterialLocalizations.of(context).closeButtonLabel), ), ], scrollable: true, alignment: Alignment.bottomCenter, ), ); } String _reportText() => """ An exception was thrown during the execution of the app.
Exception ${(widget.exception != null) ? "```\n${widget.exception}\n```" : "> Not available"}
Stack Trace ``` ${widget.stackTrace.toString().trim().split("\n").take(20).join("\n")} ```
--- The app suggested the following cause of the issue: - ***Error Message:*** ${_removeNewlines(widget.errorMessage)} - ***Details Message:*** ${_removeNewlines((widget.detailsMessage ?? "*None provided*").trim())}""" .trim(); void _report() { var url = "https://github.com/JHubi1/ollama-app/issues/new?template=bug.yaml"; url += "&description=${Uri.encodeComponent('Received error: "${widget.errorMessage.replaceFirst(RegExp(r".$"), "")}"${(widget.logId != null) ? " (@${widget.logId})" : ""}')}"; final contextText = _reportText(); url += "&context=${Uri.encodeComponent(contextText)}"; Clipboard.setData(ClipboardData(text: url)); launchUrl(Uri.parse(url), mode: LaunchMode.inAppBrowserView); } } class _ErrorGuardDetailsPanel extends StatefulWidget { final Widget? icon; final String title; final String content; final bool isExpanded; final bool monospaced; final bool italic; const _ErrorGuardDetailsPanel({ this.icon, required this.title, required this.content, this.isExpanded = false, this.monospaced = true, this.italic = false, }); @override State<_ErrorGuardDetailsPanel> createState() => _ErrorGuardDetailsPanelState(); } class _ErrorGuardDetailsPanelState extends State<_ErrorGuardDetailsPanel> with TickerProviderStateMixin { bool _expanded = false; late final AnimationController _animationController; late final Animation _animation; @override void initState() { super.initState(); _animationController = AnimationController( value: widget.isExpanded ? 1.0 : 0.0, duration: ExpressiveCurves.expressiveEffects.fastDuration, vsync: this, ); _expanded = widget.isExpanded; _animation = CurvedAnimation( parent: _animationController, curve: ExpressiveCurves.expressiveEffects.fast, reverseCurve: ExpressiveCurves.expressiveEffects.fast.flipped, ); } @override void dispose() { _animationController.dispose(); super.dispose(); } void _toggleAnimation() { setState(() { _expanded = !_expanded; _expanded ? _animationController.forward() : _animationController.reverse(); }); } Widget _monospacedContent({required Widget child}) { if (!widget.monospaced) return child; return SizedBox( width: double.infinity, child: SingleChildScrollView( scrollDirection: Axis.horizontal, child: GestureDetector( onLongPress: () { Feedback.forLongPress(context); Clipboard.setData(ClipboardData(text: widget.content.trim())); }, child: child, ), ), ); } @override Widget build(BuildContext context) { final theme = Theme.of(context); return DecoratedBox( decoration: BoxDecoration( border: Border.all(color: theme.dividerColor), borderRadius: BorderRadius.circular(8), ), child: Column( mainAxisSize: MainAxisSize.min, children: [ ListTile( leading: widget.icon, title: Text(widget.title), trailing: ExpandIcon( onPressed: null, isExpanded: _expanded, padding: EdgeInsets.zero, ), onTap: _toggleAnimation, dense: true, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(8), ), contentPadding: const EdgeInsets.only(left: 12), ), SizeTransition( sizeFactor: _animation, alignment: Alignment.topCenter, child: _monospacedContent( child: Padding( padding: const EdgeInsets.only(left: 16, right: 16, bottom: 12), child: widget.monospaced ? Text( widget.content.trim(), style: const TextStyle( fontFamily: "GoogleSansCode", height: kTextHeightNone, ), ) : Text.rich( Markdown(widget.content.trim()).toTextSpan(context), style: TextStyle( fontStyle: widget.italic ? FontStyle.italic : null, color: widget.italic ? theme.disabledColor : null, ), ), ), ), ), ], ), ); } }