727 lines
24 KiB
Dart
727 lines
24 KiB
Dart
import 'dart:async';
|
|
import 'dart:io';
|
|
|
|
import 'package:dartx/dartx.dart';
|
|
import 'package:flutter/foundation.dart';
|
|
import 'package:flutter/gestures.dart';
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter/services.dart';
|
|
import 'package:ollama_dart/ollama_dart.dart';
|
|
import 'package:url_launcher/url_launcher.dart';
|
|
|
|
import '../l10n/gen/app_localizations.dart';
|
|
|
|
final _logIdRegex = RegExp(r"^[A-Z0-9]{2,16}$");
|
|
|
|
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) {
|
|
OllamaClientException _ => "Unknown client error",
|
|
AssertionError _ =>
|
|
exception.toString().split(": ").elementAtOrNull(4) ??
|
|
"An assertion failed",
|
|
TimeoutException _ => "Request timed out",
|
|
SocketException _ || HttpException _ => "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) {
|
|
OllamaClientException e =>
|
|
e.body.toString().startsWith("ClientException with SocketException")
|
|
? "A network error occurred while trying to connect to the server."
|
|
"\n\nYou may check your network connection or server reachability and try again."
|
|
: "The Ollama API client received a faulty response with code `${e.code}`."
|
|
"\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<https://en.wikipedia.org/wiki/Assertion_(software_development)>",
|
|
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 _ =>
|
|
"A ${exception.runtimeType.toString().split(RegExp(r"(?=[A-Z])")).join(" ").toLowerCase()} might be caused by a slow or unresponsive server, or a network issue."
|
|
"\n\nYou may check your network connection 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 tools/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 `<https://example.com>` 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
|
|
/// (`<https://example.com>`).
|
|
///
|
|
/// [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.
|
|
Future<T?> errorGuard<T>(
|
|
BuildContext context,
|
|
String? logId,
|
|
FutureOr<T> Function() action, {
|
|
ErrorGuardErrorMessageGenerator errorMessage = _defaultErrorMessage,
|
|
ErrorGuardDetailsMessageGenerator detailsMessage = _defaultDetailsMessage,
|
|
ErrorGuardIgnoreIfGenerator ignoreIf = _defaultIgnoreIf,
|
|
bool instantDialog = false,
|
|
bool enableDetails = true,
|
|
bool enableReporting = true,
|
|
bool forceReporting = false,
|
|
}) 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) {
|
|
if (context.mounted && !ignoreIf.call(exception)) {
|
|
var dateTime = DateTime.now();
|
|
var 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).showSnackBar(
|
|
SnackBar(
|
|
content: Text(
|
|
_removeNewlines(errorMessageText),
|
|
style: TextStyle(color: colorScheme.onErrorContainer),
|
|
),
|
|
backgroundColor: colorScheme.errorContainer,
|
|
action: !enableDetails
|
|
? null
|
|
: SnackBarAction(
|
|
label: AppLocalizations.of(context).errorGuardDetails,
|
|
textColor: colorScheme.onErrorContainer,
|
|
onPressed: showErrorDialog,
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
}
|
|
|
|
String _removeNewlines(String content) =>
|
|
content.replaceAll(RegExp(r"\s*\n\s*"), " ");
|
|
|
|
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(
|
|
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,
|
|
),
|
|
),
|
|
),
|
|
Text(AppLocalizations.of(context).errorGuardTitle),
|
|
],
|
|
),
|
|
content: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
if (widget.logId != null)
|
|
Transform.translate(
|
|
offset: const Offset(0, -20),
|
|
child: Text("@${widget.logId}"),
|
|
),
|
|
|
|
Text.rich(_contentFormat(context, widget.errorMessage)),
|
|
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,
|
|
),
|
|
);
|
|
}
|
|
|
|
static TextSpan _contentFormat(BuildContext context, String content) {
|
|
content = content.trim().split("\n").map((l) => l.trim()).join("\n");
|
|
if (content.isEmpty) return const TextSpan(text: "");
|
|
// content = content.replaceAll(RegExp(r"(?=)"), "\u{00AD}");
|
|
|
|
var paragraphs = content.split("\n\n").map((p) => p.trim()).toList();
|
|
|
|
InlineSpan parseInline(String text) {
|
|
var root = <String, dynamic>{
|
|
"type": "root",
|
|
"children": <Map<String, dynamic>>[],
|
|
};
|
|
var stack = <Map<String, dynamic>>[root];
|
|
var buf = StringBuffer();
|
|
|
|
void flushBufferToCurrent() {
|
|
if (buf.isEmpty) return;
|
|
var textNode = {"type": "text", "text": buf.toString()};
|
|
(stack.last["children"] as List).add(textNode);
|
|
buf.clear();
|
|
}
|
|
|
|
var i = 0;
|
|
while (i < text.length) {
|
|
var ch = text[i];
|
|
if (ch == "\\" && i + 1 < text.length) {
|
|
buf.write(text[i + 1]);
|
|
i += 2;
|
|
continue;
|
|
}
|
|
|
|
var inCode = stack.last["type"] == "code";
|
|
if (inCode) {
|
|
if (ch == "`") {
|
|
flushBufferToCurrent();
|
|
stack.removeLast();
|
|
i++;
|
|
} else {
|
|
buf.write(ch);
|
|
i++;
|
|
}
|
|
continue;
|
|
}
|
|
|
|
if (ch == '<') {
|
|
var endIndex = text.indexOf('>', i + 1);
|
|
if (endIndex != -1) {
|
|
var url = text.substring(i + 1, endIndex);
|
|
if (url.startsWith('https://') || url.startsWith('http://')) {
|
|
flushBufferToCurrent();
|
|
var linkNode = {"type": "link", "url": url};
|
|
(stack.last["children"] as List).add(linkNode);
|
|
i = endIndex + 1;
|
|
continue;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (ch == "`") {
|
|
flushBufferToCurrent();
|
|
var codeNode = {"type": "code", "children": <Map<String, dynamic>>[]};
|
|
(stack.last["children"] as List).add(codeNode);
|
|
stack.add(codeNode);
|
|
i++;
|
|
continue;
|
|
}
|
|
|
|
if (ch == "*") {
|
|
flushBufferToCurrent();
|
|
if (stack.last["type"] == "italic") {
|
|
stack.removeLast();
|
|
} else {
|
|
var n = {"type": "italic", "children": <Map<String, dynamic>>[]};
|
|
(stack.last['children'] as List).add(n);
|
|
stack.add(n);
|
|
}
|
|
i++;
|
|
continue;
|
|
}
|
|
|
|
buf.write(ch);
|
|
i++;
|
|
}
|
|
|
|
flushBufferToCurrent();
|
|
|
|
InlineSpan build(Map<String, dynamic> node) {
|
|
var type = node["type"] as String;
|
|
if (type == "text") {
|
|
return TextSpan(text: node["text"] as String);
|
|
}
|
|
|
|
if (type == "link") {
|
|
var url = node["url"] as String;
|
|
return TextSpan(
|
|
text: url,
|
|
style: TextStyle(
|
|
color: Theme.of(context).colorScheme.primary,
|
|
decoration: TextDecoration.underline,
|
|
),
|
|
recognizer: TapGestureRecognizer()
|
|
..onTap = () => launchUrl(Uri.parse(url)),
|
|
);
|
|
}
|
|
|
|
var children = (node["children"] as List)
|
|
.map<InlineSpan>((c) => build(c as Map<String, dynamic>))
|
|
.toList();
|
|
|
|
switch (type) {
|
|
case "root":
|
|
return TextSpan(children: children);
|
|
case "italic":
|
|
return TextSpan(
|
|
children: children,
|
|
style: const TextStyle(fontStyle: FontStyle.italic),
|
|
);
|
|
case "code":
|
|
var codeText = (node["children"] as List)
|
|
.where((c) => (c as Map<String, dynamic>)["type"] == "text")
|
|
.map((c) => (c as Map<String, dynamic>)["text"] as String)
|
|
.join();
|
|
var widget = DecoratedBox(
|
|
decoration: BoxDecoration(
|
|
border: Border.all(color: Theme.of(context).dividerColor),
|
|
borderRadius: BorderRadius.circular(4),
|
|
),
|
|
child: Padding(
|
|
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2),
|
|
child: Text(
|
|
codeText,
|
|
style: const TextStyle(fontFamily: "monospace"),
|
|
),
|
|
),
|
|
);
|
|
return WidgetSpan(
|
|
alignment: PlaceholderAlignment.middle,
|
|
child: widget,
|
|
);
|
|
default:
|
|
return TextSpan(children: children);
|
|
}
|
|
}
|
|
|
|
return build(root);
|
|
}
|
|
|
|
return TextSpan(
|
|
children: List.generate(paragraphs.length * 2 - 1, (index) {
|
|
if (index.isOdd) {
|
|
return const TextSpan(
|
|
text: "\n\n",
|
|
style: TextStyle(height: 0.5, color: Colors.transparent),
|
|
);
|
|
}
|
|
var text = paragraphs[index ~/ 2];
|
|
return parseInline(text);
|
|
}),
|
|
);
|
|
}
|
|
|
|
String _reportText() =>
|
|
"""
|
|
An exception was thrown during the execution of the app.
|
|
|
|
<details open>
|
|
<summary>Exception</summary>
|
|
|
|
${(widget.exception != null) ? "```\n${widget.exception}\n```" : "> Not available"}
|
|
|
|
</details>
|
|
|
|
<details>
|
|
<summary>Stack Trace</summary>
|
|
|
|
```
|
|
${widget.stackTrace.toString().trim()}
|
|
```
|
|
|
|
</details>
|
|
|
|
---
|
|
|
|
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})" : ""}')}";
|
|
|
|
var contextText = _reportText();
|
|
url += "&context=${Uri.encodeComponent(contextText)}";
|
|
|
|
Clipboard.setData(ClipboardData(text: url));
|
|
launchUrl(Uri.parse(url));
|
|
}
|
|
}
|
|
|
|
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<double> _animation;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_animationController = AnimationController(
|
|
value: widget.isExpanded ? 1.0 : 0.0,
|
|
duration: kThemeAnimationDuration,
|
|
vsync: this,
|
|
);
|
|
_expanded = widget.isExpanded;
|
|
|
|
_animation = CurvedAnimation(
|
|
parent: _animationController,
|
|
curve: Curves.fastEaseInToSlowEaseOut,
|
|
);
|
|
}
|
|
|
|
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) {
|
|
var 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: (_) => _toggleAnimation(),
|
|
isExpanded: _expanded,
|
|
padding: EdgeInsets.zero,
|
|
),
|
|
onTap: _toggleAnimation,
|
|
dense: true,
|
|
shape: RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.circular(8),
|
|
),
|
|
contentPadding: const EdgeInsets.only(left: 12),
|
|
),
|
|
SizeTransition(
|
|
sizeFactor: _animation,
|
|
axisAlignment: -1,
|
|
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: "monospace",
|
|
height: kTextHeightNone,
|
|
),
|
|
)
|
|
: Text.rich(
|
|
_ErrorGuardDetailsDialogState._contentFormat(
|
|
context,
|
|
widget.content.trim(),
|
|
),
|
|
textAlign: TextAlign.justify,
|
|
style: TextStyle(
|
|
fontStyle: widget.italic ? FontStyle.italic : null,
|
|
color: widget.italic ? theme.disabledColor : null,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|