From 00545c34b44e706281b8b14604cb71a3161a1fd9 Mon Sep 17 00:00:00 2001 From: Aliaksei Tratseuski Date: Sat, 20 Apr 2024 13:53:55 +0400 Subject: [PATCH] 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 --- lib/config/get_it_config.dart | 4 +- .../graphql_maps/graphql_api_map.dart | 46 +-- .../api_maps/rest_maps/rest_api_map.dart | 24 +- lib/logic/get_it/console.dart | 17 - lib/logic/get_it/console_model.dart | 16 + lib/logic/models/console_log.dart | 141 ++++++++ lib/logic/models/message.dart | 75 ----- .../components/list_tiles/log_list_tile.dart | 304 ------------------ lib/ui/pages/more/console.dart | 107 ------ .../more/console/console_log_item_dialog.dart | 186 +++++++++++ .../more/console/console_log_item_widget.dart | 71 ++++ lib/ui/pages/more/console/console_page.dart | 138 ++++++++ lib/ui/router/router.dart | 3 +- 13 files changed, 592 insertions(+), 540 deletions(-) delete mode 100644 lib/logic/get_it/console.dart create mode 100644 lib/logic/get_it/console_model.dart create mode 100644 lib/logic/models/console_log.dart delete mode 100644 lib/logic/models/message.dart delete mode 100644 lib/ui/components/list_tiles/log_list_tile.dart delete mode 100644 lib/ui/pages/more/console.dart create mode 100644 lib/ui/pages/more/console/console_log_item_dialog.dart create mode 100644 lib/ui/pages/more/console/console_log_item_widget.dart create mode 100644 lib/ui/pages/more/console/console_page.dart diff --git a/lib/config/get_it_config.dart b/lib/config/get_it_config.dart index 78e40261..aa3db90b 100644 --- a/lib/config/get_it_config.dart +++ b/lib/config/get_it_config.dart @@ -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; diff --git a/lib/logic/api_maps/graphql_maps/graphql_api_map.dart b/lib/logic/api_maps/graphql_maps/graphql_api_map.dart index 6a00f5b6..698f59e8 100644 --- a/lib/logic/api_maps/graphql_maps/graphql_api_map.dart +++ b/lib/logic/api_maps/graphql_maps/graphql_api_map.dart @@ -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(final T objectToLog) { - getIt.get().addMessage( - Message( - text: objectToLog.toString(), - ), - ); -} +void _addConsoleLog(final ConsoleLog message) => + getIt.get().log(message); class RequestLoggingLink extends Link { @override @@ -20,13 +15,13 @@ class RequestLoggingLink extends Link { final Request request, [ final NextLink? forward, ]) async* { - getIt.get().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 body) { final response = super.parseResponse(body); - getIt.get().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 error) { final graphQlError = super.parseError(error); - _logToAppConsole(graphQlError); + _addConsoleLog( + ManualConsoleLog.warning( + customTitle: 'GraphQL Error', + content: graphQlError.toString(), + ), + ); return graphQlError; } } diff --git a/lib/logic/api_maps/rest_maps/rest_api_map.dart b/lib/logic/api_maps/rest_maps/rest_api_map.dart index 3a8d0571..338b99c2 100644 --- a/lib/logic/api_maps/rest_maps/rest_api_map.dart +++ b/lib/logic/api_maps/rest_maps/rest_api_map.dart @@ -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 getClient({final BaseOptions? customOptions}) async { @@ -57,8 +57,8 @@ abstract class RestApiMap { } class ConsoleInterceptor extends InterceptorsWrapper { - void addMessage(final Message message) { - getIt.get().addMessage(message); + void addConsoleLog(final ConsoleLog message) { + getIt.get().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); diff --git a/lib/logic/get_it/console.dart b/lib/logic/get_it/console.dart deleted file mode 100644 index a523c5e8..00000000 --- a/lib/logic/get_it/console.dart +++ /dev/null @@ -1,17 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:selfprivacy/logic/models/message.dart'; - -class ConsoleModel extends ChangeNotifier { - final List _messages = []; - - List 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); - } - } -} diff --git a/lib/logic/get_it/console_model.dart b/lib/logic/get_it/console_model.dart new file mode 100644 index 00000000..9ef0d67e --- /dev/null +++ b/lib/logic/get_it/console_model.dart @@ -0,0 +1,16 @@ +import 'package:flutter/material.dart'; +import 'package:selfprivacy/logic/models/console_log.dart'; + +class ConsoleModel extends ChangeNotifier { + final List _logs = []; + List 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); + } + } +} diff --git a/lib/logic/models/console_log.dart b/lib/logic/models/console_log.dart new file mode 100644 index 00000000..80fb6240 --- /dev/null +++ b/lib/logic/models/console_log.dart @@ -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? 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? 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? data; + final List? errors; + + @override + String get title => 'GraphQL Response'; + @override + String get content => 'data: $data'; +} diff --git a/lib/logic/models/message.dart b/lib/logic/models/message.dart deleted file mode 100644 index b722d464..00000000 --- a/lib/logic/models/message.dart +++ /dev/null @@ -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? 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 deleted file mode 100644 index e83765e9..00000000 --- a/lib/ui/components/list_tiles/log_list_tile.dart +++ /dev/null @@ -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( - 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 deleted file mode 100644 index 0c60cf81..00000000 --- a/lib/ui/pages/more/console.dart +++ /dev/null @@ -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 createState() => _ConsolePageState(); -} - -class _ConsolePageState extends State { - bool paused = false; - - @override - void initState() { - super.initState(); - - getIt().addListener(update); - } - - @override - void dispose() { - getIt().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 snapshot, - ) { - if (snapshot.hasData) { - final List messages = - getIt.get().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(), - ], - ); - } - }, - ), - ), - ); -} diff --git a/lib/ui/pages/more/console/console_log_item_dialog.dart b/lib/ui/pages/more/console/console_log_item_dialog.dart new file mode 100644 index 00000000..89e7abb2 --- /dev/null +++ b/lib/ui/pages/more/console/console_log_item_dialog.dart @@ -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 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( + 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, + ), + ), + ), + ); +} diff --git a/lib/ui/pages/more/console/console_log_item_widget.dart b/lib/ui/pages/more/console/console_log_item_widget.dart new file mode 100644 index 00000000..f64d1a50 --- /dev/null +++ b/lib/ui/pages/more/console/console_log_item_widget.dart @@ -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( + 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), + ), + ); +} diff --git a/lib/ui/pages/more/console/console_page.dart b/lib/ui/pages/more/console/console_page.dart new file mode 100644 index 00000000..974887d5 --- /dev/null +++ b/lib/ui/pages/more/console/console_page.dart @@ -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 createState() => _ConsolePageState(); +} + +class _ConsolePageState extends State { + /// should freeze logs state to properly read logs + bool paused = false; + late final Future future; + + @override + void initState() { + super.initState(); + + future = getIt.allReady(); + getIt().addListener(update); + } + + @override + void dispose() { + getIt().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 snapshot, + ) { + if (snapshot.hasData) { + final List messages = + getIt.get().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 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(), + ); +} diff --git a/lib/ui/router/router.dart b/lib/ui/router/router.dart index 89c43618..14f09d82 100644 --- a/lib/ui/router/router.dart +++ b/lib/ui/router/router.dart @@ -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 routes = [ AutoRoute(page: OnboardingRoute.page),