Compare commits

...

6 Commits

Author SHA1 Message Date
Aliaksei Tratseuski 00545c34b4 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
2024-04-20 13:53:55 +04:00
Aliaksei Tratseuski 22fbbb051e feat: infobox changed to use wrap.
shown as 1 line when content fits, wraps into column when not.
2024-04-20 13:44:14 +04:00
Aliaksei Tratseuski 4f200ae757 fix: typos in field names 2024-04-20 13:37:04 +04:00
Aliaksei Tratseuski 06513b6fa6 fix: typo in provider constructors.
Changed `isAuthotized` to `isAuthorized`.
2024-04-20 03:19:26 +04:00
Aliaksei Tratseuski 32769c9d9f fix: selectable new device key.
In devices menu, when key for the connection of new device is created, one can select key text for copy.
2024-04-20 03:16:38 +04:00
Aliaksei Tratseuski 551305b55a fix: disable automatic scrollbar addition for desktop builds.
If view needs a scrollbar, it should be added on all platforms. Framework, by default, adds them only on desktop, so if we add scrollbars in some places (our main builds are still smartphones), on desktop we will get double scrollbars.
2024-04-20 03:11:08 +04:00
22 changed files with 615 additions and 557 deletions

View File

@ -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;

View File

@ -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;
}
}

View File

@ -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);

View File

@ -49,9 +49,10 @@ abstract class ServerInstallationState extends Equatable {
bool get isPrimaryUserFilled => rootUser != null;
bool get isServerCreated => serverDetails != null;
bool get isFullyInitilized => _fulfilementList.every((final el) => el!);
bool get isFullyInitialized =>
_fulfillmentList.every((final el) => el == true);
ServerSetupProgress get progress => ServerSetupProgress
.values[_fulfilementList.where((final el) => el!).length];
.values[_fulfillmentList.where((final el) => el!).length];
int get porgressBar {
if (progress.index < 6) {
@ -63,7 +64,7 @@ abstract class ServerInstallationState extends Equatable {
}
}
List<bool?> get _fulfilementList {
List<bool?> get _fulfillmentList {
final List<bool> res = [
isServerProviderApiKeyFilled,
isServerTypeFilled,

View File

@ -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);
}
}
}

View 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);
}
}
}

View 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';
}

View File

@ -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;
}

View File

@ -27,9 +27,9 @@ class ApiAdapter {
class CloudflareDnsProvider extends DnsProvider {
CloudflareDnsProvider() : _adapter = ApiAdapter();
CloudflareDnsProvider.load(
final bool isAuthotized,
final bool isAuthorized,
) : _adapter = ApiAdapter(
isWithToken: isAuthotized,
isWithToken: isAuthorized,
);
ApiAdapter _adapter;

View File

@ -22,9 +22,9 @@ class ApiAdapter {
class DesecDnsProvider extends DnsProvider {
DesecDnsProvider() : _adapter = ApiAdapter();
DesecDnsProvider.load(
final bool isAuthotized,
final bool isAuthorized,
) : _adapter = ApiAdapter(
isWithToken: isAuthotized,
isWithToken: isAuthorized,
);
ApiAdapter _adapter;

View File

@ -22,9 +22,9 @@ class ApiAdapter {
class DigitalOceanDnsProvider extends DnsProvider {
DigitalOceanDnsProvider() : _adapter = ApiAdapter();
DigitalOceanDnsProvider.load(
final bool isAuthotized,
final bool isAuthorized,
) : _adapter = ApiAdapter(
isWithToken: isAuthotized,
isWithToken: isAuthorized,
);
ApiAdapter _adapter;

View File

@ -38,9 +38,9 @@ class DigitalOceanServerProvider extends ServerProvider {
DigitalOceanServerProvider() : _adapter = ApiAdapter();
DigitalOceanServerProvider.load(
final String? location,
final bool isAuthotized,
final bool isAuthorized,
) : _adapter = ApiAdapter(
isWithToken: isAuthotized,
isWithToken: isAuthorized,
region: location,
);

View File

@ -38,9 +38,9 @@ class HetznerServerProvider extends ServerProvider {
HetznerServerProvider() : _adapter = ApiAdapter();
HetznerServerProvider.load(
final String? location,
final bool isAuthotized,
final bool isAuthorized,
) : _adapter = ApiAdapter(
isWithToken: isAuthotized,
isWithToken: isAuthorized,
region: location,
);

View File

@ -91,6 +91,9 @@ class SelfprivacyApp extends StatelessWidget {
: appSettings.isDarkModeOn
? ThemeMode.dark
: ThemeMode.light,
scrollBehavior: const MaterialScrollBehavior().copyWith(
scrollbars: false,
),
builder: (final BuildContext context, final Widget? widget) {
Widget error =
const Center(child: Text('...rendering error...'));

View File

@ -11,15 +11,16 @@ class InfoBox extends StatelessWidget {
final bool isWarning;
@override
Widget build(final BuildContext context) => Column(
crossAxisAlignment: CrossAxisAlignment.start,
Widget build(final BuildContext context) => Wrap(
spacing: 8.0,
runSpacing: 16.0,
crossAxisAlignment: WrapCrossAlignment.center,
children: [
Icon(
isWarning ? Icons.warning_amber_outlined : Icons.info_outline,
size: 24,
color: Theme.of(context).colorScheme.onBackground,
),
const SizedBox(height: 16),
Text(
text,
style: Theme.of(context).textTheme.bodyMedium!.copyWith(

View File

@ -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),
],
),
),
);
}

View File

@ -39,6 +39,7 @@ class NewDeviceScreen extends StatelessWidget {
class _KeyDisplay extends StatelessWidget {
const _KeyDisplay({required this.newDeviceKey});
final String newDeviceKey;
@override
@ -47,7 +48,7 @@ class _KeyDisplay extends StatelessWidget {
children: [
const Divider(),
const SizedBox(height: 16),
Text(
SelectableText(
newDeviceKey,
style: Theme.of(context).textTheme.bodyLarge!.copyWith(
fontSize: 24,

View File

@ -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(),
],
);
}
},
),
),
);
}

View 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,
),
),
),
);
}

View 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),
),
);
}

View 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(),
);
}

View File

@ -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),