diff --git a/lib/app_block_observer.dart b/lib/app_block_observer.dart deleted file mode 100644 index be917c0715dc39ad7c7ec13c155d5858280669a3..0000000000000000000000000000000000000000 --- a/lib/app_block_observer.dart +++ /dev/null @@ -1,35 +0,0 @@ -import 'package:bloc/bloc.dart'; - -import 'main.dart'; - -class AppBlocObserver extends BlocObserver { - @override - void onCreate(BlocBase<dynamic> bloc) { - super.onCreate(bloc); - logger('onCreate -- ${bloc.runtimeType}'); - } - - @override - void onEvent(Bloc<dynamic, dynamic> bloc, Object? event) { - super.onEvent(bloc, event); - logger('onEvent -- $event'); - } - - @override - void onTransition(Bloc<dynamic, dynamic> bloc, Transition transition) { - super.onTransition(bloc, transition); - logger('onTransition -- $transition'); - } - - @override - void onError(BlocBase<dynamic> bloc, Object error, StackTrace stackTrace) { - super.onError(bloc, error, stackTrace); - logger('onError -- $error'); - } - - @override - void onClose(BlocBase<dynamic> bloc) { - super.onClose(bloc); - logger('onClose -- ${bloc.runtimeType}'); - } -} diff --git a/lib/data/models/isJsonSerializable.dart b/lib/data/models/isJsonSerializable.dart new file mode 100644 index 0000000000000000000000000000000000000000..1490d7c31cab2c33acc2527dbfb6af34bae4e90f --- /dev/null +++ b/lib/data/models/isJsonSerializable.dart @@ -0,0 +1,5 @@ +abstract class IsJsonSerializable<T> { + T fromJson(Map<String, dynamic> json); + + Map<String, dynamic> toJson(); +} diff --git a/lib/g1/api.dart b/lib/g1/api.dart index ffcb6f55daf7a8552bebe8586a4d23e733e99a1f..be7e5e47f4fa85a70629dae18950c2beeba0f34c 100644 --- a/lib/g1/api.dart +++ b/lib/g1/api.dart @@ -1,19 +1,23 @@ import 'dart:convert'; import 'dart:io'; -import 'dart:typed_data'; +import 'package:flutter/foundation.dart'; import 'package:http/http.dart' as http; import 'package:http/http.dart'; -import 'node_bloc.dart'; +import '../main.dart'; +import 'g1_helper.dart'; +import 'node.dart'; +import 'node_list_cubit.dart'; // Tx history // https://g1.duniter.org/tx/history/FadJvhddHL7qbRd3WcRPrWEJJwABQa3oZvmCBhotc7Kg // https://g1.duniter.org/tx/history/6DrGg8cftpkgffv4Y4Lse9HSjgc8coEQor3yvMPHAnVH -Future<String> getTxHistory(String publicKey) async { +Future<String> getTxHistory( + NodeListCubit nodeListCubit, String publicKey) async { final Response response = - await NodeBloc().requestWithRetry('/tx/history/$publicKey'); + await requestWithRetry(nodeListCubit, '/tx/history/$publicKey'); if (response.statusCode == 200) { return response.body; } else { @@ -21,8 +25,9 @@ Future<String> getTxHistory(String publicKey) async { } } -Future<Response> getPeers() async { - final Response response = await NodeBloc().requestWithRetry('/network/peers'); +Future<Response> getPeers(NodeListCubit nodeListCubit) async { + final Response response = + await requestWithRetry(nodeListCubit, '/network/peers', dontRecord: true); if (response.statusCode == 200) { return response; } else { @@ -30,9 +35,10 @@ Future<Response> getPeers() async { } } -Future<Response> searchUser(String searchTerm) async { +Future<Response> searchUser( + NodeListCubit nodeListCubit, String searchTerm) async { final Response response = - await NodeBloc().requestWithRetry('/wot/lookup/$searchTerm'); + await requestWithRetry(nodeListCubit, '/wot/lookup/$searchTerm'); return response; } @@ -49,7 +55,8 @@ Not found sample: } */ -Future<String> getDataImageFromKey(String publicKey) async { +Future<String> getDataImageFromKey( + NodeListCubit nodeListCubit, String publicKey) async { // FIXME (vjrj) use node manager and retry... final String url = 'https://g1.data.le-sou.org/user/profile/$publicKey'; final Response response = await http.get(Uri.parse(url)); @@ -74,7 +81,123 @@ Uint8List imageFromBase64String(String base64String) { base64Decode(base64String.substring(base64String.indexOf(',') + 1))); } -Future<Uint8List> getAvatar(String pubKey) async { - final String dataImage = await getDataImageFromKey(pubKey); +Future<Uint8List> getAvatar(NodeListCubit nodeListCubit, String pubKey) async { + final String dataImage = await getDataImageFromKey(nodeListCubit, pubKey); return imageFromBase64String(dataImage); } + +Future<void> fetchDuniterNodes(NodeListCubit cubit) async { + final List<Node> nodes = await fetchNodesFromApi(cubit); + cubit.setDuniterNodes(nodes); +} + +Future<List<Node>> fetchNodesFromApi(NodeListCubit cubit) async { + final List<Node> lNodes = <Node>[]; + // To compare with something... + String fastestNode = 'https://g1.duniter.org'; + late Duration fastestLatency = const Duration(minutes: 1); + try { + final Response response = await getPeers(cubit); + if (response.statusCode == 200) { + final Map<String, dynamic> peerList = + 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') + .where( + (dynamic peer) => (peer as Map<String, dynamic>)['version'] == 10) + .where((dynamic peer) => + (peer as Map<String, dynamic>)['status'] == 'UP') + .toList(); + for (final dynamic peerR in peers) { + 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>); + for (int j = 0; j < endpoints.length; j++) { + if (endpoints[j].startsWith('BMAS')) { + final String endpointUnParsed = endpoints[j]; + final String? endpoint = parseHost(endpointUnParsed); + if (endpoint != null) { + final Duration latency = await _pingNode(endpoint); + if (fastestNode == null || latency < fastestLatency) { + fastestNode = endpoint; + fastestLatency = latency; + if (!kReleaseMode) { + logger('Node bloc: Current faster node $fastestNode'); + } + } + final Node node = + Node(url: endpoint, latency: latency.inMicroseconds); + cubit.insertDuniterNode(node); + lNodes.insert(0, node); + } + } + } + } + } + } + logger('Node bloc: Loaded ${lNodes.length} duniter nodes'); + } catch (e) { + logger('Error: $e'); + rethrow; + } + lNodes.sort((Node a, Node b) => a.latency.compareTo(b.latency)); + logger('First node in list ${lNodes.first.url}'); + return lNodes; +} + +Future<Duration> _pingNode(String node) async { + try { + final Stopwatch stopwatch = Stopwatch()..start(); + await http.get(Uri.parse('$node/network/peers/self/ping')); + stopwatch.stop(); + return stopwatch.elapsed; + } catch (e) { + // Handle exception when node is unavailable etc + logger('Node $node does not respond to ping $e'); + return const Duration(days: 2); + } +} + +Future<http.Response> requestWithRetry(NodeListCubit cubit, String path, + {bool dontRecord = false}) async { + return _requestWithRetry(cubit, cubit.duniterNodes, path, dontRecord); +} + +Future<http.Response> requestCPlusWithRetry( + NodeListCubit cubit, String path) async { + return _requestWithRetry(cubit, cubit.cesiumPlusNodes, path, true); +} + +Future<http.Response> _requestWithRetry( + NodeListCubit cubit, List<Node> nodes, String path, bool dontRecord) async { + for (int i = 0; i < nodes.length; i++) { + final Node node = nodes[i]; + if (node.errors >= 3) { + // Too much errors skip + continue; + } + final Uri url = Uri.parse('${node.url}$path'); + logger('Trying $url'); + try { + final int startTime = DateTime.now().millisecondsSinceEpoch; + final Response response = await http.get(url); + final int endTime = DateTime.now().millisecondsSinceEpoch; + final int newLatency = endTime - startTime; + if (response.statusCode == 200) { + if (!dontRecord) { + cubit.updateDuniterNode(node.copyWith(latency: newLatency)); + } + return response; + } + } catch (e) { + if (!dontRecord) { + cubit.updateDuniterNode(node.copyWith(errors: node.errors + 1)); + } + continue; + } + } + throw Exception( + 'Cannot make the request to any of the ${nodes.length} nodes'); +} diff --git a/lib/g1/node.dart b/lib/g1/node.dart index bbfc409710fdfb708b9deddebd3779f1a61c720e..edd93402babc26c1749c9881e577b63b6131c7c1 100644 --- a/lib/g1/node.dart +++ b/lib/g1/node.dart @@ -1,17 +1,20 @@ import 'package:equatable/equatable.dart'; import 'package:json_annotation/json_annotation.dart'; +import '../data/models/isJsonSerializable.dart'; + part 'node.g.dart'; @JsonSerializable() -class Node { - Node({ +class Node extends Equatable implements IsJsonSerializable<Node> { + const Node({ required this.url, this.latency = 99999, this.errors = 0, }); factory Node.fromJson(Map<String, dynamic> json) => _$NodeFromJson(json); + final String url; final int latency; final int errors; @@ -34,130 +37,17 @@ class Node { } @override - bool operator ==(Object other) => - identical(this, other) || - other is Node && - runtimeType == other.runtimeType && - url == other.url && - latency == other.latency && - errors == other.errors; - - @override - int get hashCode => url.hashCode ^ latency.hashCode ^ errors.hashCode; -} - -abstract class NodeEvent extends Equatable { - const NodeEvent(); - - @override - List<Object?> get props => <dynamic>[]; -} - -class AddNode extends NodeEvent { - const AddNode({required this.node}); - - final Node node; - - @override - List<Object?> get props => [node]; -} - -class InsertNode extends NodeEvent { - const InsertNode({required this.node}); - - final Node node; + Map<String, dynamic> toJson() => _$NodeToJson(this); @override - List<Object?> get props => <dynamic>[node]; -} - -class AddCPlusNode extends NodeEvent { - const AddCPlusNode({required this.node}); - - final Node node; + Node fromJson(Map<String, dynamic> json) => Node.fromJson(json); @override - List<Object?> get props => <dynamic>[node]; -} - -extension NodeExtensions on Node { - Map<String, dynamic> toJson() { - return <String, dynamic>{ - 'url': url, - 'latency': latency, - 'errors': errors, - }; - } - - static Node fromJson(Map<String, dynamic> json) { - return Node( - url: json['url'] as String, - latency: json['latency'] as int, - errors: json['errors'] as int, - ); - } -} - -enum NodeStatus { loading, loaded } + List<Object?> get props => <dynamic>[url, latency, errors]; -class NodeState extends Equatable { - const NodeState( - {required this.nodes, required this.cPlusNodes, required this.status}); - - final List<Node> nodes; - final List<Node> cPlusNodes; - final NodeStatus status; - - NodeState copyWith( - {List<Node>? nodes, List<Node>? cPlusNodes, NodeStatus? status}) { - return NodeState( - nodes: nodes ?? this.nodes, - cPlusNodes: cPlusNodes ?? this.cPlusNodes, - status: status ?? this.status, - ); - } - - @override - List<Object> get props => [nodes, cPlusNodes, status]; -} - -class LoadNodes extends NodeEvent { - const LoadNodes(this.nodes); - - final List<Node> nodes; - - @override - List<Object> get props => <Object>[nodes]; - - @override - String toString() => 'LoadNodes { nodes: $nodes }'; -} - -class SetNodes extends NodeEvent { - const SetNodes(this.nodes); - - final List<Node> nodes; - - @override - List<Object> get props => <Object>[nodes]; - - @override - String toString() => 'AddNodes { nodes: $nodes }'; -} - -class AddCPlusNodes extends NodeEvent { - const AddCPlusNodes(this.nodes); - - final List<Node> nodes; - - @override - List<Object> get props => [nodes]; - - @override - String toString() => 'AddNodes { nodes: $nodes }'; } -final List<Node> defaultDuniterNodes = <Node>[ +const List<Node> defaultDuniterNodes = <Node>[ Node(url: 'https://g1.duniter.fr'), Node(url: 'https://g1.le-sou.org'), Node(url: 'https://g1.cgeek.fr'), @@ -167,7 +57,7 @@ final List<Node> defaultDuniterNodes = <Node>[ Node(url: 'https://g1.cgeek.fr') ]; -final List<Node> defaultCesiumPlusNodes = <Node>[ +const List<Node> defaultCesiumPlusNodes = <Node>[ Node(url: 'https://g1.data.e-is.pro'), Node(url: 'https://g1.data.presler.fr'), Node(url: 'https://g1.data.le-sou.org'), diff --git a/lib/g1/node_bloc.dart b/lib/g1/node_bloc.dart deleted file mode 100644 index a855fae68f7474064fe8ecc6d06c91dac7922c53..0000000000000000000000000000000000000000 --- a/lib/g1/node_bloc.dart +++ /dev/null @@ -1,203 +0,0 @@ -import 'dart:async'; -import 'dart:convert'; - -import 'package:flutter/foundation.dart'; -import 'package:http/http.dart' as http; -import 'package:http/http.dart'; -import 'package:hydrated_bloc/hydrated_bloc.dart'; - -import './node.dart'; -import '../main.dart'; -import 'api.dart'; -import 'g1_helper.dart'; - -class NodeBloc extends HydratedBloc<NodeEvent, NodeState> { - factory NodeBloc() { - return _singleton; - } - - NodeBloc._internal() - : super(NodeState( - nodes: defaultDuniterNodes, - cPlusNodes: defaultCesiumPlusNodes, - status: NodeStatus.loading)) { - // Init NodeBloc here - on<LoadNodes>((LoadNodes event, Emitter<NodeState> emit) async { - final List<Node> nodes = await fetchNodesFromApi(); - emit(NodeState( - nodes: nodes, - cPlusNodes: defaultCesiumPlusNodes, - status: NodeStatus.loaded)); - }); - on<AddNode>((AddNode event, Emitter<NodeState> emit) async {}); - on<InsertNode>((InsertNode event, Emitter<NodeState> emit) async {}); - on<SetNodes>((SetNodes event, Emitter<NodeState> emit) async {}); - on<AddCPlusNode>((AddCPlusNode event, Emitter<NodeState> emit) async {}); - on<AddCPlusNodes>((AddCPlusNodes event, Emitter<NodeState> emit) async {}); - } - - static final NodeBloc _singleton = NodeBloc._internal(); - - List<Node> get nodeList => state.nodes; - - List<Node> get cPlusNodeList => state.cPlusNodes; - - Stream<NodeState> mapEventToState(NodeEvent event) async* { - if (event is LoadNodes) { - final List<Node> nodes = List<Node>.of(state.nodes)..addAll(event.nodes); - yield state.copyWith(nodes: nodes); - } else if (event is SetNodes) { - // final List<Node> nodes = List<Node>.of(state.nodes)..addAll(event.nodes); - yield state.copyWith(nodes: event.nodes); - } else if (event is AddNode) { - final List<Node> nodes = List<Node>.of(state.nodes)..add(event.node); - yield state.copyWith(nodes: nodes); - } else if (event is InsertNode) { - final List<Node> nodes = List<Node>.of(state.nodes) - ..insert(0, event.node); - yield state.copyWith(nodes: nodes); - } else if (event is AddCPlusNodes) { - final List<Node> nodes = List<Node>.of(state.cPlusNodes) - ..addAll(event.nodes); - yield state.copyWith(cPlusNodes: nodes); - } else if (event is AddCPlusNode) { - final List<Node> nodes = List<Node>.of(state.cPlusNodes)..add(event.node); - yield state.copyWith(cPlusNodes: nodes); - } - } - - @override - NodeState? fromJson(Map<String, dynamic> json) { - final List<Node> nodes = (json['nodes'] as List<Node>) - .map((dynamic nodeJson) => - Node.fromJson(nodeJson as Map<String, dynamic>)) - .toList(); - final List<Node> cPlusNodes = (json['cPlusNodes'] as List<Node>) - .map((dynamic nodeJson) => - Node.fromJson(nodeJson as Map<String, dynamic>)) - .toList(); - logger( - 'Loaded with ${nodes.length} duniter nodes and ${cPlusNodes.length} c+ nodes'); - return NodeState( - nodes: nodes, cPlusNodes: cPlusNodes, status: NodeStatus.loaded); - } - - @override - Map<String, dynamic>? toJson(NodeState state) { - return <String, dynamic>{ - 'nodes': state.nodes.map((Node node) => node.toJson()).toList(), - }; - } - - Future<void> loadNodes() async { - final List<Node> nodes = await fetchNodesFromApi(); - add(SetNodes(nodes)); - } - - Future<List<Node>> fetchNodesFromApi() async { - final List<Node> nodes = <Node>[]; - // To compare with somthing... - String fastestNode = 'https://g1.duniter.org'; - late Duration fastestLatency = const Duration(minutes: 1); - try { - final Response response = await getPeers(); - if (response.statusCode == 200) { - final Map<String, dynamic> peerList = - 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') - .where((dynamic peer) => - (peer as Map<String, dynamic>)['version'] == 10) - .where((dynamic peer) => - (peer as Map<String, dynamic>)['status'] == 'UP') - .toList(); - for (final dynamic peerR in peers) { - 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>); - for (int j = 0; j < endpoints.length; j++) { - if (endpoints[j].startsWith('BMAS')) { - final String endpointUnParsed = endpoints[j]; - final String? endpoint = parseHost(endpointUnParsed); - if (endpoint != null) { - final Duration latency = await _pingNode(endpoint); - if (fastestNode == null || latency < fastestLatency) { - fastestNode = endpoint; - fastestLatency = latency; - if (!kReleaseMode) { - logger('Node bloc: Current faster node $fastestNode'); - } - } - final Node node = - Node(url: endpoint, latency: latency.inSeconds); - add(InsertNode(node: node)); - nodes.insert(0, node); - } - } - } - } - } - } - logger('Node bloc: Loaded ${nodes.length} duniter nodes'); - } catch (e) { - logger('Error: $e'); - rethrow; - } - nodes.sort((Node a, Node b) => a.latency.compareTo(b.latency)); - logger('First node in list ${nodes.first.url}'); - return nodes; - } - - Future<Duration> _pingNode(String node) async { - try { - final Stopwatch stopwatch = Stopwatch()..start(); - await http.get(Uri.parse('$node/network/peers/self/ping')); - stopwatch.stop(); - return stopwatch.elapsed; - } catch (e) { - // Handle exception when node is unavailable etc - logger('Node $node does not respond to ping $e'); - return const Duration(days: 20); - } - } - - Future<http.Response> requestWithRetry(String path) async { - return _requestWithRetry(nodeList, path); - } - - Future<http.Response> requestCPlusWithRetry(String path) async { - return _requestWithRetry(cPlusNodeList, path); - } - - Future<http.Response> _requestWithRetry(List<Node> nodes, String path) async { - for (int i = 0; i < nodes.length; i++) { - final Node node = nodes[i]; - if (node.errors >= 3) { - // Too much errors skip - continue; - } - final Uri url = Uri.parse('${node.url}$path'); - logger('Trying $url'); - try { - final int startTime = DateTime.now().millisecondsSinceEpoch; - final Response response = await http.get(url); - final int endTime = DateTime.now().millisecondsSinceEpoch; - final int newLatency = endTime - startTime; - if (response.statusCode == 200) { - final Node newNode = node.copyWith(latency: newLatency, errors: 0); - nodes[i] = newNode; - return response; - } - } catch (e) { - final int newErrors = node.errors + 1; - final Node newNode = node.copyWith(errors: newErrors); - nodes[i] = newNode; - continue; - } - } - throw Exception( - 'Cannot make the request to any of the ${nodes.length} nodes'); - } -} diff --git a/lib/g1/node_list_cubit.dart b/lib/g1/node_list_cubit.dart new file mode 100644 index 0000000000000000000000000000000000000000..00a5cd2f0dd5267615348d4ab007c0a693b22635 --- /dev/null +++ b/lib/g1/node_list_cubit.dart @@ -0,0 +1,52 @@ +import 'package:hydrated_bloc/hydrated_bloc.dart'; + +import 'node.dart'; +import 'node_list_state.dart'; + +class NodeListCubit extends HydratedCubit<NodeListState> { + NodeListCubit() : super(const NodeListState()); + + void addDuniterNode(Node node) { + emit(state.copyWith(duniterNodes: <Node>[...state.duniterNodes, node])); + } + + void updateDuniterNode(Node updatedNode) { + final List<Node> updatedDuniterNodes = state.duniterNodes.map((Node n) { + return n.url == updatedNode.url ? updatedNode : n; + }).toList(); + emit(state.copyWith(duniterNodes: updatedDuniterNodes)); + } + + void setDuniterNodes(List<Node> nodes) { + emit(state.copyWith(duniterNodes: nodes)); + } + + void insertDuniterNode(Node node) { + emit(state.copyWith(duniterNodes: <Node>[node, ...state.duniterNodes])); + } + + void cleanDuniterErrorStats() { + emit(state.copyWith( + duniterNodes: duniterNodes + .map((Node node) => node.copyWith(errors: 0)) + .toList())); + } + + void addCesiumPlusNode(Node node) { + emit(state + .copyWith(cesiumPlusNodes: <Node>[...state.cesiumPlusNodes, node])); + } + + List<Node> get duniterNodes => state.duniterNodes; + + List<Node> get cesiumPlusNodes => state.cesiumPlusNodes; + + @override + NodeListState? fromJson(Map<String, dynamic> json) => + NodeListState.fromJson(json); + + @override + Map<String, dynamic>? toJson(NodeListState state) { + return state.toJson(); + } +} diff --git a/lib/g1/node_list_state.dart b/lib/g1/node_list_state.dart new file mode 100644 index 0000000000000000000000000000000000000000..f30d53e06b8db0927c6d2ea013b43035adc41497 --- /dev/null +++ b/lib/g1/node_list_state.dart @@ -0,0 +1,33 @@ +import 'package:equatable/equatable.dart'; +import 'package:flutter/material.dart'; +import 'package:json_annotation/json_annotation.dart'; + +import 'node.dart'; + +part 'node_list_state.g.dart'; + +@immutable +@JsonSerializable() +class NodeListState extends Equatable { + const NodeListState( + {this.duniterNodes = defaultDuniterNodes, + this.cesiumPlusNodes = defaultCesiumPlusNodes}); + + factory NodeListState.fromJson(Map<String, dynamic> json) => + _$NodeListStateFromJson(json); + + final List<Node> duniterNodes; + final List<Node> cesiumPlusNodes; + + NodeListState copyWith( + {List<Node>? duniterNodes, List<Node>? cesiumPlusNodes}) { + return NodeListState( + duniterNodes: duniterNodes ?? this.duniterNodes, + cesiumPlusNodes: cesiumPlusNodes ?? this.cesiumPlusNodes); + } + + @override + List<Object?> get props => <Object>[duniterNodes, cesiumPlusNodes]; + + Map<String, dynamic> toJson() => _$NodeListStateToJson(this); +} diff --git a/lib/g1/node_list_state.g.dart b/lib/g1/node_list_state.g.dart new file mode 100644 index 0000000000000000000000000000000000000000..880b76cd2fa2b9b2f1ab6f7ba1d191f215355b48 --- /dev/null +++ b/lib/g1/node_list_state.g.dart @@ -0,0 +1,25 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'node_list_state.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +NodeListState _$NodeListStateFromJson(Map<String, dynamic> json) => + NodeListState( + duniterNodes: (json['duniterNodes'] as List<dynamic>?) + ?.map((e) => Node.fromJson(e as Map<String, dynamic>)) + .toList() ?? + defaultDuniterNodes, + cesiumPlusNodes: (json['cesiumPlusNodes'] as List<dynamic>?) + ?.map((e) => Node.fromJson(e as Map<String, dynamic>)) + .toList() ?? + defaultCesiumPlusNodes, + ); + +Map<String, dynamic> _$NodeListStateToJson(NodeListState instance) => + <String, dynamic>{ + 'duniterNodes': instance.duniterNodes, + 'cesiumPlusNodes': instance.cesiumPlusNodes, + }; diff --git a/lib/main.dart b/lib/main.dart index 04559e69afbcf2bbfbd61f7a8996a07669499fb2..173ef22dcade5d02802ad7d23de22971a1bf8441 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -15,10 +15,11 @@ import 'package:introduction_screen/introduction_screen.dart'; import 'package:path_provider/path_provider.dart'; import 'package:responsive_framework/responsive_wrapper.dart'; -import 'app_block_observer.dart'; +import 'app_bloc_observer.dart'; import 'config/theme.dart'; import 'data/models/payment_cubit.dart'; -import 'g1/node_bloc.dart'; +import 'g1/api.dart'; +import 'g1/node_list_cubit.dart'; import 'shared_prefs.dart'; import 'ui/screens/skeleton_screen.dart'; @@ -70,23 +71,11 @@ void main() async { HydratedBloc.storage = await HydratedStorage.build(storageDirectory: tmpDir); } - final NodeBloc nodeBloc = NodeBloc(); - if (nodeBloc.nodeList.length < 10) { - // Load nodes from /network/peers - nodeBloc.loadNodes(); - } else { - // Try to start with the persisted + // Reset hive during developing + if (!kReleaseMode) { + await HydratedBloc.storage.clear(); } - logger( - 'Starting with ${nodeBloc.nodeList.length} duniter nodes and ${nodeBloc.cPlusNodeList.length} c+ nodes'); - - final Cron cron = Cron(); - cron.schedule(Schedule.parse('*/45 * * * *'), () async { - // Every 45m check for faster node (maybe it something costly in terms of - // bandwidth - nodeBloc.loadNodes(); - }); runApp( EasyLocalization( @@ -179,10 +168,11 @@ class _MyAppState extends State<MyApp> { @override Widget build(BuildContext context) { return MultiBlocProvider( - providers: <BlocProvider>[ + providers: <BlocProvider<dynamic>>[ BlocProvider<PaymentCubit>( - create: (BuildContext context) => PaymentCubit(), - ), + create: (BuildContext context) => PaymentCubit()), + BlocProvider<NodeListCubit>( + create: (BuildContext context) => NodeListCubit()), // Add other BlocProviders here if needed ], child: ConnectivityAppWrapper( @@ -205,6 +195,29 @@ class _MyAppState extends State<MyApp> { child: _skipIntro ? const SkeletonScreen() : const AppIntro(), ), builder: (BuildContext buildContext, Widget? widget) { + final NodeListCubit nodeCubit = + BlocProvider.of<NodeListCubit>(buildContext); + final int nDuniterNodes = nodeCubit.duniterNodes.length; + final int nCesiumPlusNodes = nodeCubit.cesiumPlusNodes.length; + if (nDuniterNodes < 10) { + // Load nodes from /network/peers + fetchDuniterNodes(nodeCubit); + } else { + // Try to start with the persisted + } + logger( + 'Starting with $nDuniterNodes duniter nodes and $nCesiumPlusNodes c+ nodes'); + + final Cron cron = Cron(); + cron.schedule(Schedule.parse('*/45 * * * *'), () async { + // Every 45m check for faster node (maybe it something costly in terms of + // bandwidth + // nodeCubit.fetchDuniterNodes(); + }); + cron.schedule(Schedule.parse('*/90 * * * *'), () async { + // nodeCubit.cleanDuniterErrorStats(); + }); + return ResponsiveWrapper.builder( ConnectivityWidgetWrapper( message: tr('offline'), diff --git a/lib/ui/screens/fifth_screen.dart b/lib/ui/screens/fifth_screen.dart index 4a65bb9e94ef809df62d046c3faa51bf7247033f..653ed1067ae51ff2d6b06c2f491a48f257e622e1 100644 --- a/lib/ui/screens/fifth_screen.dart +++ b/lib/ui/screens/fifth_screen.dart @@ -1,7 +1,9 @@ import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; import '../../g1/export_import.dart'; -import '../../g1/node_bloc.dart'; +import '../../g1/node_list_cubit.dart'; +import '../../g1/node_list_state.dart'; import '../ui_helpers.dart'; import '../widgets/fifth_screen/grid_item.dart'; import '../widgets/fifth_screen/info_card.dart'; @@ -14,56 +16,64 @@ class FifthScreen extends StatelessWidget { @override Widget build(BuildContext context) { - // FIXME(vjrj) this is not reactive - final String fasterNode = NodeBloc().nodeList.first.url; - return Material( - color: Theme.of(context).colorScheme.background, - child: ListView( - padding: const EdgeInsets.symmetric(horizontal: 16), - physics: const BouncingScrollPhysics(), - children: <Widget>[ - const Header(text: 'bottom_nav_fifth'), - InfoCard( - title: 'connected-to', subtitle: fasterNode, icon: Icons.hub), - LinkCard( - title: 'code_card_title', - icon: Icons.code_rounded, - url: Uri.parse('https://git.duniter.org/vjrj/ginkgo')), - const TextDivider(text: 'key_tools_title'), - GridView.count( - physics: const NeverScrollableScrollPhysics(), - crossAxisCount: 2, - childAspectRatio: 2 / 1.15, - crossAxisSpacing: 8, - mainAxisSpacing: 8, - shrinkWrap: true, - padding: EdgeInsets.zero, - children: <GridItem>[ - GridItem( - title: 'export-key', - icon: Icons.download, - onTap: () { - showDialog( - context: context, - builder: (BuildContext context) { - return const ExportImportPage(); - }, - ); - }), - GridItem( - title: 'import-key', - icon: Icons.upload, - onTap: () { - const ExportImportPage(); - }), - GridItem( - title: 'copy-your-key', - icon: Icons.copy, - onTap: () => copyPublicKeyToClipboard(context), - ) - ]), - const SizedBox(height: 36), - ]), - ); + return BlocBuilder<NodeListCubit, NodeListState>( + builder: (BuildContext context, NodeListState state) { + return Material( + color: Theme.of(context).colorScheme.background, + child: ListView( + padding: const EdgeInsets.symmetric(horizontal: 16), + physics: const BouncingScrollPhysics(), + children: <Widget>[ + const Header(text: 'bottom_nav_fifth'), + InfoCard( + title: 'connected-to', + subtitle: context + .read<NodeListCubit>() + .duniterNodes + .first + .url + .replaceFirst(':443', ''), + icon: Icons.hub), + LinkCard( + title: 'code_card_title', + icon: Icons.code_rounded, + url: Uri.parse('https://git.duniter.org/vjrj/ginkgo')), + const TextDivider(text: 'key_tools_title'), + GridView.count( + physics: const NeverScrollableScrollPhysics(), + crossAxisCount: 2, + childAspectRatio: 2 / 1.15, + crossAxisSpacing: 8, + mainAxisSpacing: 8, + shrinkWrap: true, + padding: EdgeInsets.zero, + children: <GridItem>[ + GridItem( + title: 'export-key', + icon: Icons.download, + onTap: () { + showDialog( + context: context, + builder: (BuildContext context) { + return const ExportImportPage(); + }, + ); + }), + GridItem( + title: 'import-key', + icon: Icons.upload, + onTap: () { + const ExportImportPage(); + }), + GridItem( + title: 'copy-your-key', + icon: Icons.copy, + onTap: () => copyPublicKeyToClipboard(context), + ) + ]), + const SizedBox(height: 36), + ]), + ); + }); } } diff --git a/lib/ui/screens/first_screen.dart b/lib/ui/screens/first_screen.dart index 5b2a14ce69ff3d6d5730f75b20e4ead77b0fd220..6eee502ca7d8e3e9b941ab9c48eac8b3087bbd69 100644 --- a/lib/ui/screens/first_screen.dart +++ b/lib/ui/screens/first_screen.dart @@ -14,6 +14,8 @@ class FirstScreen extends StatefulWidget { } class _FirstScreenState extends State<FirstScreen> { + final ScrollController _controller = ScrollController(); + @override Widget build(BuildContext context) { WidgetsBinding.instance.addPostFrameCallback((_) async { @@ -21,26 +23,29 @@ class _FirstScreenState extends State<FirstScreen> { .showSnackBar(SnackBar(content: Text(tr('demo-desc')))); }); return Material( - color: Theme.of(context).colorScheme.background, - child: ListView( - padding: const EdgeInsets.symmetric(horizontal: 16), - physics: const BouncingScrollPhysics(), - children: <Widget>[ - const Header(text: 'credit_card_title'), - CreditCard(), - const SizedBox(height: 8), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 24), - child: Divider( - color: - Theme.of(context).colorScheme.onBackground.withOpacity(.4), + color: Theme.of(context).colorScheme.background, + child: ListView( + padding: const EdgeInsets.symmetric(horizontal: 16), + physics: const AlwaysScrollableScrollPhysics(), + controller: _controller, + shrinkWrap: true, + children: <Widget>[ + const Header(text: 'credit_card_title'), + CreditCard(), + const SizedBox(height: 8), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 24), + child: Divider( + color: Theme.of(context) + .colorScheme + .onBackground + .withOpacity(.4), + ), ), - ), - const SizedBox(height: 10), - const PayContactSearchWidget(), - const SizedBox(height: 10), - const PayForm(), - ]), - ); + const SizedBox(height: 10), + const PayContactSearchWidget(), + const SizedBox(height: 10), + const PayForm() + ])); } } diff --git a/lib/ui/screens/skeleton_screen.dart b/lib/ui/screens/skeleton_screen.dart index ad0c5f72f5a85474794d2d41c10c1dc3c9913008..c078a0c75e96d1cf2a076990a658bedf70c1fdd6 100644 --- a/lib/ui/screens/skeleton_screen.dart +++ b/lib/ui/screens/skeleton_screen.dart @@ -27,6 +27,7 @@ class SkeletonScreen extends StatelessWidget { create: (BuildContext context) => BottomNavCubit(), child: Scaffold( extendBodyBehindAppBar: true, + resizeToAvoidBottomInset: true, appBar: const AppBarGone(), /// When switching between tabs this will fade the old diff --git a/lib/ui/ui_helpers.dart b/lib/ui/ui_helpers.dart index 98882cb737e53f6516182f7c734d85cae1790428..503ef55e55e0cc77dbba9dc674875ca140937032 100644 --- a/lib/ui/ui_helpers.dart +++ b/lib/ui/ui_helpers.dart @@ -49,3 +49,13 @@ Widget avatar(bool hasAvatar, Uint8List? rawAvatar, : CircularIcon( iconData: Icons.person, backgroundColor: color, iconColor: bgColor); } + +String humanizeFromToPubKey(String publicAddress, String address) { + if (address == publicAddress) { + return tr('your_wallet'); + } else { + return humanizePubKey(address); + } +} + +String humanizePubKey(String address) => '\u{1F511}${address.substring(0, 8)}'; diff --git a/lib/ui/widgets/first_screen/pay_contact_search_dialog.dart b/lib/ui/widgets/first_screen/pay_contact_search_dialog.dart index 851de9db9383bce76568d3c96312fc8d0128be17..59b6d51a9347d8a4ed2cac5c571ba22633818388 100644 --- a/lib/ui/widgets/first_screen/pay_contact_search_dialog.dart +++ b/lib/ui/widgets/first_screen/pay_contact_search_dialog.dart @@ -9,6 +9,8 @@ import 'package:simple_barcode_scanner/simple_barcode_scanner.dart'; import '../../../data/models/payment_cubit.dart'; import '../../../g1/api.dart'; +import '../../../g1/node_list_cubit.dart'; +import '../../../g1/node_list_state.dart'; import '../../ui_helpers.dart'; import '../loading_box.dart'; @@ -26,16 +28,18 @@ class _SearchDialogState extends State<SearchDialog> { List<dynamic> _results = <dynamic>[]; bool _isLoading = false; - Future<void> _search() async { + Future<void> _search(NodeListCubit cubit) async { setState(() { _isLoading = true; }); - final Response response = await searchUser(_searchTerm); + final Response response = await searchUser(cubit, _searchTerm); setState(() { _results = (const JsonDecoder().convert(response.body) as Map<String, dynamic>)['results'] as List<dynamic>; // debugPrint(_results.toString()); + // FIXME (vjrj) search in the blockchain and if it's a key, just + // put the key as a result _isLoading = false; }); } @@ -43,123 +47,128 @@ class _SearchDialogState extends State<SearchDialog> { @override Widget build(BuildContext context) { bool isFavorite = false; - return Scaffold( - appBar: AppBar( - title: Text(tr('search_user_title')), - backgroundColor: Theme.of(context).colorScheme.primary, - foregroundColor: Colors.white, - actions: <Widget>[ - IconButton( - icon: const Icon(Icons.qr_code_scanner), - onPressed: () async { - final String? publicKey = await Navigator.push( - context, - MaterialPageRoute<String>( - builder: (BuildContext context) => - const SimpleBarcodeScannerPage(), - )); - setState(() { - if (publicKey is String) { - /* context - .read<PaymentCubit>() - .selectUser(publicKey, nick, avatar); */ - // result = res; - } - }); - }), - IconButton( - icon: const Icon(Icons.close), - onPressed: () => Navigator.pop(context), - ) - ], - ), - body: Padding( - padding: const EdgeInsets.all(16.0), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: <Widget>[ - TextField( - controller: _searchController, - decoration: InputDecoration( - filled: true, - fillColor: Colors.white, - labelText: tr('search_user'), - suffixIcon: IconButton( - icon: const Icon(Icons.search), - onPressed: () { - _search(); - }, + return BlocBuilder<NodeListCubit, NodeListState>( + builder: (BuildContext context, NodeListState state) { + final NodeListCubit nodeListCubit = context.read<NodeListCubit>(); + return Scaffold( + appBar: AppBar( + title: Text(tr('search_user_title')), + backgroundColor: Theme.of(context).colorScheme.primary, + foregroundColor: Colors.white, + actions: <Widget>[ + IconButton( + icon: const Icon(Icons.qr_code_scanner), + onPressed: () async { + final String? publicKey = await Navigator.push( + context, + MaterialPageRoute<String>( + builder: (BuildContext context) => + const SimpleBarcodeScannerPage(), + )); + setState(() { + if (publicKey is String) { + _searchController.text = publicKey; + _search(nodeListCubit); + } + }); + }), + IconButton( + icon: const Icon(Icons.close), + onPressed: () => Navigator.pop(context), + ) + ], + ), + body: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: <Widget>[ + TextField( + controller: _searchController, + decoration: InputDecoration( + filled: true, + fillColor: Colors.white, + labelText: tr('search_user'), + suffixIcon: IconButton( + icon: const Icon(Icons.search), + onPressed: () { + _search(nodeListCubit); + }, + ), ), + onChanged: (String value) { + setState(() { + _searchTerm = value; + }); + }, + onSubmitted: (_) { + _search(nodeListCubit); + }, ), - onChanged: (String value) { - setState(() { - _searchTerm = value; - }); - }, - onSubmitted: (_) { - _search(); - }, - ), - if (_isLoading) - const LoadingBox() - else - Expanded( - child: ListView.builder( - itemCount: _results.length, - itemBuilder: (BuildContext context, int index) { - final String uid = - (((_results[index] as Map<String, dynamic>)['uids'] - as List<dynamic>)[0] - as Map<String, dynamic>)['uid'] as String; - final String pubkey = (_results[index] - as Map<String, dynamic>)['pubkey'] as String; - return FutureBuilder<Uint8List>( - future: getAvatar((_results[index] - as Map<String, dynamic>)['pubkey'] as String), - builder: (BuildContext context, - AsyncSnapshot<Uint8List> snapshot) { - return ListTile( - title: Text(uid), - tileColor: index.isEven - ? Colors.grey[200] - : Colors.white, - onTap: () { - context - .read<PaymentCubit>() - .selectUser(pubkey, uid, snapshot.data!); - Navigator.pop(context, _results[index]); - }, - leading: avatar( - snapshot.hasData, - snapshot.data, - bgColor: index.isEven - ? defAvatarColor - : defAvatarBgColor, - color: index.isEven - ? defAvatarBgColor - : defAvatarColor, - ), - trailing: IconButton( - icon: Icon( - isFavorite - ? Icons.favorite - : Icons.favorite_border, - color: - isFavorite ? Colors.red.shade400 : null, - ), - onPressed: () { - setState(() { - isFavorite = !isFavorite; - }); + if (_isLoading) + const LoadingBox() + else + Expanded( + child: ListView.builder( + itemCount: _results.length, + itemBuilder: (BuildContext context, int index) { + final String uid = + (((_results[index] as Map<String, dynamic>)['uids'] + as List<dynamic>)[0] + as Map<String, dynamic>)['uid'] as String; + final String pubkey = (_results[index] + as Map<String, dynamic>)['pubkey'] as String; + return FutureBuilder<Uint8List>( + future: getAvatar( + nodeListCubit, + (_results[index] + as Map<String, dynamic>)['pubkey'] + as String), + builder: (BuildContext context, + AsyncSnapshot<Uint8List> snapshot) { + return ListTile( + title: Text(uid), + tileColor: index.isEven + ? Colors.grey[200] + : Colors.white, + onTap: () { + context + .read<PaymentCubit>() + .selectUser(pubkey, uid, snapshot.data!); + Navigator.pop(context, _results[index]); }, - ), - ); - }); - }), - ) - ], + leading: avatar( + snapshot.hasData, + snapshot.data, + bgColor: index.isEven + ? defAvatarColor + : defAvatarBgColor, + color: index.isEven + ? defAvatarBgColor + : defAvatarColor, + ), + trailing: IconButton( + icon: Icon( + isFavorite + ? Icons.favorite + : Icons.favorite_border, + color: + isFavorite ? Colors.red.shade400 : null, + ), + onPressed: () { + setState(() { + isFavorite = !isFavorite; + }); + }, + ), + ); + }); + }), + ) + ], + ), ), - ), - ); + ); + }); } } diff --git a/lib/ui/widgets/first_screen/recipient_widget.dart b/lib/ui/widgets/first_screen/recipient_widget.dart index fc0fb1d598c8d9fe8ae7e77abe5fc1e2541e87ee..be706da5dbcc0b0c43c1150cd143521bd202fa40 100644 --- a/lib/ui/widgets/first_screen/recipient_widget.dart +++ b/lib/ui/widgets/first_screen/recipient_widget.dart @@ -31,7 +31,7 @@ class RecipientWidget extends StatelessWidget { ), ), Text( - state.publicKey, + humanizePubKey(state.publicKey), 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 84c93f563e85299780de080a29c27a48e3ac1602..940fb8db6cd88ffd0720e5e8b85765d6eabae443 100644 --- a/lib/ui/widgets/fourth_screen/transaction_page.dart +++ b/lib/ui/widgets/fourth_screen/transaction_page.dart @@ -1,10 +1,14 @@ import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; import '../../../g1/api.dart'; +import '../../../g1/node_list_cubit.dart'; +import '../../../g1/node_list_state.dart'; import '../../../g1/transaction.dart'; import '../../../g1/transaction_parser.dart'; import '../../../shared_prefs.dart'; +import '../../ui_helpers.dart'; import '../header.dart'; import '../loading_box.dart'; @@ -37,7 +41,7 @@ class _TransactionsAndBalanceWidgetState super.dispose(); } - Future<TransactionsAndBalance> _loadTransactions() async { + Future<TransactionsAndBalance> _loadTransactions(NodeListCubit cubit) async { // carga de datos asÃncrona // ... // disabled, as we have to change the nodes @@ -49,8 +53,8 @@ class _TransactionsAndBalanceWidgetState _balanceAmount = currentBal; })); */ - final String txData = - await getTxHistory('6DrGg8cftpkgffv4Y4Lse9HSjgc8coEQor3yvMPHAnVH'); + final String txData = await getTxHistory( + cubit, '6DrGg8cftpkgffv4Y4Lse9HSjgc8coEQor3yvMPHAnVH'); final TransactionsAndBalance result = transactionParser(txData); /* .then((String txData) { final TransactionsAndBalance result = transactionParser(txData); @@ -67,102 +71,100 @@ class _TransactionsAndBalanceWidgetState Widget build(BuildContext context) { String pubKey = SharedPreferencesHelper().getPubKey(); pubKey = '6DrGg8cftpkgffv4Y4Lse9HSjgc8coEQor3yvMPHAnVH'; - return FutureBuilder<TransactionsAndBalance>( - future: _loadTransactions(), - builder: (BuildContext context, - AsyncSnapshot<TransactionsAndBalance> results) { - if (results.hasData) { - return Stack(children: <Widget>[ - const Header(text: 'transactions'), - ListView.builder( - shrinkWrap: true, - physics: const BouncingScrollPhysics(), - controller: _transactionListController, - itemCount: results.data!.transactions.length, - itemBuilder: (BuildContext context, int index) { - return ListTile( - title: Text( - tr('transaction_from_to', namedArgs: <String, String>{ - 'from': humanizeAddress( - pubKey, results.data!.transactions[index].from), - 'to': humanizeAddress( - pubKey, results.data!.transactions[index].to) - })), - subtitle: - results.data!.transactions[index].comment.isNotEmpty - ? Text( - results.data!.transactions[index].comment, - style: const TextStyle( - fontStyle: FontStyle.italic, - ), - ) - : null, - tileColor: index.isEven ? Colors.grey[200] : Colors.white, - trailing: Text( - '${results.data!.transactions[index].amount < 0 ? "" : "+"}${(results.data!.transactions[index].amount / 100).toStringAsFixed(2)} Äž1', - style: TextStyle( - color: results.data!.transactions[index].amount < 0 - ? Colors.red - : Colors.blue)), - ); - }, - ), - DraggableScrollableSheet( - initialChildSize: 0.1, - minChildSize: 0.1, - maxChildSize: 0.9, - builder: (BuildContext context, - ScrollController scrollController) => - Container( - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.inversePrimary, - border: Border.all( + return BlocBuilder<NodeListCubit, NodeListState>( + builder: (BuildContext context, NodeListState state) => FutureBuilder< + TransactionsAndBalance>( + future: _loadTransactions(context.read<NodeListCubit>()), + builder: (BuildContext context, + AsyncSnapshot<TransactionsAndBalance> results) { + if (results.hasData) { + return Stack(children: <Widget>[ + const Header(text: 'transactions'), + ListView.builder( + shrinkWrap: true, + physics: const BouncingScrollPhysics(), + controller: _transactionListController, + itemCount: results.data!.transactions.length, + itemBuilder: (BuildContext context, int index) { + return ListTile( + title: Text(tr('transaction_from_to', + namedArgs: <String, String>{ + 'from': humanizeFromToPubKey(pubKey, + results.data!.transactions[index].from), + 'to': humanizeFromToPubKey( + pubKey, results.data!.transactions[index].to) + })), + subtitle: + results.data!.transactions[index].comment.isNotEmpty + ? Text( + results.data!.transactions[index].comment, + style: const TextStyle( + fontStyle: FontStyle.italic, + ), + ) + : null, + tileColor: + index.isEven ? Colors.grey[200] : Colors.white, + trailing: Text( + '${results.data!.transactions[index].amount < 0 ? "" : "+"}${(results.data!.transactions[index].amount / 100).toStringAsFixed(2)} Äž1', + style: TextStyle( + color: + results.data!.transactions[index].amount < 0 + ? Colors.red + : Colors.blue)), + ); + }, + ), + DraggableScrollableSheet( + initialChildSize: 0.1, + minChildSize: 0.1, + maxChildSize: 0.9, + builder: (BuildContext context, + ScrollController scrollController) => + Container( + decoration: BoxDecoration( 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>[ - const Header(text: 'balance'), - Padding( - padding: - const EdgeInsets.symmetric(vertical: 20.0), - child: Center( - child: Text( - '${(results.data!.balance / 100).toStringAsFixed(2)} Äž1', - style: const TextStyle( - fontSize: 36.0, - fontWeight: FontWeight.bold), - )), + 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>[ + const Header(text: 'balance'), + Padding( + padding: const EdgeInsets.symmetric( + vertical: 20.0), + child: Center( + child: Text( + '${(results.data!.balance / 100).toStringAsFixed(2)} Äž1', + style: const TextStyle( + fontSize: 36.0, + fontWeight: FontWeight.bold), + )), + ), + /* Expanded( child: BalanceChart(transactions: _transactions)), */ - ], - )), - )) - ]); - } else if (results.hasError) { - // FIXME - return const Text('Error al cargar los datos.'); - } else { - return const LoadingBox(); - } - }); - } - - String humanizeAddress(String publicAddress, String address) { - if (address == publicAddress) { - return tr('your_wallet'); - } else { - return '\u{1F511}${address.substring(0, 8)}'; - } + ], + )), + )) + ]); + } else if (results.hasError) { + // FIXME + return const Text('Error al cargar los datos.'); + } else { + return const LoadingBox(); + } + })); } }