mirror of
https://git.selfprivacy.org/kherel/selfprivacy.org.app.git
synced 2024-11-18 06:39:14 +00:00
feat: console log feature refactor.
listing scroll performance fix, uniform code and widget UI for different log item types, dialog data can now be selected & copy-pasted
This commit is contained in:
parent
22fbbb051e
commit
00545c34b4
|
@ -1,12 +1,12 @@
|
|||
import 'package:get_it/get_it.dart';
|
||||
import 'package:selfprivacy/logic/get_it/api_config.dart';
|
||||
import 'package:selfprivacy/logic/get_it/api_connection_repository.dart';
|
||||
import 'package:selfprivacy/logic/get_it/console.dart';
|
||||
import 'package:selfprivacy/logic/get_it/console_model.dart';
|
||||
import 'package:selfprivacy/logic/get_it/navigation.dart';
|
||||
|
||||
export 'package:selfprivacy/logic/get_it/api_config.dart';
|
||||
export 'package:selfprivacy/logic/get_it/api_connection_repository.dart';
|
||||
export 'package:selfprivacy/logic/get_it/console.dart';
|
||||
export 'package:selfprivacy/logic/get_it/console_model.dart';
|
||||
export 'package:selfprivacy/logic/get_it/navigation.dart';
|
||||
|
||||
final GetIt getIt = GetIt.instance;
|
||||
|
|
|
@ -4,15 +4,10 @@ import 'package:graphql_flutter/graphql_flutter.dart';
|
|||
import 'package:http/io_client.dart';
|
||||
import 'package:selfprivacy/config/get_it_config.dart';
|
||||
import 'package:selfprivacy/logic/api_maps/tls_options.dart';
|
||||
import 'package:selfprivacy/logic/models/message.dart';
|
||||
import 'package:selfprivacy/logic/models/console_log.dart';
|
||||
|
||||
void _logToAppConsole<T>(final T objectToLog) {
|
||||
getIt.get<ConsoleModel>().addMessage(
|
||||
Message(
|
||||
text: objectToLog.toString(),
|
||||
),
|
||||
);
|
||||
}
|
||||
void _addConsoleLog(final ConsoleLog message) =>
|
||||
getIt.get<ConsoleModel>().log(message);
|
||||
|
||||
class RequestLoggingLink extends Link {
|
||||
@override
|
||||
|
@ -20,13 +15,13 @@ class RequestLoggingLink extends Link {
|
|||
final Request request, [
|
||||
final NextLink? forward,
|
||||
]) async* {
|
||||
getIt.get<ConsoleModel>().addMessage(
|
||||
GraphQlRequestMessage(
|
||||
operation: request.operation,
|
||||
variables: request.variables,
|
||||
context: request.context,
|
||||
),
|
||||
);
|
||||
_addConsoleLog(
|
||||
GraphQlRequestConsoleLog(
|
||||
operation: request.operation,
|
||||
variables: request.variables,
|
||||
context: request.context,
|
||||
),
|
||||
);
|
||||
yield* forward!(request);
|
||||
}
|
||||
}
|
||||
|
@ -35,20 +30,25 @@ class ResponseLoggingParser extends ResponseParser {
|
|||
@override
|
||||
Response parseResponse(final Map<String, dynamic> body) {
|
||||
final response = super.parseResponse(body);
|
||||
getIt.get<ConsoleModel>().addMessage(
|
||||
GraphQlResponseMessage(
|
||||
data: response.data,
|
||||
errors: response.errors,
|
||||
context: response.context,
|
||||
),
|
||||
);
|
||||
_addConsoleLog(
|
||||
GraphQlResponseConsoleLog(
|
||||
data: response.data,
|
||||
errors: response.errors,
|
||||
context: response.context,
|
||||
),
|
||||
);
|
||||
return response;
|
||||
}
|
||||
|
||||
@override
|
||||
GraphQLError parseError(final Map<String, dynamic> error) {
|
||||
final graphQlError = super.parseError(error);
|
||||
_logToAppConsole(graphQlError);
|
||||
_addConsoleLog(
|
||||
ManualConsoleLog.warning(
|
||||
customTitle: 'GraphQL Error',
|
||||
content: graphQlError.toString(),
|
||||
),
|
||||
);
|
||||
return graphQlError;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -6,7 +6,7 @@ import 'package:dio/dio.dart';
|
|||
import 'package:dio/io.dart';
|
||||
import 'package:pretty_dio_logger/pretty_dio_logger.dart';
|
||||
import 'package:selfprivacy/config/get_it_config.dart';
|
||||
import 'package:selfprivacy/logic/models/message.dart';
|
||||
import 'package:selfprivacy/logic/models/console_log.dart';
|
||||
|
||||
abstract class RestApiMap {
|
||||
Future<Dio> getClient({final BaseOptions? customOptions}) async {
|
||||
|
@ -57,8 +57,8 @@ abstract class RestApiMap {
|
|||
}
|
||||
|
||||
class ConsoleInterceptor extends InterceptorsWrapper {
|
||||
void addMessage(final Message message) {
|
||||
getIt.get<ConsoleModel>().addMessage(message);
|
||||
void addConsoleLog(final ConsoleLog message) {
|
||||
getIt.get<ConsoleModel>().log(message);
|
||||
}
|
||||
|
||||
@override
|
||||
|
@ -66,8 +66,8 @@ class ConsoleInterceptor extends InterceptorsWrapper {
|
|||
final RequestOptions options,
|
||||
final RequestInterceptorHandler handler,
|
||||
) async {
|
||||
addMessage(
|
||||
RestApiRequestMessage(
|
||||
addConsoleLog(
|
||||
RestApiRequestConsoleLog(
|
||||
method: options.method,
|
||||
data: options.data.toString(),
|
||||
headers: options.headers,
|
||||
|
@ -82,8 +82,8 @@ class ConsoleInterceptor extends InterceptorsWrapper {
|
|||
final Response response,
|
||||
final ResponseInterceptorHandler handler,
|
||||
) async {
|
||||
addMessage(
|
||||
RestApiResponseMessage(
|
||||
addConsoleLog(
|
||||
RestApiResponseConsoleLog(
|
||||
method: response.requestOptions.method,
|
||||
statusCode: response.statusCode,
|
||||
data: response.data.toString(),
|
||||
|
@ -103,10 +103,12 @@ class ConsoleInterceptor extends InterceptorsWrapper {
|
|||
) async {
|
||||
final Response? response = err.response;
|
||||
log(err.toString());
|
||||
addMessage(
|
||||
Message.warn(
|
||||
text:
|
||||
'response-uri: ${response?.realUri}\ncode: ${response?.statusCode}\ndata: ${response?.toString()}\n',
|
||||
addConsoleLog(
|
||||
ManualConsoleLog.warning(
|
||||
customTitle: 'RestAPI error',
|
||||
content: 'response-uri: ${response?.realUri}\n'
|
||||
'code: ${response?.statusCode}\n'
|
||||
'data: ${response?.toString()}\n',
|
||||
),
|
||||
);
|
||||
return super.onError(err, handler);
|
||||
|
|
|
@ -1,17 +0,0 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:selfprivacy/logic/models/message.dart';
|
||||
|
||||
class ConsoleModel extends ChangeNotifier {
|
||||
final List<Message> _messages = [];
|
||||
|
||||
List<Message> get messages => _messages;
|
||||
|
||||
void addMessage(final Message message) {
|
||||
messages.add(message);
|
||||
notifyListeners();
|
||||
// Make sure we don't have too many messages
|
||||
if (messages.length > 500) {
|
||||
messages.removeAt(0);
|
||||
}
|
||||
}
|
||||
}
|
16
lib/logic/get_it/console_model.dart
Normal file
16
lib/logic/get_it/console_model.dart
Normal file
|
@ -0,0 +1,16 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:selfprivacy/logic/models/console_log.dart';
|
||||
|
||||
class ConsoleModel extends ChangeNotifier {
|
||||
final List<ConsoleLog> _logs = [];
|
||||
List<ConsoleLog> get logs => _logs;
|
||||
|
||||
void log(final ConsoleLog newLog) {
|
||||
logs.add(newLog);
|
||||
notifyListeners();
|
||||
// Make sure we don't have too many
|
||||
if (logs.length > 500) {
|
||||
logs.removeAt(0);
|
||||
}
|
||||
}
|
||||
}
|
141
lib/logic/models/console_log.dart
Normal file
141
lib/logic/models/console_log.dart
Normal file
|
@ -0,0 +1,141 @@
|
|||
import 'package:gql/language.dart';
|
||||
import 'package:graphql/client.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
|
||||
enum ConsoleLogSeverity {
|
||||
normal,
|
||||
warning,
|
||||
}
|
||||
|
||||
/// Base entity for console logs.
|
||||
///
|
||||
/// TODO(misterfourtytwo): should we add?
|
||||
///
|
||||
/// * equality override
|
||||
/// * translations of theese strings
|
||||
sealed class ConsoleLog {
|
||||
ConsoleLog({
|
||||
final String? customTitle,
|
||||
this.severity = ConsoleLogSeverity.normal,
|
||||
}) : title = customTitle ??
|
||||
(severity == ConsoleLogSeverity.warning ? 'Error' : 'Log'),
|
||||
time = DateTime.now();
|
||||
|
||||
final DateTime time;
|
||||
final ConsoleLogSeverity severity;
|
||||
bool get isError => severity == ConsoleLogSeverity.warning;
|
||||
|
||||
/// title for both in listing and in dialog
|
||||
final String title;
|
||||
|
||||
/// formatted data to be shown in listing
|
||||
String get content;
|
||||
|
||||
/// data available for copy in dialog
|
||||
String? get shareableData => '$title\n'
|
||||
'$content';
|
||||
|
||||
static final DateFormat _formatter = DateFormat('hh:mm:ss');
|
||||
String get timeString => _formatter.format(time);
|
||||
}
|
||||
|
||||
class ManualConsoleLog extends ConsoleLog {
|
||||
ManualConsoleLog({
|
||||
required this.content,
|
||||
super.customTitle,
|
||||
super.severity,
|
||||
});
|
||||
|
||||
ManualConsoleLog.warning({
|
||||
required this.content,
|
||||
super.customTitle,
|
||||
}) : super(severity: ConsoleLogSeverity.warning);
|
||||
|
||||
@override
|
||||
String content;
|
||||
}
|
||||
|
||||
class RestApiRequestConsoleLog extends ConsoleLog {
|
||||
RestApiRequestConsoleLog({
|
||||
this.method,
|
||||
this.uri,
|
||||
this.headers,
|
||||
this.data,
|
||||
super.severity,
|
||||
});
|
||||
|
||||
final String? method;
|
||||
final Uri? uri;
|
||||
final Map<String, dynamic>? headers;
|
||||
final String? data;
|
||||
|
||||
@override
|
||||
String get title => 'Rest API Request';
|
||||
@override
|
||||
String get content => 'method: $method\n'
|
||||
'uri: $uri';
|
||||
}
|
||||
|
||||
class RestApiResponseConsoleLog extends ConsoleLog {
|
||||
RestApiResponseConsoleLog({
|
||||
this.method,
|
||||
this.uri,
|
||||
this.statusCode,
|
||||
this.data,
|
||||
super.severity,
|
||||
});
|
||||
|
||||
final String? method;
|
||||
final Uri? uri;
|
||||
final int? statusCode;
|
||||
final String? data;
|
||||
|
||||
@override
|
||||
String get title => 'Rest API Response';
|
||||
@override
|
||||
String get content => 'method: $method | status code: $statusCode\n'
|
||||
'uri: $uri';
|
||||
}
|
||||
|
||||
class GraphQlRequestConsoleLog extends ConsoleLog {
|
||||
GraphQlRequestConsoleLog({
|
||||
this.operation,
|
||||
this.variables,
|
||||
this.context,
|
||||
super.severity,
|
||||
});
|
||||
|
||||
final Context? context;
|
||||
final Operation? operation;
|
||||
final Map<String, dynamic>? variables;
|
||||
|
||||
@override
|
||||
String get title => 'GraphQL Request';
|
||||
@override
|
||||
String get content => 'name: ${operation?.operationName}\n'
|
||||
'document: ${operation?.document != null ? printNode(operation!.document) : null}';
|
||||
String get stringifiedOperation => operation == null
|
||||
? 'null'
|
||||
: 'Operation{\n'
|
||||
'\tname: ${operation?.operationName},\n'
|
||||
'\tdocument: ${operation?.document != null ? printNode(operation!.document) : null}\n'
|
||||
'}';
|
||||
}
|
||||
|
||||
class GraphQlResponseConsoleLog extends ConsoleLog {
|
||||
GraphQlResponseConsoleLog({
|
||||
this.data,
|
||||
this.errors,
|
||||
this.context,
|
||||
super.severity,
|
||||
});
|
||||
|
||||
final Context? context;
|
||||
final Map<String, dynamic>? data;
|
||||
final List<GraphQLError>? errors;
|
||||
|
||||
@override
|
||||
String get title => 'GraphQL Response';
|
||||
@override
|
||||
String get content => 'data: $data';
|
||||
}
|
|
@ -1,75 +0,0 @@
|
|||
import 'package:graphql/client.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
|
||||
/// TODO(misterfourtytwo): add equality override
|
||||
class Message {
|
||||
Message({this.text, this.severity = MessageSeverity.normal})
|
||||
: time = DateTime.now();
|
||||
Message.warn({this.text})
|
||||
: severity = MessageSeverity.warning,
|
||||
time = DateTime.now();
|
||||
|
||||
final String? text;
|
||||
final DateTime time;
|
||||
final MessageSeverity severity;
|
||||
|
||||
static final DateFormat _formatter = DateFormat('hh:mm');
|
||||
String get timeString => _formatter.format(time);
|
||||
}
|
||||
|
||||
enum MessageSeverity {
|
||||
normal,
|
||||
warning,
|
||||
}
|
||||
|
||||
class RestApiRequestMessage extends Message {
|
||||
RestApiRequestMessage({
|
||||
this.method,
|
||||
this.uri,
|
||||
this.data,
|
||||
this.headers,
|
||||
}) : super(text: 'request-uri: $uri\nheaders: $headers\ndata: $data');
|
||||
|
||||
final String? method;
|
||||
final Uri? uri;
|
||||
final String? data;
|
||||
final Map<String, dynamic>? headers;
|
||||
}
|
||||
|
||||
class RestApiResponseMessage extends Message {
|
||||
RestApiResponseMessage({
|
||||
this.method,
|
||||
this.uri,
|
||||
this.statusCode,
|
||||
this.data,
|
||||
}) : super(text: 'response-uri: $uri\ncode: $statusCode\ndata: $data');
|
||||
|
||||
final String? method;
|
||||
final Uri? uri;
|
||||
final int? statusCode;
|
||||
final String? data;
|
||||
}
|
||||
|
||||
class GraphQlResponseMessage extends Message {
|
||||
GraphQlResponseMessage({
|
||||
this.data,
|
||||
this.errors,
|
||||
this.context,
|
||||
}) : super(text: 'GraphQL Response\ndata: $data');
|
||||
|
||||
final Map<String, dynamic>? data;
|
||||
final List<GraphQLError>? errors;
|
||||
final Context? context;
|
||||
}
|
||||
|
||||
class GraphQlRequestMessage extends Message {
|
||||
GraphQlRequestMessage({
|
||||
this.operation,
|
||||
this.variables,
|
||||
this.context,
|
||||
}) : super(text: 'GraphQL Request\noperation: $operation');
|
||||
|
||||
final Operation? operation;
|
||||
final Map<String, dynamic>? variables;
|
||||
final Context? context;
|
||||
}
|
|
@ -1,304 +0,0 @@
|
|||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:selfprivacy/logic/models/message.dart';
|
||||
import 'package:selfprivacy/utils/platform_adapter.dart';
|
||||
|
||||
class LogListItem extends StatelessWidget {
|
||||
const LogListItem({
|
||||
required this.message,
|
||||
super.key,
|
||||
});
|
||||
|
||||
final Message message;
|
||||
|
||||
@override
|
||||
Widget build(final BuildContext context) {
|
||||
final messageItem = message;
|
||||
if (messageItem is RestApiRequestMessage) {
|
||||
return _RestApiRequestMessageItem(message: messageItem);
|
||||
} else if (messageItem is RestApiResponseMessage) {
|
||||
return _RestApiResponseMessageItem(message: messageItem);
|
||||
} else if (messageItem is GraphQlResponseMessage) {
|
||||
return _GraphQlResponseMessageItem(message: messageItem);
|
||||
} else if (messageItem is GraphQlRequestMessage) {
|
||||
return _GraphQlRequestMessageItem(message: messageItem);
|
||||
} else {
|
||||
return _DefaultMessageItem(message: messageItem);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class _RestApiRequestMessageItem extends StatelessWidget {
|
||||
const _RestApiRequestMessageItem({required this.message});
|
||||
|
||||
final RestApiRequestMessage message;
|
||||
|
||||
@override
|
||||
Widget build(final BuildContext context) => ListTile(
|
||||
title: Text(
|
||||
'${message.method}\n${message.uri}',
|
||||
),
|
||||
subtitle: Text(message.timeString),
|
||||
leading: const Icon(Icons.upload_outlined),
|
||||
iconColor: Theme.of(context).colorScheme.secondary,
|
||||
onTap: () => showDialog(
|
||||
context: context,
|
||||
builder: (final BuildContext context) => AlertDialog(
|
||||
scrollable: true,
|
||||
title: Text(
|
||||
'${message.method}\n${message.uri}',
|
||||
),
|
||||
content: Column(
|
||||
children: [
|
||||
Text(message.timeString),
|
||||
const SizedBox(height: 16),
|
||||
// Headers is a map of key-value pairs
|
||||
if (message.headers != null) const Text('Headers'),
|
||||
if (message.headers != null)
|
||||
Text(
|
||||
message.headers!.entries
|
||||
.map((final entry) => '${entry.key}: ${entry.value}')
|
||||
.join('\n'),
|
||||
),
|
||||
if (message.data != null && message.data != 'null')
|
||||
const Text('Data'),
|
||||
if (message.data != null && message.data != 'null')
|
||||
Text(message.data!),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
// A button to copy the request to the clipboard
|
||||
if (message.text != null)
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
PlatformAdapter.setClipboard(message.text ?? '');
|
||||
},
|
||||
child: Text('console_page.copy'.tr()),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: Text('basis.close'.tr()),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
class _RestApiResponseMessageItem extends StatelessWidget {
|
||||
const _RestApiResponseMessageItem({required this.message});
|
||||
|
||||
final RestApiResponseMessage message;
|
||||
|
||||
@override
|
||||
Widget build(final BuildContext context) => ListTile(
|
||||
title: Text(
|
||||
'${message.statusCode} ${message.method}\n${message.uri}',
|
||||
),
|
||||
subtitle: Text(message.timeString),
|
||||
leading: const Icon(Icons.download_outlined),
|
||||
iconColor: Theme.of(context).colorScheme.primary,
|
||||
onTap: () => showDialog(
|
||||
context: context,
|
||||
builder: (final BuildContext context) => AlertDialog(
|
||||
scrollable: true,
|
||||
title: Text(
|
||||
'${message.statusCode} ${message.method}\n${message.uri}',
|
||||
),
|
||||
content: Column(
|
||||
children: [
|
||||
Text(message.timeString),
|
||||
const SizedBox(height: 16),
|
||||
// Headers is a map of key-value pairs
|
||||
if (message.data != null && message.data != 'null')
|
||||
const Text('Data'),
|
||||
if (message.data != null && message.data != 'null')
|
||||
Text(message.data!),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
// A button to copy the request to the clipboard
|
||||
if (message.text != null)
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
PlatformAdapter.setClipboard(message.text ?? '');
|
||||
},
|
||||
child: Text('console_page.copy'.tr()),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: Text('basis.close'.tr()),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
class _GraphQlResponseMessageItem extends StatelessWidget {
|
||||
const _GraphQlResponseMessageItem({required this.message});
|
||||
|
||||
final GraphQlResponseMessage message;
|
||||
|
||||
@override
|
||||
Widget build(final BuildContext context) => ListTile(
|
||||
title: Text(
|
||||
'GraphQL Response at ${message.timeString}',
|
||||
),
|
||||
subtitle: Text(
|
||||
message.data.toString(),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
maxLines: 1,
|
||||
),
|
||||
leading: const Icon(Icons.arrow_circle_down_outlined),
|
||||
iconColor: Theme.of(context).colorScheme.tertiary,
|
||||
onTap: () => showDialog(
|
||||
context: context,
|
||||
builder: (final BuildContext context) => AlertDialog(
|
||||
scrollable: true,
|
||||
title: Text(
|
||||
'GraphQL Response at ${message.timeString}',
|
||||
),
|
||||
content: Column(
|
||||
children: [
|
||||
Text(message.timeString),
|
||||
const Divider(),
|
||||
if (message.data != null) const Text('Data'),
|
||||
// Data is a map of key-value pairs
|
||||
if (message.data != null)
|
||||
Text(
|
||||
message.data!.entries
|
||||
.map((final entry) => '${entry.key}: ${entry.value}')
|
||||
.join('\n'),
|
||||
),
|
||||
const Divider(),
|
||||
if (message.errors != null) const Text('Errors'),
|
||||
if (message.errors != null)
|
||||
Text(
|
||||
message.errors!
|
||||
.map(
|
||||
(final entry) =>
|
||||
'${entry.message} at ${entry.locations}',
|
||||
)
|
||||
.join('\n'),
|
||||
),
|
||||
const Divider(),
|
||||
if (message.context != null) const Text('Context'),
|
||||
if (message.context != null)
|
||||
Text(
|
||||
message.context!.toString(),
|
||||
),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
// A button to copy the request to the clipboard
|
||||
if (message.text != null)
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
PlatformAdapter.setClipboard(message.text ?? '');
|
||||
},
|
||||
child: Text('console_page.copy'.tr()),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: Text('basis.close'.tr()),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
class _GraphQlRequestMessageItem extends StatelessWidget {
|
||||
const _GraphQlRequestMessageItem({required this.message});
|
||||
|
||||
final GraphQlRequestMessage message;
|
||||
|
||||
@override
|
||||
Widget build(final BuildContext context) => ListTile(
|
||||
title: Text(
|
||||
'GraphQL Request at ${message.timeString}',
|
||||
),
|
||||
subtitle: Text(
|
||||
message.operation.toString(),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
maxLines: 1,
|
||||
),
|
||||
leading: const Icon(Icons.arrow_circle_up_outlined),
|
||||
iconColor: Theme.of(context).colorScheme.secondary,
|
||||
onTap: () => showDialog(
|
||||
context: context,
|
||||
builder: (final BuildContext context) => AlertDialog(
|
||||
scrollable: true,
|
||||
title: Text(
|
||||
'GraphQL Response at ${message.timeString}',
|
||||
),
|
||||
content: Column(
|
||||
children: [
|
||||
Text(message.timeString),
|
||||
const Divider(),
|
||||
if (message.operation != null) const Text('Operation'),
|
||||
// Data is a map of key-value pairs
|
||||
if (message.operation != null)
|
||||
Text(
|
||||
message.operation!.toString(),
|
||||
),
|
||||
const Divider(),
|
||||
if (message.variables != null) const Text('Variables'),
|
||||
if (message.variables != null)
|
||||
Text(
|
||||
message.variables!.entries
|
||||
.map((final entry) => '${entry.key}: ${entry.value}')
|
||||
.join('\n'),
|
||||
),
|
||||
const Divider(),
|
||||
if (message.context != null) const Text('Context'),
|
||||
if (message.context != null)
|
||||
Text(
|
||||
message.context!.toString(),
|
||||
),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
// A button to copy the request to the clipboard
|
||||
if (message.text != null)
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
PlatformAdapter.setClipboard(message.text ?? '');
|
||||
},
|
||||
child: Text('console_page.copy'.tr()),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: Text('basis.close'.tr()),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
class _DefaultMessageItem extends StatelessWidget {
|
||||
const _DefaultMessageItem({required this.message});
|
||||
|
||||
final Message message;
|
||||
|
||||
@override
|
||||
Widget build(final BuildContext context) => Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||
child: RichText(
|
||||
text: TextSpan(
|
||||
style: DefaultTextStyle.of(context).style,
|
||||
children: <TextSpan>[
|
||||
TextSpan(
|
||||
text: '${message.timeString}: \n',
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
TextSpan(text: message.text),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
|
@ -1,107 +0,0 @@
|
|||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:gap/gap.dart';
|
||||
import 'package:selfprivacy/config/get_it_config.dart';
|
||||
import 'package:selfprivacy/logic/models/message.dart';
|
||||
import 'package:selfprivacy/ui/components/list_tiles/log_list_tile.dart';
|
||||
|
||||
@RoutePage()
|
||||
class ConsolePage extends StatefulWidget {
|
||||
const ConsolePage({super.key});
|
||||
|
||||
@override
|
||||
State<ConsolePage> createState() => _ConsolePageState();
|
||||
}
|
||||
|
||||
class _ConsolePageState extends State<ConsolePage> {
|
||||
bool paused = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
getIt<ConsoleModel>().addListener(update);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
getIt<ConsoleModel>().removeListener(update);
|
||||
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void update() {
|
||||
/// listener update could come at any time, like when widget is already
|
||||
/// unmounted or during frame build, adding as postframe callback ensures
|
||||
/// that element is marked for rebuild
|
||||
WidgetsBinding.instance.addPostFrameCallback((final _) {
|
||||
if (!paused && mounted) {
|
||||
setState(() => {});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void togglePause() {
|
||||
paused ^= true;
|
||||
setState(() {});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(final BuildContext context) => SafeArea(
|
||||
child: Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text('console_page.title'.tr()),
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.arrow_back),
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
),
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: Icon(
|
||||
paused ? Icons.play_arrow_outlined : Icons.pause_outlined,
|
||||
),
|
||||
onPressed: togglePause,
|
||||
),
|
||||
],
|
||||
),
|
||||
body: FutureBuilder(
|
||||
future: getIt.allReady(),
|
||||
builder: (
|
||||
final BuildContext context,
|
||||
final AsyncSnapshot<void> snapshot,
|
||||
) {
|
||||
if (snapshot.hasData) {
|
||||
final List<Message> messages =
|
||||
getIt.get<ConsoleModel>().messages;
|
||||
|
||||
return ListView(
|
||||
reverse: true,
|
||||
shrinkWrap: true,
|
||||
children: [
|
||||
const Gap(20),
|
||||
...messages.reversed.map(
|
||||
(final message) => LogListItem(
|
||||
key: ValueKey(message),
|
||||
message: message,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
} else {
|
||||
return Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Text('console_page.waiting'.tr()),
|
||||
const Gap(16),
|
||||
const CircularProgressIndicator.adaptive(),
|
||||
],
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
186
lib/ui/pages/more/console/console_log_item_dialog.dart
Normal file
186
lib/ui/pages/more/console/console_log_item_dialog.dart
Normal file
|
@ -0,0 +1,186 @@
|
|||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:selfprivacy/logic/models/console_log.dart';
|
||||
import 'package:selfprivacy/utils/platform_adapter.dart';
|
||||
|
||||
extension on ConsoleLog {
|
||||
List<Widget> unwrapContent(final BuildContext context) => switch (this) {
|
||||
(final RestApiRequestConsoleLog log) => [
|
||||
if (log.method != null) _KeyValueRow('method', log.method),
|
||||
if (log.uri != null) _KeyValueRow('uri', log.uri.toString()),
|
||||
|
||||
// headers bloc
|
||||
if (log.headers?.isNotEmpty ?? false) const _SectionRow('Headers'),
|
||||
...?log.headers?.entries
|
||||
.map((final entry) => _KeyValueRow(entry.key, entry.value)),
|
||||
|
||||
// data bloc
|
||||
const _SectionRow('Request data'),
|
||||
_DataRow(log.data?.toString()),
|
||||
],
|
||||
(final RestApiResponseConsoleLog log) => [
|
||||
if (log.method != null) _KeyValueRow('method', log.method),
|
||||
if (log.uri != null) _KeyValueRow('uri', log.uri.toString()),
|
||||
if (log.statusCode != null)
|
||||
_KeyValueRow(
|
||||
'statusCode',
|
||||
log.statusCode.toString(),
|
||||
),
|
||||
|
||||
// data bloc
|
||||
const _SectionRow('Response data'),
|
||||
_DataRow(log.data?.toString()),
|
||||
],
|
||||
(final GraphQlRequestConsoleLog log) => [
|
||||
// context
|
||||
const _SectionRow('Context'),
|
||||
_DataRow(log.context?.toString()),
|
||||
// data
|
||||
if (log.operation != null) const _SectionRow('Operation'),
|
||||
_DataRow(log.stringifiedOperation), // errors
|
||||
if (log.variables?.isNotEmpty ?? false)
|
||||
const _SectionRow('Variables'),
|
||||
...?log.variables?.entries.map(
|
||||
(final entry) => _KeyValueRow(
|
||||
entry.key,
|
||||
'${entry.value}',
|
||||
),
|
||||
),
|
||||
],
|
||||
(final GraphQlResponseConsoleLog log) => [
|
||||
// context
|
||||
const _SectionRow('Context'),
|
||||
_DataRow(log.context?.toString()),
|
||||
// data
|
||||
if (log.data != null) const _SectionRow('Data'),
|
||||
...?log.data?.entries.map(
|
||||
(final entry) => _KeyValueRow(
|
||||
entry.key,
|
||||
'${entry.value}',
|
||||
),
|
||||
),
|
||||
// errors
|
||||
if (log.errors?.isNotEmpty ?? false) const _SectionRow('Errors'),
|
||||
...?log.errors?.map(
|
||||
(final entry) => _KeyValueRow(
|
||||
entry.message,
|
||||
'${entry.locations}',
|
||||
),
|
||||
),
|
||||
],
|
||||
(final ManualConsoleLog log) => [
|
||||
_DataRow(log.content),
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
class ConsoleItemDialog extends StatelessWidget {
|
||||
const ConsoleItemDialog({
|
||||
required this.log,
|
||||
super.key,
|
||||
});
|
||||
|
||||
final ConsoleLog log;
|
||||
|
||||
@override
|
||||
Widget build(final BuildContext context) {
|
||||
final content = log.unwrapContent(context);
|
||||
|
||||
return AlertDialog(
|
||||
scrollable: true,
|
||||
title: Text(log.title),
|
||||
content: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
/// TODO(misterfourtytwo): maybe should add translations later
|
||||
const Text('logged at: '),
|
||||
SelectableText(
|
||||
log.timeString,
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.w700,
|
||||
fontFeatures: [FontFeature.tabularFigures()],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const Divider(),
|
||||
...content,
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
// A button to copy the request to the clipboard
|
||||
if (log.shareableData?.isNotEmpty ?? false)
|
||||
TextButton(
|
||||
onPressed: () => PlatformAdapter.setClipboard(log.content),
|
||||
child: Text('console_page.copy'.tr()),
|
||||
),
|
||||
// close dialog
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: Text('basis.close'.tr()),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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),
|
||||
);
|
||||
}
|
||||
|
||||
class _SectionRow extends StatelessWidget {
|
||||
const _SectionRow(this.title);
|
||||
final String title;
|
||||
|
||||
@override
|
||||
Widget build(final BuildContext context) => Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
border: Border(
|
||||
bottom: BorderSide(
|
||||
color: Theme.of(context).colorScheme.outlineVariant,
|
||||
width: 2.4,
|
||||
),
|
||||
),
|
||||
),
|
||||
child: SelectableText(
|
||||
title,
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.w800,
|
||||
fontSize: 20,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
71
lib/ui/pages/more/console/console_log_item_widget.dart
Normal file
71
lib/ui/pages/more/console/console_log_item_widget.dart
Normal file
|
@ -0,0 +1,71 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:selfprivacy/logic/models/console_log.dart';
|
||||
import 'package:selfprivacy/ui/pages/more/console/console_log_item_dialog.dart';
|
||||
|
||||
extension on ConsoleLog {
|
||||
Color resolveColor(final BuildContext context) => isError
|
||||
? Theme.of(context).colorScheme.error
|
||||
: switch (this) {
|
||||
(final RestApiRequestConsoleLog _) =>
|
||||
Theme.of(context).colorScheme.secondary,
|
||||
(final RestApiResponseConsoleLog _) =>
|
||||
Theme.of(context).colorScheme.primary,
|
||||
(final GraphQlRequestConsoleLog _) =>
|
||||
Theme.of(context).colorScheme.secondary,
|
||||
(final GraphQlResponseConsoleLog _) =>
|
||||
Theme.of(context).colorScheme.tertiary,
|
||||
(final ManualConsoleLog _) => Theme.of(context).colorScheme.tertiary,
|
||||
};
|
||||
|
||||
IconData resolveIcon() => switch (this) {
|
||||
(final RestApiRequestConsoleLog _) => Icons.upload_outlined,
|
||||
(final RestApiResponseConsoleLog _) => Icons.download_outlined,
|
||||
(final GraphQlRequestConsoleLog _) => Icons.arrow_circle_up_outlined,
|
||||
(final GraphQlResponseConsoleLog _) => Icons.arrow_circle_down_outlined,
|
||||
(final ManualConsoleLog _) => Icons.read_more_outlined,
|
||||
};
|
||||
}
|
||||
|
||||
class ConsoleLogItemWidget extends StatelessWidget {
|
||||
const ConsoleLogItemWidget({
|
||||
required this.log,
|
||||
super.key,
|
||||
});
|
||||
|
||||
final ConsoleLog log;
|
||||
|
||||
@override
|
||||
Widget build(final BuildContext context) => ListTile(
|
||||
dense: true,
|
||||
title: SelectableText.rich(
|
||||
TextSpan(
|
||||
style: DefaultTextStyle.of(context).style,
|
||||
children: <TextSpan>[
|
||||
TextSpan(
|
||||
text: '${log.timeString}: ',
|
||||
style: const TextStyle(
|
||||
fontFeatures: [FontFeature.tabularFigures()],
|
||||
),
|
||||
),
|
||||
TextSpan(
|
||||
text: log.title,
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
subtitle: Text(
|
||||
log.content,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
maxLines: 3,
|
||||
),
|
||||
leading: Icon(log.resolveIcon()),
|
||||
iconColor: log.resolveColor(context),
|
||||
onTap: () => showDialog(
|
||||
context: context,
|
||||
builder: (final BuildContext context) => ConsoleItemDialog(log: log),
|
||||
),
|
||||
);
|
||||
}
|
138
lib/ui/pages/more/console/console_page.dart
Normal file
138
lib/ui/pages/more/console/console_page.dart
Normal file
|
@ -0,0 +1,138 @@
|
|||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:gap/gap.dart';
|
||||
import 'package:selfprivacy/config/get_it_config.dart';
|
||||
import 'package:selfprivacy/logic/models/console_log.dart';
|
||||
import 'package:selfprivacy/ui/pages/more/console/console_log_item_widget.dart';
|
||||
|
||||
/// listing with 500 latest app operations.
|
||||
@RoutePage()
|
||||
class ConsolePage extends StatefulWidget {
|
||||
const ConsolePage({super.key});
|
||||
|
||||
@override
|
||||
State<ConsolePage> createState() => _ConsolePageState();
|
||||
}
|
||||
|
||||
class _ConsolePageState extends State<ConsolePage> {
|
||||
/// should freeze logs state to properly read logs
|
||||
bool paused = false;
|
||||
late final Future<void> future;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
future = getIt.allReady();
|
||||
getIt<ConsoleModel>().addListener(update);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
getIt<ConsoleModel>().removeListener(update);
|
||||
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void update() {
|
||||
/// listener update could come at any time, like when widget is already
|
||||
/// unmounted or during frame build, adding as postframe callback ensures
|
||||
/// that element is marked for rebuild
|
||||
WidgetsBinding.instance.addPostFrameCallback((final _) {
|
||||
if (!paused && mounted) {
|
||||
setState(() => {});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void togglePause() {
|
||||
paused ^= true;
|
||||
setState(() {});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(final BuildContext context) => SafeArea(
|
||||
child: Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text('console_page.title'.tr()),
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.arrow_back),
|
||||
onPressed: () => Navigator.of(context).maybePop(),
|
||||
),
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: Icon(
|
||||
paused ? Icons.play_arrow_outlined : Icons.pause_outlined,
|
||||
),
|
||||
onPressed: togglePause,
|
||||
),
|
||||
],
|
||||
),
|
||||
body: SelectionArea(
|
||||
child: Scrollbar(
|
||||
child: FutureBuilder(
|
||||
future: future,
|
||||
builder: (
|
||||
final BuildContext context,
|
||||
final AsyncSnapshot<void> snapshot,
|
||||
) {
|
||||
if (snapshot.hasData) {
|
||||
final List<ConsoleLog> messages =
|
||||
getIt.get<ConsoleModel>().logs;
|
||||
|
||||
return _ConsoleViewLoaded(
|
||||
logs: messages,
|
||||
);
|
||||
}
|
||||
|
||||
return const _ConsoleViewLoading();
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
class _ConsoleViewLoading extends StatelessWidget {
|
||||
const _ConsoleViewLoading();
|
||||
|
||||
@override
|
||||
Widget build(final BuildContext context) => Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Text('console_page.waiting'.tr()),
|
||||
const Gap(16),
|
||||
const Expanded(
|
||||
child: Center(
|
||||
child: CircularProgressIndicator.adaptive(),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
class _ConsoleViewLoaded extends StatelessWidget {
|
||||
const _ConsoleViewLoaded({required this.logs});
|
||||
|
||||
final List<ConsoleLog> logs;
|
||||
|
||||
@override
|
||||
Widget build(final BuildContext context) => ListView.separated(
|
||||
primary: true,
|
||||
padding: const EdgeInsets.symmetric(vertical: 10),
|
||||
itemCount: logs.length,
|
||||
itemBuilder: (final BuildContext context, final int index) {
|
||||
final log = logs[logs.length - 1 - index];
|
||||
|
||||
return ConsoleLogItemWidget(
|
||||
key: ValueKey(log),
|
||||
log: log,
|
||||
);
|
||||
},
|
||||
separatorBuilder: (final context, final _) => const Divider(),
|
||||
);
|
||||
}
|
|
@ -10,7 +10,7 @@ import 'package:selfprivacy/ui/pages/dns_details/dns_details.dart';
|
|||
import 'package:selfprivacy/ui/pages/more/about_application.dart';
|
||||
import 'package:selfprivacy/ui/pages/more/app_settings/app_settings.dart';
|
||||
import 'package:selfprivacy/ui/pages/more/app_settings/developer_settings.dart';
|
||||
import 'package:selfprivacy/ui/pages/more/console.dart';
|
||||
import 'package:selfprivacy/ui/pages/more/console/console_page.dart';
|
||||
import 'package:selfprivacy/ui/pages/more/more.dart';
|
||||
import 'package:selfprivacy/ui/pages/onboarding/onboarding.dart';
|
||||
import 'package:selfprivacy/ui/pages/providers/providers.dart';
|
||||
|
@ -53,6 +53,7 @@ class RootRouter extends _$RootRouter {
|
|||
|
||||
@override
|
||||
RouteType get defaultRouteType => const RouteType.material();
|
||||
|
||||
@override
|
||||
final List<AutoRoute> routes = [
|
||||
AutoRoute(page: OnboardingRoute.page),
|
||||
|
|
Loading…
Reference in a new issue