feat: some more work on console_page

* console_log's copy data is now a valid json object for all log types
* graphQLResponse now provides raw response object for copy
* console_model now handles pause in itself, so UI pipeline doesn't disturb pause (like when revisiting page / hot reloading)
* some minor console_page UI tweaks
This commit is contained in:
Aliaksei Tratseuski 2024-05-20 03:09:23 +04:00
parent 0ee46e1c1e
commit 4e0779f5e7
9 changed files with 345 additions and 214 deletions

View file

@ -48,6 +48,7 @@
"title": "Console", "title": "Console",
"waiting": "Waiting for initialization…", "waiting": "Waiting for initialization…",
"copy": "Copy", "copy": "Copy",
"copy_raw": "Raw response",
"historyEmpty": "No data yet", "historyEmpty": "No data yet",
"error":"Error", "error":"Error",
"log":"Log", "log":"Log",
@ -55,14 +56,19 @@
"rest_api_response":"Rest API Response", "rest_api_response":"Rest API Response",
"graphql_request":"GraphQL Request", "graphql_request":"GraphQL Request",
"graphql_response":"GraphQL Response", "graphql_response":"GraphQL Response",
"logged_at": "logged at: ", "logged_at": "Logged at",
"data": "Data", "data": "Data",
"erros":"Errors", "errors":"Errors",
"error_path": "Path",
"error_locations": "Locations",
"error_extensions": "Extensions",
"request_data": "Request data", "request_data": "Request data",
"headers": "Headers", "headers": "Headers",
"response_data": "Response data", "response_data": "Response data",
"context": "Context", "context": "Context",
"operation": "Operation", "operation": "Operation",
"operation_type": "Operation type",
"operation_name": "Operation name",
"variables": "Variables" "variables": "Variables"
}, },
"about_application_page": { "about_application_page": {

View file

@ -1,3 +1,4 @@
import 'dart:convert';
import 'dart:io'; import 'dart:io';
import 'package:graphql_flutter/graphql_flutter.dart'; import 'package:graphql_flutter/graphql_flutter.dart';
@ -17,9 +18,10 @@ class RequestLoggingLink extends Link {
]) async* { ]) async* {
_addConsoleLog( _addConsoleLog(
GraphQlRequestConsoleLog( GraphQlRequestConsoleLog(
// context: request.context,
operationType: request.type.name,
operation: request.operation, operation: request.operation,
variables: request.variables, variables: request.variables,
context: request.context,
), ),
); );
yield* forward!(request); yield* forward!(request);
@ -32,9 +34,10 @@ class ResponseLoggingParser extends ResponseParser {
final response = super.parseResponse(body); final response = super.parseResponse(body);
_addConsoleLog( _addConsoleLog(
GraphQlResponseConsoleLog( GraphQlResponseConsoleLog(
// context: response.context,
data: response.data, data: response.data,
errors: response.errors, errors: response.errors,
context: response.context, rawResponse: jsonEncode(response.response),
), ),
); );
return response; return response;

View file

@ -1,4 +1,5 @@
import 'dart:async'; import 'dart:async';
import 'dart:convert';
import 'dart:developer'; import 'dart:developer';
import 'dart:io'; import 'dart:io';
@ -68,10 +69,10 @@ class ConsoleInterceptor extends InterceptorsWrapper {
) async { ) async {
addConsoleLog( addConsoleLog(
RestApiRequestConsoleLog( RestApiRequestConsoleLog(
method: options.method,
data: options.data.toString(),
headers: options.headers,
uri: options.uri, uri: options.uri,
method: options.method,
headers: options.headers,
data: jsonEncode(options.data),
), ),
); );
return super.onRequest(options, handler); return super.onRequest(options, handler);
@ -84,10 +85,10 @@ class ConsoleInterceptor extends InterceptorsWrapper {
) async { ) async {
addConsoleLog( addConsoleLog(
RestApiResponseConsoleLog( RestApiResponseConsoleLog(
uri: response.realUri,
method: response.requestOptions.method, method: response.requestOptions.method,
statusCode: response.statusCode, statusCode: response.statusCode,
data: response.data.toString(), data: jsonEncode(response.data),
uri: response.realUri,
), ),
); );
return super.onResponse( return super.onResponse(
@ -103,12 +104,13 @@ class ConsoleInterceptor extends InterceptorsWrapper {
) async { ) async {
final Response? response = err.response; final Response? response = err.response;
log(err.toString()); log(err.toString());
addConsoleLog( addConsoleLog(
ManualConsoleLog.warning( ManualConsoleLog.warning(
customTitle: 'RestAPI error', customTitle: 'RestAPI error',
content: 'response-uri: ${response?.realUri}\n' content: '"uri": "${response?.realUri}",\n'
'code: ${response?.statusCode}\n' '"status_code": ${response?.statusCode},\n'
'data: ${response?.toString()}\n', '"response": ${jsonEncode(response)}',
), ),
); );
return super.onError(err, handler); return super.onError(err, handler);

View file

@ -530,7 +530,7 @@ class ServerInstallationRepository {
Future<void> deleteDomain() async { Future<void> deleteDomain() async {
await box.delete(BNames.serverDomain); await box.delete(BNames.serverDomain);
getIt<ApiConfigModel>().init(); await getIt<ApiConfigModel>().init();
} }
Future<void> saveIsServerStarted(final bool value) async { Future<void> saveIsServerStarted(final bool value) async {
@ -604,6 +604,6 @@ class ServerInstallationRepository {
BNames.hasFinalChecked, BNames.hasFinalChecked,
BNames.isLoading, BNames.isLoading,
]); ]);
getIt<ApiConfigModel>().init(); await getIt<ApiConfigModel>().init();
} }
} }

View file

@ -2,15 +2,50 @@ import 'package:flutter/material.dart';
import 'package:selfprivacy/logic/models/console_log.dart'; import 'package:selfprivacy/logic/models/console_log.dart';
class ConsoleModel extends ChangeNotifier { class ConsoleModel extends ChangeNotifier {
/// limit for history, so logs won't affect memory and overflow
static const logBufferLimit = 500;
/// differs from log buffer limit so as to not rearrange memory each time
/// we add incoming log
static const incomingBufferBreakpoint = 750;
final List<ConsoleLog> _logs = []; final List<ConsoleLog> _logs = [];
final List<ConsoleLog> _incomingQueue = [];
bool _paused = false;
bool get paused => _paused;
List<ConsoleLog> get logs => _logs; List<ConsoleLog> get logs => _logs;
void log(final ConsoleLog newLog) { void log(final ConsoleLog newLog) {
logs.add(newLog); if (paused) {
notifyListeners(); _incomingQueue.add(newLog);
// Make sure we don't have too many if (_incomingQueue.length > incomingBufferBreakpoint) {
if (logs.length > 500) { logs.removeRange(0, _incomingQueue.length - logBufferLimit);
logs.removeAt(0);
} }
} else {
logs.add(newLog);
_updateQueue();
}
}
void play() {
_logs.addAll(_incomingQueue);
_paused = false;
_updateQueue();
_incomingQueue.clear();
}
void pause() {
_paused = true;
notifyListeners();
}
/// drop logs over the limit and
void _updateQueue() {
// Make sure we don't have too many
if (logs.length > logBufferLimit) {
logs.removeRange(0, logs.length - logBufferLimit);
}
notifyListeners();
} }
} }

View file

@ -1,5 +1,7 @@
import 'package:gql/language.dart'; import 'dart:convert';
import 'package:graphql/client.dart';
import 'package:gql/language.dart' as gql;
import 'package:graphql/client.dart' as gql_client;
import 'package:intl/intl.dart'; import 'package:intl/intl.dart';
enum ConsoleLogSeverity { enum ConsoleLogSeverity {
@ -12,7 +14,6 @@ enum ConsoleLogSeverity {
/// TODO(misterfourtytwo): should we add? /// TODO(misterfourtytwo): should we add?
/// ///
/// * equality override /// * equality override
/// * translations of theese strings
sealed class ConsoleLog { sealed class ConsoleLog {
ConsoleLog({ ConsoleLog({
final String? customTitle, final String? customTitle,
@ -32,13 +33,23 @@ sealed class ConsoleLog {
String get content; String get content;
/// data available for copy in dialog /// data available for copy in dialog
String? get shareableData => '$title\n' String? get shareableData => '{"title":"$title",\n'
'{\n$content\n}'; '"timestamp": "$fullUTCString",\n'
'"data":{\n$content\n}'
'\n}';
static final DateFormat _formatter = DateFormat('hh:mm:ss'); static final DateFormat _formatter = DateFormat('hh:mm:ss');
String get timeString => _formatter.format(time); String get timeString => _formatter.format(time);
String get fullUTCString => time.toUtc().toIso8601String();
} }
abstract class LogWithRawResponse {
String get rawResponse;
}
/// entity for manually created logs, as opposed to automated ones coming
/// from requests / responses
class ManualConsoleLog extends ConsoleLog { class ManualConsoleLog extends ConsoleLog {
ManualConsoleLog({ ManualConsoleLog({
required this.content, required this.content,
@ -72,8 +83,10 @@ class RestApiRequestConsoleLog extends ConsoleLog {
@override @override
String get title => 'Rest API Request'; String get title => 'Rest API Request';
@override @override
String get content => 'method: $method\n' String get content => '"method": "$method",\n'
'uri: $uri'; '"uri": "$uri",\n'
'"headers": ${jsonEncode(headers)},\n'
'"data": $data';
} }
class RestApiResponseConsoleLog extends ConsoleLog { class RestApiResponseConsoleLog extends ConsoleLog {
@ -93,49 +106,70 @@ class RestApiResponseConsoleLog extends ConsoleLog {
@override @override
String get title => 'Rest API Response'; String get title => 'Rest API Response';
@override @override
String get content => 'method: $method | status code: $statusCode\n' String get content => '"method": "$method",\n'
'uri: $uri'; '"status_code": $statusCode,\n'
'"uri": "$uri",\n'
'"data": $data';
} }
/// there is no actual getter for context fields outside of its class
/// one can extract unique entries by their type, which implements
/// `ContextEntry` class, I'll leave the code here if in the future
/// some entries will actually be needed.
// extension ContextEncoder on gql_client.Context {
// String get encode {
// return '""';
// }
// }
class GraphQlRequestConsoleLog extends ConsoleLog { class GraphQlRequestConsoleLog extends ConsoleLog {
GraphQlRequestConsoleLog({ GraphQlRequestConsoleLog({
this.operation, required this.operationType,
this.variables, required this.operation,
this.context, required this.variables,
// this.context,
super.severity, super.severity,
}); });
final Context? context; // final gql_client.Context? context;
final Operation? operation; final String operationType;
final gql_client.Operation? operation;
String get operationDocument =>
operation != null ? gql.printNode(operation!.document) : 'null';
final Map<String, dynamic>? variables; final Map<String, dynamic>? variables;
@override @override
String get title => 'GraphQL Request'; String get title => 'GraphQL Request';
@override @override
String get content => 'name: ${operation?.operationName}\n' String get content =>
'document: ${operation?.document != null ? printNode(operation!.document) : null}'; // '"context": ${context?.encode},\n'
String get stringifiedOperation => operation == null '"variables": ${jsonEncode(variables)},\n'
? 'null' '"type": "$operationType",\n'
: 'Operation{\n' '"name": "${operation?.operationName}",\n'
'\tname: ${operation?.operationName},\n' '"document": ${jsonEncode(operationDocument)}';
'\tdocument: ${operation?.document != null ? printNode(operation!.document) : null}\n'
'}';
} }
class GraphQlResponseConsoleLog extends ConsoleLog { class GraphQlResponseConsoleLog extends ConsoleLog
implements LogWithRawResponse {
GraphQlResponseConsoleLog({ GraphQlResponseConsoleLog({
required this.rawResponse,
// this.context,
this.data, this.data,
this.errors, this.errors,
this.context,
super.severity, super.severity,
}); });
final Context? context; @override
final String rawResponse;
// final gql_client.Context? context;
final Map<String, dynamic>? data; final Map<String, dynamic>? data;
final List<GraphQLError>? errors; final List<gql_client.GraphQLError>? errors;
@override @override
String get title => 'GraphQL Response'; String get title => 'GraphQL Response';
@override @override
String get content => 'data: $data'; String get content =>
// '"context": ${context?.encode},\n'
'"data": ${jsonEncode(data)},\n'
'"errors": $errors';
} }

View file

@ -7,66 +7,92 @@ extension on ConsoleLog {
List<Widget> unwrapContent(final BuildContext context) => switch (this) { List<Widget> unwrapContent(final BuildContext context) => switch (this) {
(final RestApiRequestConsoleLog log) => [ (final RestApiRequestConsoleLog log) => [
if (log.method != null) _KeyValueRow('method', log.method), if (log.method != null) _KeyValueRow('method', log.method),
if (log.uri != null) _KeyValueRow('uri', log.uri.toString()), if (log.uri != null) _KeyValueRow('uri', '${log.uri}'),
// headers bloc // headers bloc
if (log.headers?.isNotEmpty ?? false) if (log.headers?.isNotEmpty ?? false) ...[
const _SectionRow('console_page.headers'), const _SectionRow('console_page.headers'),
...?log.headers?.entries for (final entry in log.headers!.entries)
.map((final entry) => _KeyValueRow(entry.key, entry.value)), _KeyValueRow(entry.key, '${entry.value}'),
],
// data bloc // data
const _SectionRow('console_page.data'), const _SectionRow('console_page.data'),
_DataRow(log.data?.toString()), _DataRow('${log.data}'),
], ],
(final RestApiResponseConsoleLog log) => [ (final RestApiResponseConsoleLog log) => [
if (log.method != null) _KeyValueRow('method', log.method), if (log.method != null) _KeyValueRow('method', '${log.method}'),
if (log.uri != null) _KeyValueRow('uri', log.uri.toString()), if (log.uri != null) _KeyValueRow('uri', '${log.uri}'),
if (log.statusCode != null) if (log.statusCode != null)
_KeyValueRow('statusCode', log.statusCode.toString()), _KeyValueRow('statusCode', '${log.statusCode}'),
// data bloc // data
const _SectionRow('console_page.response_data'), const _SectionRow('console_page.response_data'),
_DataRow(log.data?.toString()), _DataRow('${log.data}'),
], ],
(final GraphQlRequestConsoleLog log) => [ (final GraphQlRequestConsoleLog log) => [
// context // // context
const _SectionRow('console_page.context'), // if (log.context != null) ...[
_DataRow(log.context?.toString()), // const _SectionRow('console_page.context'),
// data // _DataRow('${log.context}'),
if (log.operation != null) // ],
const _SectionRow('console_page.operation'), const _SectionRow('console_page.operation'),
_DataRow(log.stringifiedOperation), // errors if (log.operation != null) ...[
if (log.variables?.isNotEmpty ?? false) _KeyValueRow(
'console_page.operation_type'.tr(),
log.operationType,
),
_KeyValueRow(
'console_page.operation_name'.tr(),
log.operation?.operationName,
),
const Divider(),
// data
_DataRow(log.operationDocument),
],
// preset variables
if (log.variables?.isNotEmpty ?? false) ...[
const _SectionRow('console_page.variables'), const _SectionRow('console_page.variables'),
...?log.variables?.entries.map( for (final entry in log.variables!.entries)
(final entry) => _KeyValueRow( _KeyValueRow(entry.key, '${entry.value}'),
entry.key, ],
'${entry.value}',
),
),
], ],
(final GraphQlResponseConsoleLog log) => [ (final GraphQlResponseConsoleLog log) => [
// context // // context
const _SectionRow('console_page.context'), // const _SectionRow('console_page.context'),
_DataRow(log.context?.toString()), // _DataRow('${log.context}'),
// data // data
if (log.data != null) const _SectionRow('console_page.data'), if (log.data != null) ...[
...?log.data?.entries.map( const _SectionRow('console_page.data'),
(final entry) => _KeyValueRow( for (final entry in log.data!.entries)
entry.key, _KeyValueRow(entry.key, '${entry.value}'),
'${entry.value}', ],
),
),
// errors // errors
if (log.errors?.isNotEmpty ?? false) if (log.errors?.isNotEmpty ?? false) ...[
const _SectionRow('console_page.errors'), const _SectionRow('console_page.errors'),
...?log.errors?.map( for (final entry in log.errors!) ...[
(final entry) => _KeyValueRow( _KeyValueRow(
'${'console_page.error_message'.tr()}: ',
entry.message, entry.message,
),
_KeyValueRow(
'${'console_page.error_path'.tr()}: ',
'${entry.path}',
),
if (entry.locations?.isNotEmpty ?? false)
_KeyValueRow(
'${'console_page.error_locations'.tr()}: ',
'${entry.locations}', '${entry.locations}',
), ),
if (entry.extensions?.isNotEmpty ?? false)
_KeyValueRow(
'${'console_page.error_extensions'.tr()}: ',
'${entry.extensions}',
), ),
const Divider(),
],
],
], ],
(final ManualConsoleLog log) => [ (final ManualConsoleLog log) => [
_DataRow(log.content), _DataRow(log.content),
@ -74,6 +100,7 @@ extension on ConsoleLog {
}; };
} }
/// dialog with detailed log content
class ConsoleItemDialog extends StatelessWidget { class ConsoleItemDialog extends StatelessWidget {
const ConsoleItemDialog({ const ConsoleItemDialog({
required this.log, required this.log,
@ -83,20 +110,29 @@ class ConsoleItemDialog extends StatelessWidget {
final ConsoleLog log; final ConsoleLog log;
@override @override
Widget build(final BuildContext context) { Widget build(final BuildContext context) => AlertDialog(
final content = log.unwrapContent(context);
return AlertDialog(
scrollable: true, scrollable: true,
title: Text(log.title), title: Text(log.title),
contentPadding: const EdgeInsets.symmetric(
vertical: 16,
horizontal: 12,
),
content: Column( content: Column(
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
Row( const Divider(),
children: [ Padding(
Text('logged_at'.tr()), padding: const EdgeInsets.symmetric(horizontal: 12),
SelectableText( child: SelectableText.rich(
log.timeString, TextSpan(
style: DefaultTextStyle.of(context).style,
children: <TextSpan>[
TextSpan(
text: '${'console_page.logged_at'.tr()}: ',
style: const TextStyle(),
),
TextSpan(
text: '${log.timeString} (${log.fullUTCString})',
style: const TextStyle( style: const TextStyle(
fontWeight: FontWeight.w700, fontWeight: FontWeight.w700,
fontFeatures: [FontFeature.tabularFigures()], fontFeatures: [FontFeature.tabularFigures()],
@ -104,11 +140,20 @@ class ConsoleItemDialog extends StatelessWidget {
), ),
], ],
), ),
),
),
const Divider(), const Divider(),
...content, ...log.unwrapContent(context),
], ],
), ),
actions: [ actions: [
if (log is LogWithRawResponse)
TextButton(
onPressed: () => PlatformAdapter.setClipboard(
(log as LogWithRawResponse).rawResponse,
),
child: Text('console_page.copy_raw'.tr()),
),
// A button to copy the request to the clipboard // A button to copy the request to the clipboard
if (log.shareableData?.isNotEmpty ?? false) if (log.shareableData?.isNotEmpty ?? false)
TextButton( TextButton(
@ -122,41 +167,9 @@ class ConsoleItemDialog extends StatelessWidget {
), ),
], ],
); );
}
}
class _KeyValueRow extends StatelessWidget {
const _KeyValueRow(this.title, this.value);
final String title;
final String? value;
@override
Widget build(final BuildContext context) => SelectableText.rich(
TextSpan(
style: DefaultTextStyle.of(context).style,
children: <TextSpan>[
TextSpan(
text: '$title: ',
style: const TextStyle(fontWeight: FontWeight.bold),
),
TextSpan(text: value ?? ''),
],
),
);
}
class _DataRow extends StatelessWidget {
const _DataRow(this.data);
final String? data;
@override
Widget build(final BuildContext context) => SelectableText(
data ?? 'null',
style: const TextStyle(fontWeight: FontWeight.w400),
);
} }
/// different sections delimiter with `title`
class _SectionRow extends StatelessWidget { class _SectionRow extends StatelessWidget {
const _SectionRow(this.title); const _SectionRow(this.title);
@ -184,3 +197,44 @@ class _SectionRow extends StatelessWidget {
), ),
); );
} }
/// data row with a {key: value} pair
class _KeyValueRow extends StatelessWidget {
const _KeyValueRow(this.title, this.value);
final String title;
final String? value;
@override
Widget build(final BuildContext context) => Padding(
padding: const EdgeInsets.symmetric(horizontal: 12),
child: SelectableText.rich(
TextSpan(
style: DefaultTextStyle.of(context).style,
children: <TextSpan>[
TextSpan(
text: '$title: ',
style: const TextStyle(fontWeight: FontWeight.bold),
),
TextSpan(text: value ?? ''),
],
),
),
);
}
/// data row with only text
class _DataRow extends StatelessWidget {
const _DataRow(this.data);
final String? data;
@override
Widget build(final BuildContext context) => Padding(
padding: const EdgeInsets.symmetric(horizontal: 12),
child: SelectableText(
data ?? 'null',
style: const TextStyle(fontWeight: FontWeight.w400),
),
);
}

View file

@ -35,9 +35,11 @@ class ConsoleLogItemWidget extends StatelessWidget {
final ConsoleLog log; final ConsoleLog log;
@override @override
Widget build(final BuildContext context) => ListTile( Widget build(final BuildContext context) => Padding(
padding: const EdgeInsets.symmetric(horizontal: 12),
child: ListTile(
dense: true, dense: true,
title: SelectableText.rich( title: Text.rich(
TextSpan( TextSpan(
style: DefaultTextStyle.of(context).style, style: DefaultTextStyle.of(context).style,
children: <TextSpan>[ children: <TextSpan>[
@ -65,7 +67,9 @@ class ConsoleLogItemWidget extends StatelessWidget {
iconColor: log.resolveColor(context), iconColor: log.resolveColor(context),
onTap: () => showDialog( onTap: () => showDialog(
context: context, context: context,
builder: (final BuildContext context) => ConsoleItemDialog(log: log), builder: (final BuildContext context) =>
ConsoleItemDialog(log: log),
),
), ),
); );
} }

View file

@ -16,8 +16,9 @@ class ConsolePage extends StatefulWidget {
} }
class _ConsolePageState extends State<ConsolePage> { class _ConsolePageState extends State<ConsolePage> {
ConsoleModel get console => getIt<ConsoleModel>();
/// should freeze logs state to properly read logs /// should freeze logs state to properly read logs
bool paused = false;
late final Future<void> future; late final Future<void> future;
@override @override
@ -25,12 +26,12 @@ class _ConsolePageState extends State<ConsolePage> {
super.initState(); super.initState();
future = getIt.allReady(); future = getIt.allReady();
getIt<ConsoleModel>().addListener(update); console.addListener(update);
} }
@override @override
void dispose() { void dispose() {
getIt<ConsoleModel>().removeListener(update); console.removeListener(update);
super.dispose(); super.dispose();
} }
@ -40,17 +41,12 @@ class _ConsolePageState extends State<ConsolePage> {
/// unmounted or during frame build, adding as postframe callback ensures /// unmounted or during frame build, adding as postframe callback ensures
/// that element is marked for rebuild /// that element is marked for rebuild
WidgetsBinding.instance.addPostFrameCallback((final _) { WidgetsBinding.instance.addPostFrameCallback((final _) {
if (!paused && mounted) { if (mounted) {
setState(() => {}); setState(() => {});
} }
}); });
} }
void togglePause() {
paused ^= true;
setState(() {});
}
@override @override
Widget build(final BuildContext context) => SafeArea( Widget build(final BuildContext context) => SafeArea(
child: Scaffold( child: Scaffold(
@ -63,14 +59,15 @@ class _ConsolePageState extends State<ConsolePage> {
actions: [ actions: [
IconButton( IconButton(
icon: Icon( icon: Icon(
paused ? Icons.play_arrow_outlined : Icons.pause_outlined, console.paused
? Icons.play_arrow_outlined
: Icons.pause_outlined,
), ),
onPressed: togglePause, onPressed: console.paused ? console.play : console.pause,
), ),
], ],
), ),
body: SelectionArea( body: Scrollbar(
child: Scrollbar(
child: FutureBuilder( child: FutureBuilder(
future: future, future: future,
builder: ( builder: (
@ -78,14 +75,11 @@ class _ConsolePageState extends State<ConsolePage> {
final AsyncSnapshot<void> snapshot, final AsyncSnapshot<void> snapshot,
) { ) {
if (snapshot.hasData) { if (snapshot.hasData) {
final List<ConsoleLog> logs = final List<ConsoleLog> logs = console.logs;
getIt.get<ConsoleModel>().logs;
return logs.isEmpty return logs.isEmpty
? const _ConsoleViewEmpty() ? const _ConsoleViewEmpty()
: _ConsoleViewLoaded( : _ConsoleViewLoaded(logs: logs);
logs: logs,
);
} }
return const _ConsoleViewLoading(); return const _ConsoleViewLoading();
@ -93,7 +87,6 @@ class _ConsolePageState extends State<ConsolePage> {
), ),
), ),
), ),
),
); );
} }
@ -135,7 +128,7 @@ class _ConsoleViewLoaded extends StatelessWidget {
@override @override
Widget build(final BuildContext context) => ListView.separated( Widget build(final BuildContext context) => ListView.separated(
primary: true, primary: true,
padding: const EdgeInsets.symmetric(vertical: 10), padding: const EdgeInsets.symmetric(vertical: 8),
itemCount: logs.length, itemCount: logs.length,
itemBuilder: (final BuildContext context, final int index) { itemBuilder: (final BuildContext context, final int index) {
final log = logs[logs.length - 1 - index]; final log = logs[logs.length - 1 - index];