diff --git a/README.md b/README.md index f4a4e7a5b846bd8f11c75cb480eead653548bea7..f0a1beeb94f2a29950e64f37eb4fca99f249c648 100644 --- a/README.md +++ b/README.md @@ -37,6 +37,11 @@ First of all, you can contribute translating Äž1nkgo to your language: [https://weblate.duniter.org/settings/g1nkgo/g1nkgo/](https://weblate.duniter.org/settings/g1nkgo/g1nkgo/) +## Docker + +mkdir -p ~/.ginkgo/nginx-conf +mkdir -p ~/.ginkgo/www + ## Dev contributions ### Prerequisites diff --git a/assets/translations/ca.json b/assets/translations/ca.json index b20bf1dd0e2ef5510ca9b0f8315be89a04e62607..220d277bbe11a31237aaf14b4b85d6c9c799f379 100644 --- a/assets/translations/ca.json +++ b/assets/translations/ca.json @@ -61,7 +61,6 @@ "nothing_found": "No s'ha trobat res", "using_nodes": "Usant {nodes} nodes de {type}", "using_nodes_first": "El més rà pid: {node}", - "long_press_to_refresh": "Prem sostingudament per actualitzar", "technical_info_title": "Informació tècnica", "pattern_do_not_match": "Els patrons no coincideixen", "at_least_3": "Com a mÃnim calen 3 punts", diff --git a/assets/translations/en.json b/assets/translations/en.json index 1ff918b203e92b3ba6e677157e5f3dd3bde93705..e42ccebe083003cf586009e9758e5f1de8542b35 100644 --- a/assets/translations/en.json +++ b/assets/translations/en.json @@ -62,7 +62,7 @@ "nothing_found": "Nothing found", "using_nodes": "Using {nodes} nodes of {type}", "using_nodes_first": "Faster: {node}", - "long_press_to_refresh": "Long press to refresh", + "long_press_to_refresh": "Reordering the current list of nodes. Tap an hold to refresh the list.", "technical_info_title": "Technical info", "pattern_do_not_match": "Patterns do not match", "at_least_3": "At least 3 points required", @@ -95,5 +95,6 @@ "transaction_receiving": "Receiving", "transaction_sent": "Sent", "transaction_received": "Received", - "valid_comment": "The comment cannot have accents or commas" + "valid_comment": "The comment cannot have accents or commas", + "search_limitation": "The search term must have at least 3 characters" } diff --git a/assets/translations/es.json b/assets/translations/es.json index 2c00c5a4f0616030b86b3e646d8f0352503c726f..23ba2fd4c044d184acddf19a2a41f9b2815c6a12 100644 --- a/assets/translations/es.json +++ b/assets/translations/es.json @@ -62,7 +62,7 @@ "nothing_found": "No se ha encontrado nada", "using_nodes": "Usando {nodes} nodos de {type}", "using_nodes_first": "Más rápido: {node}", - "long_press_to_refresh": "Mantén pulsado para actualizar", + "long_press_to_refresh": "Reordenando la lista actual de nodos. Manten pulsado para refrescar la lista", "technical_info_title": "Información técnica", "pattern_do_not_match": "Los patrones no coinciden", "at_least_3": "Se requieren al menos 3 puntos", @@ -94,5 +94,6 @@ "transaction_receiving": "Recibiendo", "transaction_sent": "Enviado", "transaction_received": "Recibido", - "valid_comment": "El comentario no puede tener tildes o comas" + "valid_comment": "El comentario no puede tener tildes o comas", + "search_limitation": "La búsqueda debe tener al menos 3 caracteres" } diff --git a/assets/translations/fr.json b/assets/translations/fr.json index f5ad60d0d422513dab887b6cf2d8a65095295adf..9eed1a354dac3c21a143764ddbf446569d946804 100644 --- a/assets/translations/fr.json +++ b/assets/translations/fr.json @@ -56,7 +56,6 @@ "nothing_found": "Rien trouvé", "using_nodes": "Utilisation de {nodes} nÅ“uds de type {type}", "using_nodes_first": "Plus rapide : {node}", - "long_press_to_refresh": "Appuyez longuement pour rafraîchir", "technical_info_title": "Informations techniques", "pattern_do_not_match": "Les modèles ne correspondent pas", "at_least_3": "Au moins 3 points sont requis", diff --git a/lib/data/models/contact.dart b/lib/data/models/contact.dart index a7b09ea540761a0f41b7b5e345034f51bbd2843d..6b51fca9e564f4bb7910ecf5ff94b18f95ee6ba0 100644 --- a/lib/data/models/contact.dart +++ b/lib/data/models/contact.dart @@ -4,6 +4,7 @@ import 'package:copy_with_extension/copy_with_extension.dart'; import 'package:equatable/equatable.dart'; import 'package:json_annotation/json_annotation.dart'; +import '../../ui/ui_helpers.dart'; import 'is_json_serializable.dart'; import 'model_utils.dart'; @@ -23,6 +24,16 @@ class Contact extends Equatable implements IsJsonSerializable<Contact> { factory Contact.fromJson(Map<String, dynamic> json) => _$ContactFromJson(json); + Contact merge(Contact c) { + return Contact( + nick: c.nick ?? nick, + pubKey: c.pubKey, + avatar: c.avatar ?? avatar, + notes: c.notes ?? notes, + name: c.name ?? name, + ); + } + final String? nick; final String pubKey; @JsonKey(fromJson: uIntFromList, toJson: uIntToList) @@ -33,6 +44,8 @@ class Contact extends Equatable implements IsJsonSerializable<Contact> { @override List<Object?> get props => <dynamic>[nick, pubKey, avatar, notes, name]; + bool get hasAvatar => avatar != null; + @override Map<String, dynamic> toJson() => _$ContactToJson(this); @@ -43,4 +56,11 @@ class Contact extends Equatable implements IsJsonSerializable<Contact> { String toString() { return 'Contact $pubKey, hasAvatar: ${avatar != null}, nick: $nick, name: $name'; } + + String get title => name != null && nick != null + ? '$name ($nick)' + : nick ?? name ?? humanizePubKey(pubKey); + + String? get subtitle => + (nick != null || name != null) ? humanizePubKey(pubKey) : null; } diff --git a/lib/data/models/node_list_cubit.dart b/lib/data/models/node_list_cubit.dart index c6e213ac48ca440c2018c6e88948986a869ab133..fddb303b4daf749579f43c17b607c7086e07a52b 100644 --- a/lib/data/models/node_list_cubit.dart +++ b/lib/data/models/node_list_cubit.dart @@ -2,10 +2,40 @@ import 'package:hydrated_bloc/hydrated_bloc.dart'; import 'node.dart'; import 'node_list_state.dart'; +import 'node_type.dart'; class NodeListCubit extends HydratedCubit<NodeListState> { NodeListCubit() : super(NodeListState()); + void shuffle(NodeType type) { + switch (type) { + case NodeType.duniter: + emit(state.copyWith(duniterNodes: shuffleFirstN(state.duniterNodes))); + break; + case NodeType.cesiumPlus: + emit(state.copyWith( + cesiumPlusNodes: shuffleFirstN(state.cesiumPlusNodes))); + break; + case NodeType.gva: + emit(state.copyWith(gvaNodes: shuffleFirstN(state.gvaNodes))); + break; + } + } + + // shuffle fist n nodes + List<Node> shuffleFirstN(List<Node> list, [int n = 5]) { + if (list.length <= n) { + list.shuffle(); + } else { + final List<Node> subList = list.sublist(0, n); + subList.shuffle(); + for (int i = 0; i < n; i++) { + list[i] = subList[i]; + } + } + return list; + } + void setDuniterNodes(List<Node> nodes) { emit(state.copyWith(duniterNodes: nodes)); } diff --git a/lib/data/models/payment_cubit.dart b/lib/data/models/payment_cubit.dart index 6538413d2b940eee8b332ae47b4c6aa894fbcc90..96afa86261141c12624ef49a530c1132eef58a23 100644 --- a/lib/data/models/payment_cubit.dart +++ b/lib/data/models/payment_cubit.dart @@ -1,7 +1,6 @@ -import 'dart:typed_data'; - import 'package:hydrated_bloc/hydrated_bloc.dart'; +import 'contact.dart'; import 'payment_state.dart'; class PaymentCubit extends HydratedCubit<PaymentState> { @@ -20,16 +19,15 @@ class PaymentCubit extends HydratedCubit<PaymentState> { emit(newState); } - void selectUser(String publicKey, String? nick, Uint8List? avatar, - [double? amount]) { - final PaymentState newState = PaymentState( - publicKey: publicKey, nick: nick, avatar: avatar, amount: amount); + void selectUser(Contact contact, [double? amount]) { + final PaymentState newState = + PaymentState(contact: contact, amount: amount); emit(newState); } - void selectKeyAmount(String publicKey, double amount) { + void selectKeyAmount(Contact contact, double amount) { final PaymentState newState = - PaymentState(publicKey: publicKey, amount: amount); + PaymentState(contact: contact, amount: amount); emit(newState); } @@ -45,9 +43,9 @@ class PaymentCubit extends HydratedCubit<PaymentState> { emit(state.copyWith(status: PaymentStatus.sending)); } - void selectKey(String publicKey) { + void selectKey(Contact? contact) { final PaymentState newState = PaymentState( - publicKey: publicKey, amount: state.amount, comment: state.comment); + contact: contact, amount: state.amount, comment: state.comment); emit(newState); } @@ -65,22 +63,14 @@ class PaymentCubit extends HydratedCubit<PaymentState> { void selectAmount(double? amount) { // As copyWith ignores null amounts final PaymentState newState = PaymentState( - publicKey: state.publicKey, - nick: state.nick, - comment: state.comment, - avatar: state.avatar, - amount: amount); + contact: state.contact, comment: state.comment, amount: amount); emit(newState); } void setComment(String comment) { // As copyWith ignores null amounts final PaymentState newState = PaymentState( - publicKey: state.publicKey, - amount: state.amount, - comment: comment, - avatar: state.avatar, - nick: state.nick); + contact: state.contact, amount: state.amount, comment: comment); emit(newState); } } diff --git a/lib/data/models/payment_state.dart b/lib/data/models/payment_state.dart index 581d54dda75838772d944cfc78c6e9dbd67a1b99..6bcb52d182790ec4d98579f4076d1877b3912c3a 100644 --- a/lib/data/models/payment_state.dart +++ b/lib/data/models/payment_state.dart @@ -1,10 +1,8 @@ -import 'dart:typed_data'; - import 'package:equatable/equatable.dart'; import 'package:json_annotation/json_annotation.dart'; import '../../g1/g1_helper.dart'; -import 'model_utils.dart'; +import 'contact.dart'; part 'payment_state.g.dart'; @@ -13,9 +11,7 @@ enum PaymentStatus { notSent, sending, isSent } @JsonSerializable() class PaymentState extends Equatable { const PaymentState({ - required this.publicKey, - this.nick, - this.avatar, + this.contact, this.comment = '', this.amount, this.status = PaymentStatus.notSent, @@ -26,14 +22,11 @@ class PaymentState extends Equatable { bool canBeSent() => status == PaymentStatus.notSent && - validateKey(publicKey) && + (contact != null && validateKey(contact!.pubKey)) && amount != null && amount! > 0; - final String publicKey; - final String? nick; - @JsonKey(fromJson: uIntFromList, toJson: uIntToList) - final Uint8List? avatar; + final Contact? contact; final String comment; final double? amount; final PaymentStatus status; @@ -41,34 +34,26 @@ class PaymentState extends Equatable { Map<String, dynamic> toJson() => _$PaymentStateToJson(this); PaymentState copyWith({ - String? publicKey, - String? nick, - Uint8List? avatar, + Contact? contact, String? comment, double? amount, PaymentStatus? status, }) { return PaymentState( - publicKey: publicKey ?? this.publicKey, - nick: nick ?? this.nick, - avatar: avatar ?? this.avatar, + contact: contact ?? this.contact, comment: comment ?? this.comment, amount: amount ?? this.amount, status: status ?? this.status, ); } - static PaymentState emptyPayment = const PaymentState( - publicKey: '', - nick: '', - ); + static PaymentState emptyPayment = const PaymentState(); @override String toString() { - return '$publicKey ${amount ?? ""}'; + return '$contact.pubKey ${amount ?? ""}'; } @override - List<Object?> get props => - <dynamic>[publicKey, nick, avatar, comment, amount, status]; + List<Object?> get props => <dynamic>[contact, comment, amount, status]; } diff --git a/lib/data/models/payment_state.g.dart b/lib/data/models/payment_state.g.dart index 5bb586a666591a9cdace210b44e9d06ea78c68ea..d935dcf0a9441ad8675ef21164c2c8f865aa39b1 100644 --- a/lib/data/models/payment_state.g.dart +++ b/lib/data/models/payment_state.g.dart @@ -7,9 +7,9 @@ part of 'payment_state.dart'; // ************************************************************************** PaymentState _$PaymentStateFromJson(Map<String, dynamic> json) => PaymentState( - publicKey: json['publicKey'] as String, - nick: json['nick'] as String?, - avatar: uIntFromList(json['avatar']), + contact: json['contact'] == null + ? null + : Contact.fromJson(json['contact'] as Map<String, dynamic>), comment: json['comment'] as String? ?? '', amount: (json['amount'] as num?)?.toDouble(), status: $enumDecodeNullable(_$PaymentStatusEnumMap, json['status']) ?? @@ -18,9 +18,7 @@ PaymentState _$PaymentStateFromJson(Map<String, dynamic> json) => PaymentState( Map<String, dynamic> _$PaymentStateToJson(PaymentState instance) => <String, dynamic>{ - 'publicKey': instance.publicKey, - 'nick': instance.nick, - 'avatar': uIntToList(instance.avatar), + 'contact': instance.contact, 'comment': instance.comment, 'amount': instance.amount, 'status': _$PaymentStatusEnumMap[instance.status]!, diff --git a/lib/g1/api.dart b/lib/g1/api.dart index 640d7276de54c8580055f9f362b9049454283796..f6c2095b2d561976a4a731fb6e83185735879f84 100644 --- a/lib/g1/api.dart +++ b/lib/g1/api.dart @@ -1,5 +1,4 @@ import 'dart:convert'; - // import 'dart:developer' as developer; import 'dart:io'; @@ -23,7 +22,7 @@ import 'g1_helper.dart'; Future<String> getTxHistory(String publicKey) async { final Response response = - await requestWithRetry(NodeType.duniter, '/tx/history/$publicKey'); + await requestWithRetry(NodeType.duniter, '/tx/history/$publicKey'); if (response.statusCode == 200) { return response.body; } else { @@ -44,29 +43,36 @@ Future<Response> getPeers() async { Future<Response> searchCPlusUser(String searchTerm) async { final Response response = await requestCPlusWithRetry( - '/user/profile/_search?q=title:*$searchTerm* OR _id:$searchTerm* OR _id:$searchTerm', + '/user/profile/_search?q=title:$searchTerm OR issuer:$searchTerm', retryWith404: false); return response; } -Future<Contact> getProfile(String pubKey) async { +Future<Contact> getProfile(String pubKey, + [bool onlyCPlusProfile = false]) async { try { final Response cPlusResponse = await requestCPlusWithRetry( '/user/profile/$pubKey', retryWith404: false); final Map<String, dynamic> result = - const JsonDecoder().convert(cPlusResponse.body) as Map<String, dynamic>; + const JsonDecoder().convert(cPlusResponse.body) as Map<String, dynamic>; if (result['found'] == false) { return Contact(pubKey: pubKey); } - - final String? nick = await gvaNick(pubKey); final Map<String, dynamic> profile = - const JsonDecoder().convert(cPlusResponse.body) as Map<String, dynamic>; - + const JsonDecoder().convert(cPlusResponse.body) as Map<String, dynamic>; final Contact c = contactFromResultSearch(profile); - logger('Contact retrieved in search $c'); - return c.copyWith(nick: nick); + if (!onlyCPlusProfile) { + // This penalize the gva rate limit + // final String? nick = await gvaNick(pubKey); + final List<Contact> wotList = await searchWot(pubKey); + if (wotList.isNotEmpty) { + final Contact c = wotList[0]; + c.copyWith(nick: c.nick); + } + } + logger('Contact retrieved in getProfile $c (c+ only $onlyCPlusProfile)'); + return c; } catch (e) { logger('Error in getProfile $e'); return Contact(pubKey: pubKey); @@ -84,7 +90,6 @@ Not found sample: } */ Future<List<Contact>> searchWot(String searchTerm) async { - // USE gva.getUsername final Response response = await requestDuniterWithRetry( '/wot/lookup/$searchTerm', retryWith404: false); @@ -92,7 +97,7 @@ Future<List<Contact>> searchWot(String searchTerm) async { final List<Contact> contacts = <Contact>[]; if (response.statusCode == HttpStatus.ok) { final Map<String, dynamic> data = - json.decode(response.body) as Map<String, dynamic>; + json.decode(response.body) as Map<String, dynamic>; final List<dynamic> results = data['results'] as List<dynamic>; // logger('Returning wot results ${results.length}'); if (results.isNotEmpty) { @@ -106,6 +111,9 @@ Future<List<Contact>> searchWot(String searchTerm) async { } } logger('Returning wot contact ${contacts.length}'); + if (contacts.isNotEmpty) { + logger('First: ${contacts.first}'); + } return contacts; } @@ -116,11 +124,11 @@ Future<Contact> getWot(Contact contact) async { // Will be better to analyze the 404 response (to detect faulty node) if (response.statusCode == HttpStatus.ok) { final Map<String, dynamic> data = - json.decode(response.body) as Map<String, dynamic>; + json.decode(response.body) as Map<String, dynamic>; final List<dynamic> results = data['results'] as List<dynamic>; if (results.isNotEmpty) { final List<dynamic> uids = - (results[0] as Map<String, dynamic>)['uids'] as List<dynamic>; + (results[0] as Map<String, dynamic>)['uids'] as List<dynamic>; if (uids.isNotEmpty) { // ignore: avoid_dynamic_calls return contact.copyWith(nick: uids[0]!['uid'] as String); @@ -133,14 +141,14 @@ Future<Contact> getWot(Contact contact) async { @Deprecated('use getProfile') Future<String> _getDataImageFromKey(String publicKey) async { final Response response = - await requestCPlusWithRetry('/user/profile/$publicKey'); + await requestCPlusWithRetry('/user/profile/$publicKey'); if (response.statusCode == HttpStatus.ok) { final Map<String, dynamic> data = - json.decode(response.body) as Map<String, dynamic>; + json.decode(response.body) as Map<String, dynamic>; final Map<String, dynamic> source = data['_source'] as Map<String, dynamic>; if (source.containsKey('avatar')) { final Map<String, dynamic> avatarData = - source['avatar'] as Map<String, dynamic>; + source['avatar'] as Map<String, dynamic>; if (avatarData.containsKey('_content')) { final String content = avatarData['_content'] as String; return 'data:image/png;base64,$content'; @@ -193,18 +201,16 @@ Future<void> fetchCesiumPlusNodes({bool force = false}) async { NodeManager().updateNodes(type, nodes); } -int nodesWorking(NodeType type) => - NodeManager() - .nodeList(type) - .where((Node n) => n.errors < NodeManager.maxNodeErrors) - .toList() - .length; +int nodesWorking(NodeType type) => NodeManager() + .nodeList(type) + .where((Node n) => n.errors < NodeManager.maxNodeErrors) + .toList() + .length; -List<Node> nodesWorkingList(NodeType type) => - NodeManager() - .nodeList(type) - .where((Node n) => n.errors < NodeManager.maxNodeErrors) - .toList(); +List<Node> nodesWorkingList(NodeType type) => NodeManager() + .nodeList(type) + .where((Node n) => n.errors < NodeManager.maxNodeErrors) + .toList(); Future<List<Node>> _fetchDuniterNodesFromPeers(NodeType type) async { final List<Node> lNodes = <Node>[]; @@ -216,14 +222,14 @@ Future<List<Node>> _fetchDuniterNodesFromPeers(NodeType type) async { final Response response = await getPeers(); if (response.statusCode == 200) { final Map<String, dynamic> peerList = - jsonDecode(response.body) as Map<String, dynamic>; + jsonDecode(response.body) as Map<String, dynamic>; final List<dynamic> peers = (peerList['peers'] as List<dynamic>) .where((dynamic peer) => - (peer as Map<String, dynamic>)['currency'] == 'g1') + (peer as Map<String, dynamic>)['currency'] == 'g1') .where( (dynamic peer) => (peer as Map<String, dynamic>)['version'] == 10) .where((dynamic peer) => - (peer as Map<String, dynamic>)['status'] == 'UP') + (peer as Map<String, dynamic>)['status'] == 'UP') .toList(); // reorder peer list peers.shuffle(); @@ -231,7 +237,7 @@ Future<List<Node>> _fetchDuniterNodesFromPeers(NodeType type) async { final Map<String, dynamic> peer = peerR as Map<String, dynamic>; if (peer['endpoints'] != null) { final List<String> endpoints = - List<String>.from(peer['endpoints'] as List<dynamic>); + List<String>.from(peer['endpoints'] as List<dynamic>); for (int j = 0; j < endpoints.length; j++) { if (endpoints[j].startsWith(apyType)) { final String endpointUnParsed = endpoints[j]; @@ -242,10 +248,9 @@ Future<List<Node>> _fetchDuniterNodesFromPeers(NodeType type) async { try { final Duration latency = await _pingNode(endpoint, type); logger( - 'Evaluating node: $endpoint, latency ${latency - .inMicroseconds}'); + 'Evaluating node: $endpoint, latency ${latency.inMicroseconds}'); final Node node = - Node(url: endpoint, latency: latency.inMicroseconds); + Node(url: endpoint, latency: latency.inMicroseconds); if (fastestNode == null || latency < fastestLatency) { fastestNode = endpoint; fastestLatency = latency; @@ -273,8 +278,7 @@ Future<List<Node>> _fetchDuniterNodesFromPeers(NodeType type) async { } } logger( - 'Fetched ${lNodes.length} ${type - .name} nodes ordered by latency (first: ${lNodes.first.url})'); + 'Fetched ${lNodes.length} ${type.name} nodes ordered by latency (first: ${lNodes.first.url})'); } catch (e, stacktrace) { logger('General error in fetch ${type.name} nodes: $e'); logger(stacktrace); @@ -318,8 +322,7 @@ Future<List<Node>> _fetchNodes(NodeType type) async { } logger( - 'Fetched ${lNodes.length} ${type - .name} nodes ordered by latency (first: ${lNodes.first.url})'); + 'Fetched ${lNodes.length} ${type.name} nodes ordered by latency (first: ${lNodes.first.url})'); } catch (e, stacktrace) { logger('General error in fetch ${type.name}: $e'); logger(stacktrace); @@ -331,19 +334,18 @@ Future<List<Node>> _fetchNodes(NodeType type) async { Future<Duration> _pingNode(String node, NodeType type) async { try { - final Stopwatch stopwatch = Stopwatch() - ..start(); + final Stopwatch stopwatch = Stopwatch()..start(); await http .get(Uri.parse(type == NodeType.duniter - ? '$node/network/peers/self/ping' - : type == NodeType.cesiumPlus - ? - // see: http://g1.data.e-is.pro/network/peering - '$node/network/peering' - : - // gva (just the url) - node)) - // Decrease http timeout during ping + ? '$node/network/peers/self/ping' + : type == NodeType.cesiumPlus + ? + // see: http://g1.data.e-is.pro/network/peering + '$node/network/peering' + : + // gva (just the url) + node)) + // Decrease http timeout during ping .timeout(const Duration(seconds: 10)); stopwatch.stop(); return stopwatch.elapsed; @@ -374,30 +376,28 @@ Future<http.Response> requestGvaWithRetry(String path, return _requestWithRetry(NodeType.gva, path, true, retryWith404); } -Future<http.Response> _requestWithRetry(NodeType type, String path, - bool dontRecord, bool retryWith404) async { - final List<Node> nodes = NodeManager().nodeList(type).where(( - Node node) => node.errors <= NodeManager.maxNodeErrors).toList(); +Future<http.Response> _requestWithRetry( + NodeType type, String path, bool dontRecord, bool retryWith404) async { + final List<Node> nodes = NodeManager() + .nodeList(type) + .where((Node node) => node.errors <= NodeManager.maxNodeErrors) + .toList(); if (nodes.isEmpty) { nodes.addAll(type == NodeType.duniter ? defaultDuniterNodes : type == NodeType.cesiumPlus - ? defaultCesiumPlusNodes - : defaultGvaNodes); + ? defaultCesiumPlusNodes + : defaultGvaNodes); } for (int i = 0; i < nodes.length; i++) { final Node node = nodes[i]; try { final Uri url = Uri.parse('${node.url}$path'); logger('Fetching $url (${type.name})'); - final int startTime = DateTime - .now() - .millisecondsSinceEpoch; + final int startTime = DateTime.now().millisecondsSinceEpoch; final Response response = - await http.get(url).timeout(const Duration(seconds: 10)); - final int endTime = DateTime - .now() - .millisecondsSinceEpoch; + await http.get(url).timeout(const Duration(seconds: 10)); + final int endTime = DateTime.now().millisecondsSinceEpoch; final int newLatency = endTime - startTime; if (!kReleaseMode) { logger('response.statusCode: ${response.statusCode}'); @@ -452,13 +452,14 @@ Future<String> pay( recipient: to, amount: amount, comment: comment ?? '', - cesiumSeed: wallet.seed); + cesiumSeed: wallet.seed, + raiseException: true); logger('GVA replied with "$response"'); return response; } catch (e, stacktrace) { logger(e); logger(stacktrace); - return "Oops! the payment failed. Something didn't work as expected"; + return "Oops! the payment failed. Something didn't work as expected ($e)"; } } return output; @@ -471,8 +472,7 @@ String getGvaNode([bool useProxy = true]) { nodes.shuffle(); // Reference of working proxy 'https://g1demo.comunes.net/proxy/g1v1.p2p.legal/gva/'; final String node = useProxy - ? 'https://g1demo.comunes.net/proxy/${nodes.first.url.replaceFirst( - 'https://', '').replaceFirst('http://', '')}/' + ? 'https://g1demo.comunes.net/proxy/${nodes.first.url.replaceFirst('https://', '').replaceFirst('http://', '')}/' : nodes.first.url; return node; } else { @@ -494,8 +494,8 @@ Future<String?> gvaNick(String pubKey) async { pubKey, (Gva gva) => gva.getUsername(pubKey)); } -Future<T?> gvaFunctionWrapper<T>(String pubKey, - Future<T?> Function(Gva) specificFunction) async { +Future<T?> gvaFunctionWrapper<T>( + String pubKey, Future<T?> Function(Gva) specificFunction) async { final List<Node> nodes = NodeManager() .nodeList(NodeType.gva) .where((Node node) => node.errors <= NodeManager.maxNodeErrors) diff --git a/lib/g1/g1_helper.dart b/lib/g1/g1_helper.dart index 490c4306f10db51004b611aa31f942e019d13784..25a04f439c4498ee6c4ceedb2e1bb058c9759d18 100644 --- a/lib/g1/g1_helper.dart +++ b/lib/g1/g1_helper.dart @@ -6,6 +6,7 @@ import 'package:durt/durt.dart'; import 'package:encrypt/encrypt.dart' as encrypt; import 'package:encrypt/encrypt.dart'; +import '../data/models/contact.dart'; import '../data/models/payment_state.dart'; Random createRandom() { @@ -121,7 +122,7 @@ PaymentState? parseScannedUri(String qr) { if (matchKeyAmount != null) { final String publicKey = matchKeyAmount.group(1)!; final double amount = double.parse(matchKeyAmount.group(2)!); - return PaymentState(publicKey: publicKey, amount: amount); + return PaymentState(contact: Contact(pubKey: publicKey), amount: amount); } // Match no amount @@ -129,12 +130,12 @@ PaymentState? parseScannedUri(String qr) { final RegExpMatch? matchKey = regexKey.firstMatch(qr); if (matchKey != null) { final String publicKey = matchKey.group(1)!; - return PaymentState(publicKey: publicKey); + return PaymentState(contact: Contact(pubKey: publicKey)); } // Match key only if (validateKey(qr)) { - return PaymentState(publicKey: qr); + return PaymentState(contact: Contact(pubKey: qr)); } return null; diff --git a/lib/ui/contacts_cache.dart b/lib/ui/contacts_cache.dart index c7501198d77a3061fd53cceb2330542701cec834..ba833410d07bfc9e1422ea754e4423dcaa10a9b7 100644 --- a/lib/ui/contacts_cache.dart +++ b/lib/ui/contacts_cache.dart @@ -19,33 +19,30 @@ class ContactsCache { static ContactsCache? _instance; final Map<String, List<Completer<Contact>>> _pendingRequests = <String, List<Completer<Contact>>>{}; + static Duration duration = + kReleaseMode ? const Duration(days: 3) : const Duration(hours: 5); Future<Contact> getContact(String pubKey) async { - final String cacheKey = 'contact-$pubKey'; - const Duration duration = - kReleaseMode ? Duration(days: 3) : Duration(minutes: 5); + final String cacheKey = _key(pubKey); + Contact? cachedContact; try { - final String? cachedValue = window.localStorage[cacheKey]; - if (cachedValue != null) { - final Map<String, dynamic> decodedValue = - json.decode(cachedValue) as Map<String, dynamic>; - final DateTime timestamp = - DateTime.parse(decodedValue['timestamp'] as String); - - if (DateTime.now().isBefore(timestamp.add(duration))) { - final Contact contact = - Contact.fromJson(decodedValue['data'] as Map<String, dynamic>); - if (!kReleaseMode) { - // logger('Returning cached contact $contact'); - } - return contact; - } - } + cachedContact = _retrieveContact(pubKey); } catch (e) { logger('Error while retrieving contact from cache: $e, $pubKey'); } + if (cachedContact != null) { + if (!kReleaseMode) { + logger('Returning cached contact $cachedContact'); + } + return cachedContact; + } else { + if (!kReleaseMode) { + logger('Contact $pubKey not cached'); + } + } + if (_pendingRequests.containsKey(pubKey)) { final Completer<Contact> completer = Completer<Contact>(); _pendingRequests[pubKey]!.add(completer); @@ -55,11 +52,11 @@ class ContactsCache { final Completer<Contact> completer = Completer<Contact>(); _pendingRequests[pubKey] = <Completer<Contact>>[completer]; try { - final Contact contact = await getProfile(pubKey); + cachedContact = await getProfile(pubKey); final String encodedValue = json.encode(<String, dynamic>{ 'timestamp': DateTime.now().toIso8601String(), - 'data': contact.toJson(), + 'data': cachedContact.toJson(), }); window.localStorage[cacheKey] = encodedValue; if (!kReleaseMode) { @@ -67,11 +64,11 @@ class ContactsCache { } // Send to listeners for (final Completer<Contact> completer in _pendingRequests[pubKey]!) { - completer.complete(contact); + completer.complete(cachedContact); } _pendingRequests.remove(pubKey); - return contact; + return cachedContact; } catch (e) { // Send error to listeners for (final Completer<Contact> completer in _pendingRequests[pubKey]!) { @@ -82,4 +79,49 @@ class ContactsCache { rethrow; } } + + String _key(String pubKey) => 'contact-$pubKey'; + + void addContact(Contact contact) { + // Get the cached version of the contact, if it exists + Contact? cachedContact = _retrieveContact(contact.pubKey); + + // Merge the new contact with the cached contact + if (cachedContact != null) { + // logger('Merging contact $contact with cached contact $cachedContact'); + cachedContact = cachedContact.merge(contact); + } else { + // logger('Adding contact $contact to cache as is not cached'); + cachedContact = contact; + } + + // Cache the merged contact + final String encodedValue = json.encode(<String, dynamic>{ + 'timestamp': DateTime.now().toIso8601String(), + 'data': cachedContact.toJson(), + }); + window.localStorage[_key(contact.pubKey)] = encodedValue; + // logger('Added contact $cachedContact to cache'); + } + + Contact? _retrieveContact(String pubKey) { + final String? cachedValue = window.localStorage[_key(pubKey)]; + if (cachedValue != null) { + final Map<String, dynamic> decodedValue = + json.decode(cachedValue) as Map<String, dynamic>; + final DateTime timestamp = + DateTime.parse(decodedValue['timestamp'] as String); + final bool before = DateTime.now().isBefore(timestamp.add(duration)); + if (before) { + final Contact contact = + Contact.fromJson(decodedValue['data'] as Map<String, dynamic>); + if (!kReleaseMode) { + logger('Returning cached contact $contact'); + } + return contact; + } + // logger('Cached contact $pubKey is expired'); + } + return null; + } } diff --git a/lib/ui/screens/pay_form.dart b/lib/ui/screens/pay_form.dart index e1f334f54a511a2c24f746abf651ecd6c6dbf5f6..7c1cc19f89aa68a3927a58885f9c25f512748b0e 100644 --- a/lib/ui/screens/pay_form.dart +++ b/lib/ui/screens/pay_form.dart @@ -41,7 +41,7 @@ class _PayFormState extends State<PayForm> { TextFormField( controller: _commentController, onChanged: (String? value) { - final bool? validate = _commentValidate(); + final bool validate = _commentValidate(); if (validate != null && value != null && value.isNotEmpty && @@ -72,10 +72,11 @@ class _PayFormState extends State<PayForm> { : () async { // We disable the number, anyway context.read<PaymentCubit>().sending(); + final String contactPubKey = state.contact!.pubKey; final bool? confirmed = await _confirmSend( context, state.amount!.toString(), - humanizePubKey(state.publicKey)); + humanizePubKey(contactPubKey)); if (!mounted) { return; } @@ -83,7 +84,7 @@ class _PayFormState extends State<PayForm> { context.read<PaymentCubit>().sentFailed(); } else { final String response = await pay( - to: state.publicKey, + to: contactPubKey, comment: state.comment, amount: state.amount!); if (!mounted) { diff --git a/lib/ui/ui_helpers.dart b/lib/ui/ui_helpers.dart index 1e38bbaeb46be8c5c78394d96d0e68a3a824ced3..c167066bc374111b8d7b1c8f4038ae7859727883 100644 --- a/lib/ui/ui_helpers.dart +++ b/lib/ui/ui_helpers.dart @@ -87,14 +87,14 @@ String humanizeContact(String publicAddress, Contact contact) { String humanizePubKey(String address) => '\u{1F511} ${simplifyPubKey(address)}'; String simplifyPubKey(String address) => address.substring(0, 8); - +/* Widget humanizePubKeyAsWidget(String pubKey) => Text( humanizePubKey(pubKey), style: const TextStyle( fontSize: 16.0, ), ); - +*/ Color tileColor(int index, BuildContext context, [bool inverse = false]) { final ColorScheme colorScheme = Theme.of(context).colorScheme; final Color selectedColor = colorScheme.primary.withOpacity(0.1); @@ -133,7 +133,7 @@ String formatKAmount(BuildContext context, double amount) => double parseToDoubleLocalized(String locale, String double) => NumberFormat.decimalPattern(locale).parse(double).toDouble(); -String getAppVersion() => '0.0.9'; +String getAppVersion() => '0.0.10'; String localizeNumber(BuildContext context, double amount) => NumberFormat.decimalPattern(context.locale.toString()).format(amount); @@ -147,13 +147,14 @@ Contact contactFromResultSearch(Map<String, dynamic> record) { avatar: avatarBase64); } +/* Contact contactFromUserProfile(Map<String, dynamic> source) { final Uint8List? avatarBase64 = _getAvatarFromResults(source); return Contact( pubKey: source['issuer'] as String, name: source['title'] as String, avatar: avatarBase64); -} +} */ Uint8List? _getAvatarFromResults(Map<String, dynamic> source) { Uint8List? avatarBase64; @@ -175,3 +176,21 @@ void fetchTransactions(BuildContext context) { final NodeListCubit nodeListCubit = context.read<NodeListCubit>(); transCubit.fetchTransactions(nodeListCubit); } + +ListTile contactToListItem(Contact contact, int index, BuildContext context, + [VoidCallback? onTap, Widget? trailing]) { + final String title = contact.title; + final Widget? subtitle = + contact.subtitle != null ? Text(contact.subtitle!) : null; + return ListTile( + title: Text(title), + subtitle: subtitle, + tileColor: tileColor(index, context), + onTap: onTap, + leading: avatar( + contact.avatar, + bgColor: tileColor(index, context), + color: tileColor(index, context, true), + ), + trailing: trailing); +} diff --git a/lib/ui/widgets/fifth_screen/node_info.dart b/lib/ui/widgets/fifth_screen/node_info.dart index eecba3d81d7a322dce1652b3c265a7780e753a5d..0356c6d6a26f51b3cc4d3c4a9b21769ea0b88924 100644 --- a/lib/ui/widgets/fifth_screen/node_info.dart +++ b/lib/ui/widgets/fifth_screen/node_info.dart @@ -25,11 +25,14 @@ class NodeInfoCard extends StatelessWidget { ? state.cesiumPlusNodes : state.gvaNodes; return GestureDetector( - onTap: () => ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(tr('long_press_to_refresh')), - ), + onTap: () { + context.read<NodeListCubit>().shuffle(type); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(tr('long_press_to_refresh')), ), + ); + }, onLongPress: () { logger('On long press'); if (type == NodeType.duniter) { diff --git a/lib/ui/widgets/first_screen/pay_contact_search_button.dart b/lib/ui/widgets/first_screen/pay_contact_search_button.dart index d546b9342fd1346e249d4f476955b05319a6520a..c6e5162499ebd8b02fde5c40ba65e9e64bf789cc 100644 --- a/lib/ui/widgets/first_screen/pay_contact_search_button.dart +++ b/lib/ui/widgets/first_screen/pay_contact_search_button.dart @@ -19,7 +19,7 @@ class _PayContactSearchButtonState extends State<PayContactSearchButton> { Widget build(BuildContext context) { return BlocBuilder<PaymentCubit, PaymentState>( builder: (BuildContext context, PaymentState state) { - if (state.publicKey.isEmpty) { + if (state.contact == null || state.contact!.pubKey.isEmpty) { return ElevatedButton.icon( onPressed: () { showDialog( diff --git a/lib/ui/widgets/first_screen/pay_contact_search_page.dart b/lib/ui/widgets/first_screen/pay_contact_search_page.dart index 3b4e83876e44c038e7584e07a9d608f83b63c877..ab48736e7b63f95a5e0b6fc7e5b2d614fe56db09 100644 --- a/lib/ui/widgets/first_screen/pay_contact_search_page.dart +++ b/lib/ui/widgets/first_screen/pay_contact_search_page.dart @@ -14,6 +14,7 @@ import '../../../data/models/payment_cubit.dart'; import '../../../data/models/payment_state.dart'; import '../../../g1/api.dart'; import '../../../g1/g1_helper.dart'; +import '../../contacts_cache.dart'; import '../../logger.dart'; import '../../ui_helpers.dart'; import '../custom_error_widget.dart'; @@ -35,18 +36,24 @@ class _PayContactSearchPageState extends State<PayContactSearchPage> { bool _isLoading = false; Future<void> _search() async { + if (_searchTerm.length < 3) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(tr('search_limitation'))), + ); + return; + } + setState(() { _isLoading = true; }); final Response cPlusResponse = await searchCPlusUser(_searchTerm); - if (cPlusResponse.statusCode == 404) { - setState(() { - _results = <Contact>[]; - }); - } else { - _results = await searchWot(_searchTerm); - // FIXME(vjrj) ... no avatars in wot! + + setState(() { + _results = <Contact>[]; + }); + + if (cPlusResponse.statusCode != 404) { setState(() { // Add cplus users final List<dynamic> hits = ((const JsonDecoder() @@ -55,22 +62,52 @@ class _PayContactSearchPageState extends State<PayContactSearchPage> { for (final dynamic hit in hits) { final Contact c = contactFromResultSearch(hit as Map<String, dynamic>); - logger('Contact retrieved in search $c'); - _results.add(c); + logger('Contact retrieved in c+ search $c'); + ContactsCache().addContact(c); + _addIfNotPresent(c); } logger('Found: ${_results.length}'); - _isLoading = false; }); } + + final List<Contact> wotResults = await searchWot(_searchTerm); + // ignore: prefer_foreach + for (final Contact c in wotResults) { + ContactsCache().addContact(c); + _addIfNotPresent(c); + // retrieve extra results with c+ profile + for (final Contact wotC in wotResults) { + final Contact cachedWotProfile = + await ContactsCache().getContact(wotC.pubKey); + if (cachedWotProfile.name == null) { + // Users without c+ profile + final Contact cPlusProfile = + await getProfile(cachedWotProfile.pubKey, true); + ContactsCache().addContact(cPlusProfile); + } + } + } + if (_results.isEmpty && validateKey(_searchTerm)) { logger('$_searchTerm looks like a plain pub key'); setState(() { - _isLoading = true; final Contact contact = Contact(pubKey: _searchTerm); _results.add(contact); - _isLoading = false; }); } + + setState(() { + _isLoading = false; + }); + } + + void _addIfNotPresent(Contact contact) { + if (_results + .where((Contact c) => c.pubKey == contact.pubKey) + .toList() + .isEmpty) { + _results.add(contact); + } } @override @@ -100,21 +137,17 @@ class _PayContactSearchPageState extends State<PayContactSearchPage> { final PaymentState? pay = parseScannedUri(scannedKey); if (pay != null) { logger('Scanned $pay'); - _searchTerm = pay.publicKey; + _searchTerm = pay.contact!.pubKey; await _search(); } logger('QR result length ${_results.length}'); if (_results.length == 1 && pay != null) { final Contact contact = _results[0]; - paymentCubit.selectUser( - contact.pubKey, - contact.nick ?? contact.name, - contact.avatar, - pay.amount); + paymentCubit.selectUser(contact, pay.amount); } else if (pay!.amount != null) { - paymentCubit.selectKeyAmount(pay.publicKey, pay.amount!); + paymentCubit.selectKeyAmount(pay.contact!, pay.amount!); } else { - paymentCubit.selectKey(pay.publicKey); + paymentCubit.selectKey(pay.contact); } if (!mounted) { return; @@ -141,15 +174,11 @@ class _PayContactSearchPageState extends State<PayContactSearchPage> { labelText: tr('search_user'), suffixIcon: IconButton( icon: const Icon(Icons.search), - onPressed: () { - _search(); - }, + onPressed: () => _searchTerm.length < 3 ? null : _search(), ), ), onChanged: (String value) { - setState(() { - _searchTerm = value; - }); + _searchTerm = value; }, onSubmitted: (_) { _search(); @@ -166,7 +195,7 @@ class _PayContactSearchPageState extends State<PayContactSearchPage> { itemBuilder: (BuildContext context, int index) { final Contact contact = _results[index]; return FutureBuilder<Contact>( - future: getWot(contact), + future: ContactsCache().getContact(contact.pubKey), builder: (BuildContext context, AsyncSnapshot<Contact> snapshot) { Widget widget; @@ -190,31 +219,18 @@ class _PayContactSearchPageState extends State<PayContactSearchPage> { } Widget _buildItem(Contact contact, int index, BuildContext context) { - logger('Contact retrieved $contact'); - final String pubKey = contact.pubKey; - final String title = contact.nick ?? contact.name ?? humanizePubKey(pubKey); - final Widget? subtitle = (contact.nick != null || contact.name != null) - ? Text(humanizePubKey(pubKey)) - : null; - final bool hasAvatar = contact.avatar != null; - return ListTile( - title: Text(title), - subtitle: subtitle, - tileColor: tileColor(index, context), - onTap: () { - context.read<PaymentCubit>().selectUser(pubKey, - contact.nick ?? contact.name, hasAvatar ? contact.avatar : null); + return contactToListItem( + contact, + index, + context, + () { + context.read<PaymentCubit>().selectUser(contact); Navigator.pop(context); }, - leading: avatar( - contact.avatar, - bgColor: tileColor(index, context), - color: tileColor(index, context, true), - ), - trailing: BlocBuilder<ContactsCubit, ContactsState>( + BlocBuilder<ContactsCubit, ContactsState>( builder: (BuildContext context, ContactsState state) { final ContactsCubit contactsCubit = context.read<ContactsCubit>(); - final bool isFavorite = contactsCubit.isContact(pubKey); + final bool isFavorite = contactsCubit.isContact(contact.pubKey); return IconButton( icon: Icon( isFavorite ? Icons.favorite : Icons.favorite_border, @@ -226,7 +242,7 @@ class _PayContactSearchPageState extends State<PayContactSearchPage> { contactsCubit.addContact(contact); } else { contactsCubit.removeContact(Contact( - pubKey: pubKey, + pubKey: contact.pubKey, )); } }); diff --git a/lib/ui/widgets/first_screen/recipient_widget.dart b/lib/ui/widgets/first_screen/recipient_widget.dart index 9da9d361a60069506fa4ebfe96d196b78e93418b..58a5ab854e91d9af560d1b620c3cbb0331510edc 100644 --- a/lib/ui/widgets/first_screen/recipient_widget.dart +++ b/lib/ui/widgets/first_screen/recipient_widget.dart @@ -17,24 +17,29 @@ class RecipientWidget extends StatelessWidget { child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: <Widget>[ - avatar(state.avatar), + avatar(state.contact!.avatar), const SizedBox(width: 16.0), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: <Widget>[ - if (state.nick != null) + if (state.contact!.title != null) Text( - state.nick!, + state.contact!.title, style: const TextStyle( fontSize: 20.0, fontWeight: FontWeight.bold, ), ), - Padding( - padding: EdgeInsets.fromLTRB( - 0, state.nick != null ? 0 : 12, 0, 0), - child: humanizePubKeyAsWidget(state.publicKey)), + if (state.contact!.subtitle != null) + Padding( + padding: const EdgeInsets.fromLTRB(0, 2, 0, 0), + child: Text( + state.contact!.subtitle!, + style: const TextStyle( + fontSize: 16.0, + ), + )), ], ), ), diff --git a/lib/ui/widgets/fourth_screen/transaction_page.dart b/lib/ui/widgets/fourth_screen/transaction_page.dart index 1086ec4c0f6e222b4e0bd09f228a2752d812c77c..9a6067d2439650da31d7109d34c93f721d1c3201 100644 --- a/lib/ui/widgets/fourth_screen/transaction_page.dart +++ b/lib/ui/widgets/fourth_screen/transaction_page.dart @@ -57,7 +57,7 @@ class _TransactionsAndBalanceWidgetState } final GlobalKey<RefreshIndicatorState> _refreshIndicatorKey = - GlobalKey<RefreshIndicatorState>(); + GlobalKey<RefreshIndicatorState>(); @override Widget build(BuildContext context) { @@ -68,10 +68,7 @@ class _TransactionsAndBalanceWidgetState final double balance = transBalanceState.balance; return BackdropScaffold( appBar: BackdropAppBar( - backgroundColor: Theme - .of(context) - .colorScheme - .inversePrimary, + backgroundColor: Theme.of(context).colorScheme.inversePrimary, title: Text(tr('balance')), actions: <Widget>[ IconButton( @@ -81,60 +78,51 @@ class _TransactionsAndBalanceWidgetState // _refreshTransactions(); }, ), - const BackdropToggleButton( - // The default - // icon: AnimatedIcons.close_menu, + // BackdropToggleButton() + IconButton( + icon: const Icon(Icons.savings), + onPressed: () => Backdrop.of(context).animationController, ) ], ), backLayer: Center( child: Container( - decoration: BoxDecoration( - color: Theme - .of(context) - .colorScheme - .inversePrimary, - border: Border.all( - color: Theme - .of(context) - .colorScheme - .inversePrimary, - width: 3), - /* borderRadius: const BorderRadius.only( + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.inversePrimary, + border: Border.all( + color: Theme.of(context).colorScheme.inversePrimary, + width: 3), + /* borderRadius: const BorderRadius.only( topLeft: Radius.circular(8), topRight: Radius.circular(8), ), */ + ), + child: Scrollbar( + child: ListView( + // controller: scrollController, + children: <Widget>[ + Padding( + padding: const EdgeInsets.symmetric(vertical: 10.0), + child: Center( + child: Text( + formatKAmount(context, balance), + style: TextStyle( + fontSize: 36.0, + color: + balance == 0 ? Colors.lightBlue : Colors.lightBlue, + fontWeight: FontWeight.bold), + )), ), - child: Scrollbar( - child: ListView( - // controller: scrollController, - children: <Widget>[ - Padding( - padding: const EdgeInsets.symmetric(vertical: 10.0), - child: Center( - child: Text( - formatKAmount(context, balance), - style: TextStyle( - fontSize: 36.0, - color: - balance == 0 ? Colors.lightBlue : Colors - .lightBlue, - fontWeight: FontWeight.bold), - )), - ), - if (!kReleaseMode) TransactionChart() - /*BalanceChart( + if (!kReleaseMode) TransactionChart() + /*BalanceChart( transactions: .transactions),*/ - ], - )), - )), + ], + )), + )), subHeader: BackdropSubHeader( title: Text(tr('transactions')), divider: Divider( - color: Theme - .of(context) - .colorScheme - .surfaceVariant, + color: Theme.of(context).colorScheme.surfaceVariant, height: 0, ), ), @@ -154,19 +142,16 @@ class _TransactionsAndBalanceWidgetState child: Header(text: 'transactions'))), */ Expanded( child: Padding( - padding: const EdgeInsets.fromLTRB(0, 0, 0, 50), - child: transactions.isEmpty - ? Padding( + padding: const EdgeInsets.fromLTRB(0, 0, 0, 50), + child: transactions.isEmpty + ? Padding( padding: const EdgeInsets.symmetric(horizontal: 20), child: Center(child: Text(tr('no_transactions')))) - : RefreshIndicator( + : RefreshIndicator( key: _refreshIndicatorKey, color: Colors.white, backgroundColor: - Theme - .of(context) - .colorScheme - .primary, + Theme.of(context).colorScheme.primary, strokeWidth: 4.0, onRefresh: () async { return _refreshTransactions(); @@ -255,7 +240,7 @@ class _TransactionsAndBalanceWidgetState */ }, )), - )) + )) ]), )); }); diff --git a/lib/ui/widgets/third_screen/contacts_page.dart b/lib/ui/widgets/third_screen/contacts_page.dart index be8cf150fee2ba325886e30f546a8102bacbf723..36c2ffae125e5d2fad2c504598834cfe84318b9b 100644 --- a/lib/ui/widgets/third_screen/contacts_page.dart +++ b/lib/ui/widgets/third_screen/contacts_page.dart @@ -38,7 +38,6 @@ class _ContactsPageState extends State<ContactsPage> { @override Widget build(BuildContext context) { - // final ContactsCubit cubit = context.read<ContactsCubit>(); return BlocBuilder<ContactsCubit, ContactsState>( builder: (BuildContext context, ContactsState state) { return Padding( @@ -127,22 +126,7 @@ class _ContactsPageState extends State<ContactsPage> { ), ], ), - child: ListTile( - title: contact.nick != null - ? Text(contact.nick!) - : contact.name != null - ? Text(contact.name!) - : humanizePubKeyAsWidget(contact.pubKey), - subtitle: contact.nick != null || contact.name != null - ? humanizePubKeyAsWidget(contact.pubKey) - : null, - leading: avatar( - contact.avatar, - bgColor: tileColor(index, context), - color: tileColor(index, context, true), - ), - tileColor: tileColor(index, context), - ), + child: contactToListItem(contact, index, context), ); }, )), @@ -153,9 +137,7 @@ class _ContactsPageState extends State<ContactsPage> { } void onSent(BuildContext c, Contact contact) { - c - .read<PaymentCubit>() - .selectUser(contact.pubKey, contact.nick, contact.avatar); + c.read<PaymentCubit>().selectUser(contact); c.read<BottomNavCubit>().updateIndex(0); } } diff --git a/pubspec.yaml b/pubspec.yaml index 1c1b9fd0347605e76d478ddff2b222c209cda094..801ebc12464e474bae146f8d4a80aaca85a2bac1 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -15,7 +15,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev # In iOS, build-name is used as CFBundleShortVersionString while build-number used as CFBundleVersion. # Read more about iOS versioning at # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html -version: 0.0.9 +version: 0.0.10 environment: sdk: ">=2.17.1 <3.0.0" diff --git a/test/contacts_test.dart b/test/contacts_test.dart index c7d72a6e8ffd7659526bd851eb97670cc277d289..aad6424d4d710d28a9e862d3af86d55eaf00e275 100644 --- a/test/contacts_test.dart +++ b/test/contacts_test.dart @@ -35,4 +35,49 @@ void main() { expect(contactFromWithAvatar.avatar!.toList(), equals(<int>[68, 174, 66, 96, 130])); }); + + group('Contact merge test', () { + test('Merge properties', () { + const Contact contact1 = Contact(nick: 'nick1', pubKey: 'pubKey'); + + final Contact contact2 = Contact( + pubKey: 'pubKey', + avatar: Uint8List.fromList(<int>[1, 2, 3]), + name: 'name2', + notes: 'notes2', + ); + + final List<Contact> merged = <Contact>[ + contact1.merge(contact2), + contact2.merge(contact1), + ]; + for (final Contact mergedContact in merged) { + expect(mergedContact.nick, 'nick1'); + expect(mergedContact.pubKey, 'pubKey'); + expect(mergedContact.avatar, Uint8List.fromList(<int>[1, 2, 3])); + expect(mergedContact.notes, 'notes2'); + expect(mergedContact.name, 'name2'); + } + }); + + test('Merge with empty contact', () { + final Contact contact1 = Contact( + nick: 'nick1', + pubKey: 'pubKey1', + avatar: Uint8List.fromList(<int>[1, 2, 3]), + notes: 'notes1', + name: 'name1', + ); + + const Contact contact2 = Contact(pubKey: 'pubKey1'); + + final Contact mergedContact = contact1.merge(contact2); + + expect(mergedContact.nick, 'nick1'); + expect(mergedContact.pubKey, 'pubKey1'); + expect(mergedContact.avatar, Uint8List.fromList(<int>[1, 2, 3])); + expect(mergedContact.notes, 'notes1'); + expect(mergedContact.name, 'name1'); + }); + }); } diff --git a/test/keys_test.dart b/test/keys_test.dart index 6936a4046f63874695292b5a69532baa37bba3c6..1962d5bf6ad0eb33133ab9f81166de08a7c5974f 100644 --- a/test/keys_test.dart +++ b/test/keys_test.dart @@ -54,16 +54,16 @@ void main() { final String uriA = getQrUri(publicKey, '10'); final PaymentState? payA = parseScannedUri(uriA); expect(payA!.amount, equals(10)); - expect(payA.publicKey, equals(publicKey)); + expect(payA.contact!.pubKey, equals(publicKey)); final String uriB = getQrUri(publicKey); final PaymentState? payB = parseScannedUri(uriB); expect(payB!.amount, equals(null)); - expect(payB.publicKey, equals(publicKey)); + expect(payB.contact!.pubKey, equals(publicKey)); final PaymentState? payC = parseScannedUri(publicKey); expect(payC!.amount, equals(null)); - expect(payC.publicKey, equals(publicKey)); + expect(payC.contact!.pubKey, equals(publicKey)); }); test('encrypt/decrypt of keys', () {