diff --git a/assets/translations/en.json b/assets/translations/en.json index a6a2f05c..0d825bff 100644 --- a/assets/translations/en.json +++ b/assets/translations/en.json @@ -47,7 +47,8 @@ }, "console_page": { "title": "Console", - "waiting": "Waiting for initialization…" + "waiting": "Waiting for initialization…", + "copy": "Copy" }, "about_us_page": { "title": "About us" diff --git a/lib/logic/api_maps/graphql_maps/api_map.dart b/lib/logic/api_maps/graphql_maps/api_map.dart index 02b9bc04..a633866e 100644 --- a/lib/logic/api_maps/graphql_maps/api_map.dart +++ b/lib/logic/api_maps/graphql_maps/api_map.dart @@ -20,7 +20,13 @@ class RequestLoggingLink extends Link { final Request request, [ final NextLink? forward, ]) async* { - _logToAppConsole(request); + getIt.get().addMessage( + GraphQlRequestMessage( + operation: request.operation, + variables: request.variables, + context: request.context, + ), + ); yield* forward!(request); } } @@ -29,7 +35,13 @@ class ResponseLoggingParser extends ResponseParser { @override Response parseResponse(final Map body) { final response = super.parseResponse(body); - _logToAppConsole(response); + getIt.get().addMessage( + GraphQlResponseMessage( + data: response.data, + errors: response.errors, + context: response.context, + ), + ); return response; } diff --git a/lib/logic/api_maps/rest_maps/api_map.dart b/lib/logic/api_maps/rest_maps/api_map.dart index 6fd0bdda..299837fa 100644 --- a/lib/logic/api_maps/rest_maps/api_map.dart +++ b/lib/logic/api_maps/rest_maps/api_map.dart @@ -65,9 +65,11 @@ class ConsoleInterceptor extends InterceptorsWrapper { final RequestInterceptorHandler handler, ) async { addMessage( - Message( - text: - 'request-uri: ${options.uri}\nheaders: ${options.headers}\ndata: ${options.data}', + RestApiRequestMessage( + method: options.method, + data: options.data.toString(), + headers: options.headers, + uri: options.uri, ), ); return super.onRequest(options, handler); @@ -79,9 +81,11 @@ class ConsoleInterceptor extends InterceptorsWrapper { final ResponseInterceptorHandler handler, ) async { addMessage( - Message( - text: - 'response-uri: ${response.realUri}\ncode: ${response.statusCode}\ndata: ${response.toString()}\n', + RestApiResponseMessage( + method: response.requestOptions.method, + statusCode: response.statusCode, + data: response.data.toString(), + uri: response.realUri, ), ); return super.onResponse( diff --git a/lib/logic/models/message.dart b/lib/logic/models/message.dart index 8bbc6dfd..aaaf0930 100644 --- a/lib/logic/models/message.dart +++ b/lib/logic/models/message.dart @@ -1,20 +1,74 @@ +import 'package:graphql/client.dart'; import 'package:intl/intl.dart'; final DateFormat formatter = DateFormat('hh:mm'); class Message { - Message({this.text, this.type = MessageType.normal}) : time = DateTime.now(); + Message({this.text, this.severity = MessageSeverity.normal}) + : time = DateTime.now(); Message.warn({this.text}) - : type = MessageType.warning, + : severity = MessageSeverity.warning, time = DateTime.now(); final String? text; final DateTime time; - final MessageType type; + final MessageSeverity severity; String get timeString => formatter.format(time); } -enum MessageType { +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? 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? data; + final List? 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? variables; + final Context? context; +} diff --git a/lib/ui/components/list_tiles/log_list_tile.dart b/lib/ui/components/list_tiles/log_list_tile.dart new file mode 100644 index 00000000..1165ff25 --- /dev/null +++ b/lib/ui/components/list_tiles/log_list_tile.dart @@ -0,0 +1,292 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:selfprivacy/logic/models/message.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 + TextButton( + onPressed: () { + Clipboard.setData(ClipboardData(text: 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 + TextButton( + onPressed: () { + Clipboard.setData(ClipboardData(text: 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 + TextButton( + onPressed: () { + Clipboard.setData(ClipboardData(text: 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 + TextButton( + onPressed: () { + Clipboard.setData(ClipboardData(text: 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( + text: '${message.timeString}: \n', + style: const TextStyle( + fontWeight: FontWeight.bold, + ), + ), + TextSpan(text: message.text), + ], + ), + ), + ); +} diff --git a/lib/ui/pages/more/console.dart b/lib/ui/pages/more/console.dart index 85df0ffa..59ad514a 100644 --- a/lib/ui/pages/more/console.dart +++ b/lib/ui/pages/more/console.dart @@ -5,7 +5,7 @@ import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:selfprivacy/config/get_it_config.dart'; import 'package:selfprivacy/logic/models/message.dart'; -import 'package:selfprivacy/ui/components/brand_header/brand_header.dart'; +import 'package:selfprivacy/ui/components/list_tiles/log_list_tile.dart'; @RoutePage() class ConsolePage extends StatefulWidget { @@ -29,21 +29,29 @@ class _ConsolePageState extends State { super.dispose(); } - void update() => setState(() => {}); + bool paused = false; + + void update() { + if (!paused) { + setState(() => {}); + } + } @override Widget build(final BuildContext context) => SafeArea( child: Scaffold( - appBar: PreferredSize( - preferredSize: const Size.fromHeight(53), - child: Column( - children: [ - BrandHeader( - title: 'console_page.title'.tr(), - hasBackButton: true, - ), - ], + 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: () => setState(() => paused = !paused), + ), + ], ), body: FutureBuilder( future: getIt.allReady(), @@ -62,33 +70,7 @@ class _ConsolePageState extends State { const SizedBox(height: 20), ...UnmodifiableListView( messages - .map((final message) { - final bool isError = - message.type == MessageType.warning; - return Padding( - padding: const EdgeInsets.symmetric(vertical: 4), - child: RichText( - text: TextSpan( - style: DefaultTextStyle.of(context).style, - children: [ - TextSpan( - text: - '${message.timeString}${isError ? '(Error)' : ''}: \n', - style: TextStyle( - fontWeight: FontWeight.bold, - color: isError - ? Theme.of(context) - .colorScheme - .error - : null, - ), - ), - TextSpan(text: message.text), - ], - ), - ), - ); - }) + .map((final message) => LogListItem(message: message)) .toList() .reversed, ),