diff --git a/README.md b/README.md index e8f8d3b0284c15bde03d032e6f4e32277c9ed84f..0f03f76cf35fd9da108a70ba3d527031e992af0f 100644 --- a/README.md +++ b/README.md @@ -138,7 +138,7 @@ to [ios/Runner/Info.plist](./ios/Runner/Info.plist) and update the following cod ## Credits -- Äž1 logo from duniter.org used in the card +- Äž1 logos from duniter.org - undraw intro images: https://undraw.co/license - Chipcard https://commons.wikimedia.org/wiki/File:Chipcard.svg under the Creative Commons Attribution-Share Alike 3.0 Unported license. diff --git a/assets/img/animated-bell.gif b/assets/img/animated-bell.gif new file mode 100644 index 0000000000000000000000000000000000000000..5b207828bf2f53c2f22ea7836cc3d2577fd7d21b Binary files /dev/null and b/assets/img/animated-bell.gif differ diff --git a/assets/img/coin.png b/assets/img/coin.png new file mode 100644 index 0000000000000000000000000000000000000000..c964255f7fc3f2eae37ead131a7ceb80ad261037 Binary files /dev/null and b/assets/img/coin.png differ diff --git a/assets/img/gbrevedot_color.svg b/assets/img/gbrevedot_color.svg new file mode 100644 index 0000000000000000000000000000000000000000..737af2ceb634a363f2e9bffa95c56899eac956e7 --- /dev/null +++ b/assets/img/gbrevedot_color.svg @@ -0,0 +1,114 @@ +<?xml version="1.0" encoding="UTF-8" standalone="no"?> +<svg + xmlns:dc="http://purl.org/dc/elements/1.1/" + xmlns:cc="http://creativecommons.org/ns#" + xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" + xmlns:svg="http://www.w3.org/2000/svg" + xmlns="http://www.w3.org/2000/svg" + xmlns:xlink="http://www.w3.org/1999/xlink" + xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd" + xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" + width="512" + height="512" + viewBox="-0.72 -0.72 1.44 1.44" + version="1.1" + id="svg7" + sodipodi:docname="gbrevedot (copie).svg" + inkscape:version="1.0.2 (e86c870879, 2021-01-15)"> + <metadata + id="metadata13"> + <rdf:RDF> + <cc:Work + rdf:about=""> + <dc:format>image/svg+xml</dc:format> + <dc:type + rdf:resource="http://purl.org/dc/dcmitype/StillImage" /> + <dc:title></dc:title> + </cc:Work> + </rdf:RDF> + </metadata> + <defs + id="defs11"> + <linearGradient + inkscape:collect="always" + id="linearGradient893"> + <stop + style="stop-color:#5096c8;stop-opacity:1" + offset="0" + id="stop885" /> + <stop + style="stop-color:#40b2ff;stop-opacity:0.99607843" + offset="0.49135655" + id="stop887" /> + <stop + style="stop-color:#ffd086;stop-opacity:1" + offset="0.70193791" + id="stop889" /> + <stop + style="stop-color:#fabb37;stop-opacity:1" + offset="1" + id="stop891" /> + </linearGradient> + <linearGradient + inkscape:collect="always" + xlink:href="#linearGradient893" + id="linearGradient863" + x1="0.034742367" + y1="-2.8491416" + x2="0.032584585" + y2="2.8229399" + gradientUnits="userSpaceOnUse" /> + <linearGradient + inkscape:collect="always" + xlink:href="#linearGradient893" + id="linearGradient883" + gradientUnits="userSpaceOnUse" + x1="0.12357556" + y1="-2.7718253" + x2="0.11708657" + y2="1.8124492" /> + </defs> + <sodipodi:namedview + pagecolor="#ffffff" + bordercolor="#666666" + borderopacity="1" + objecttolerance="10" + gridtolerance="10" + guidetolerance="10" + inkscape:pageopacity="0" + inkscape:pageshadow="2" + inkscape:window-width="1920" + inkscape:window-height="1007" + id="namedview9" + showgrid="false" + inkscape:zoom="0.51997208" + inkscape:cx="205.24387" + inkscape:cy="279.21503" + inkscape:window-x="0" + inkscape:window-y="0" + inkscape:window-maximized="1" + inkscape:current-layer="svg7" /> + <g + id="gbreve" + transform="scale(0.25)" + style="fill:url(#linearGradient863);fill-opacity:1.0"> + <path + id="g" + d="M 0.7071,0.7071 A 1,1 0 1,1 0.866,-0.5 L 1.7321,-1 A 2,2 0 1,0 1.4142,1.4142 l 0.3536,0.3536 v -1.4142 h -1.4142 z" + fill="#000" + style="fill:url(#linearGradient883);fill-opacity:1.0" /> + <path + id="breve" + d="M 1,0 h 1 A 2,2 0 0,0 -0.5176,-1.9319 L -0.2588,-0.9659 A 1,1 0 0,1 1,0 z" + transform="translate(0 -3.14159) scale(0.5) rotate(142.5)" + fill="#000" + style="fill:url(#linearGradient863);fill-opacity:1.0" /> + <circle + cx="0" + cy="2.5" + r="0.3536" + fill="#000" + id="circle4" + style="fill:url(#linearGradient863);fill-opacity:1.0" /> + </g> +</svg> diff --git a/assets/translations/en.json b/assets/translations/en.json index 2c8e6d2adbf8edf15d3cbc12965f54f4f8462ea8..efc5818cff7f2b4257864fad6ac591bb2dbb9243 100644 --- a/assets/translations/en.json +++ b/assets/translations/en.json @@ -125,5 +125,14 @@ "install_desktop": "Install Äž1nkgo in Desktop", "import_failed": "Wallet import failed", "select_file_to_import": "Select the wallet backup", - "You can't send money to yourself.": "You can't send money to yourself." + "You can't send money to yourself.": "You can't send money to yourself.", + "request_notifications_perms": "Get Notified with new payments", + "allow_notifications_desc": "Allow Äž1nkgo to notify you with new payments", + "allow_notifications_btn": "ALLOW", + "deny_notifications_btn": "DENY", + "notification_open": "OPEN", + "notification_new_payment_title": "New payment received", + "notification_new_payment_desc": "You have received a {amount} payment from {from}", + "notification_new_sent_title": "New payment sent", + "notification_new_sent_desc": "You have sent a {amount} to {to}" } diff --git a/assets/translations/es.json b/assets/translations/es.json index dff193b89bd031e9babc20067b566453b26ca3db..3b917f665dd85af5d1dc58714f8916a250864a7c 100644 --- a/assets/translations/es.json +++ b/assets/translations/es.json @@ -125,5 +125,14 @@ "install_desktop": "Instalar Äž1nkgo en tu Desktop", "import_failed": "Error al importar monedero", "select_file_to_import": "Selecciona el monedero guadado", - "You can't send money to yourself.": "No puedes enviarte dinero a ti mismo/a." + "You can't send money to yourself.": "No puedes enviarte dinero a ti mismo/a.", + "request_notifications_perms": "Reciba notificaciones con nuevos pagos", + "allow_notifications_desc": "Permitir a Äž1nkgo notificarte de nuevos pagos", + "allow_notifications_btn": "PERMITIR", + "deny_notifications_btn": "DENEGAR", + "notification_open": "ABRIR", + "notification_new_payment_title": "Nuevo pago recibido", + "notification_new_payment_desc": "Has recibido un nuevo pago de {amount} de {from}", + "notification_new_sent_title": "Nuevo pago enviado", + "notification_new_sent_desc": "Has enviado un nuevo pago de {amount} a {to}" } diff --git a/lib/data/models/transaction.dart b/lib/data/models/transaction.dart index 1a4bbfb2293d1f2d434869e5844b8e0c57071417..266531deab7d1f3974827268a2f75f0ea1bb6cb4 100644 --- a/lib/data/models/transaction.dart +++ b/lib/data/models/transaction.dart @@ -67,20 +67,29 @@ class Transaction extends Equatable { @JsonSerializable() @CopyWith() class TransactionsAndBalanceState extends Equatable { - const TransactionsAndBalanceState({ - required this.transactions, - required this.balance, - required this.lastChecked, - }); + const TransactionsAndBalanceState( + {required this.transactions, + required this.balance, + required this.lastChecked, + this.lastSentNotification, + this.lastReceivedNotification}); factory TransactionsAndBalanceState.fromJson(Map<String, dynamic> json) => _$TransactionsAndBalanceStateFromJson(json); final List<Transaction> transactions; final double balance; final DateTime lastChecked; + final DateTime? lastSentNotification; + final DateTime? lastReceivedNotification; Map<String, dynamic> toJson() => _$TransactionsAndBalanceStateToJson(this); @override - List<Object?> get props => <dynamic>[transactions, balance, lastChecked]; + List<Object?> get props => <dynamic>[ + transactions, + balance, + lastChecked, + lastSentNotification, + lastReceivedNotification + ]; } diff --git a/lib/data/models/transaction.g.dart b/lib/data/models/transaction.g.dart index 9442400adfcbe57bd654f92e99fb55f6ed6dad22..73042ccdfebd26a67b7fcc4328170e189f109605 100644 --- a/lib/data/models/transaction.g.dart +++ b/lib/data/models/transaction.g.dart @@ -161,6 +161,12 @@ abstract class _$TransactionsAndBalanceStateCWProxy { TransactionsAndBalanceState lastChecked(DateTime lastChecked); + TransactionsAndBalanceState lastSentNotification( + DateTime? lastSentNotification); + + TransactionsAndBalanceState lastReceivedNotification( + DateTime? lastReceivedNotification); + /// This function **does support** nullification of nullable fields. All `null` values passed to `non-nullable` fields will be ignored. You can also use `TransactionsAndBalanceState(...).copyWith.fieldName(...)` to override fields one at a time with nullification support. /// /// Usage @@ -171,6 +177,8 @@ abstract class _$TransactionsAndBalanceStateCWProxy { List<Transaction>? transactions, double? balance, DateTime? lastChecked, + DateTime? lastSentNotification, + DateTime? lastReceivedNotification, }); } @@ -192,6 +200,16 @@ class _$TransactionsAndBalanceStateCWProxyImpl TransactionsAndBalanceState lastChecked(DateTime lastChecked) => this(lastChecked: lastChecked); + @override + TransactionsAndBalanceState lastSentNotification( + DateTime? lastSentNotification) => + this(lastSentNotification: lastSentNotification); + + @override + TransactionsAndBalanceState lastReceivedNotification( + DateTime? lastReceivedNotification) => + this(lastReceivedNotification: lastReceivedNotification); + @override /// This function **does support** nullification of nullable fields. All `null` values passed to `non-nullable` fields will be ignored. You can also use `TransactionsAndBalanceState(...).copyWith.fieldName(...)` to override fields one at a time with nullification support. @@ -204,6 +222,8 @@ class _$TransactionsAndBalanceStateCWProxyImpl Object? transactions = const $CopyWithPlaceholder(), Object? balance = const $CopyWithPlaceholder(), Object? lastChecked = const $CopyWithPlaceholder(), + Object? lastSentNotification = const $CopyWithPlaceholder(), + Object? lastReceivedNotification = const $CopyWithPlaceholder(), }) { return TransactionsAndBalanceState( transactions: @@ -220,6 +240,15 @@ class _$TransactionsAndBalanceStateCWProxyImpl ? _value.lastChecked // ignore: cast_nullable_to_non_nullable : lastChecked as DateTime, + lastSentNotification: lastSentNotification == const $CopyWithPlaceholder() + ? _value.lastSentNotification + // ignore: cast_nullable_to_non_nullable + : lastSentNotification as DateTime?, + lastReceivedNotification: + lastReceivedNotification == const $CopyWithPlaceholder() + ? _value.lastReceivedNotification + // ignore: cast_nullable_to_non_nullable + : lastReceivedNotification as DateTime?, ); } } @@ -278,6 +307,12 @@ TransactionsAndBalanceState _$TransactionsAndBalanceStateFromJson( .toList(), balance: (json['balance'] as num).toDouble(), lastChecked: DateTime.parse(json['lastChecked'] as String), + lastSentNotification: json['lastSentNotification'] == null + ? null + : DateTime.parse(json['lastSentNotification'] as String), + lastReceivedNotification: json['lastReceivedNotification'] == null + ? null + : DateTime.parse(json['lastReceivedNotification'] as String), ); Map<String, dynamic> _$TransactionsAndBalanceStateToJson( @@ -286,4 +321,7 @@ Map<String, dynamic> _$TransactionsAndBalanceStateToJson( 'transactions': instance.transactions, 'balance': instance.balance, 'lastChecked': instance.lastChecked.toIso8601String(), + 'lastSentNotification': instance.lastSentNotification?.toIso8601String(), + 'lastReceivedNotification': + instance.lastReceivedNotification?.toIso8601String(), }; diff --git a/lib/data/models/transaction_cubit.dart b/lib/data/models/transaction_cubit.dart index fb54f6a35f3bb8a94d74f287b8e7f733885a0847..6ec5eecaa35de044e6af15c24e015bcb3a69787b 100644 --- a/lib/data/models/transaction_cubit.dart +++ b/lib/data/models/transaction_cubit.dart @@ -2,10 +2,14 @@ import 'package:hydrated_bloc/hydrated_bloc.dart'; import '../../../g1/api.dart'; import '../../../g1/transaction_parser.dart'; +import '../../notification_controller.dart'; import '../../shared_prefs.dart'; +import '../../ui/contacts_cache.dart'; import '../../ui/logger.dart'; +import 'contact.dart'; import 'node_list_cubit.dart'; import 'transaction.dart'; +import 'transaction_type.dart'; class TransactionsCubit extends HydratedCubit<TransactionsAndBalanceState> { TransactionsCubit() @@ -29,18 +33,46 @@ class TransactionsCubit extends HydratedCubit<TransactionsAndBalanceState> { } Future<void> fetchTransactions(NodeListCubit cubit) async { - logger('Loading transactions'); + logger('Loading transactions --------------------'); final Map<String, dynamic>? txData = await gvaHistoryAndBalance(SharedPreferencesHelper().getPubKey()); if (txData == null) { logger('Failed to get transactions'); return; } - final TransactionsAndBalanceState state = transactionsGvaParser(txData); - emit(state.copyWith( - transactions: state.transactions, - balance: state.balance, - lastChecked: state.lastChecked)); + final TransactionsAndBalanceState newState = + transactionsGvaParser(txData, state); + // Notify + final DateTime lastReceivedNotification = + newState.lastReceivedNotification ?? DateTime.now(); + final DateTime lastSentNotification = + newState.lastSentNotification ?? DateTime.now(); + // Notify +/* logger( + 'Last received: ${lastReceived.toIso8601String()}, last received notification: ${lastReceivedNotification.toIso8601String()}, compared ${lastReceived.compareTo(lastReceivedNotification)}');*/ + emit(newState); + for (final Transaction tx in newState.transactions.reversed) { + if (tx.type == TransactionType.received && + lastReceivedNotification.compareTo(tx.time) == -1) { + // Future + final Contact from = await ContactsCache().getContact(tx.from); + NotificationController.createNewNotification( + tx.time.millisecondsSinceEpoch.toString(), + amount: tx.amount / 100, + from: from.title); + emit(newState.copyWith(lastReceivedNotification: tx.time)); + } + if (tx.type == TransactionType.sent && + lastSentNotification.compareTo(tx.time) == -1) { + // Future + final Contact to = await ContactsCache().getContact(tx.from); + NotificationController.createNewNotification( + tx.time.millisecondsSinceEpoch.toString(), + amount: -tx.amount / 100, + to: to.title); + emit(newState.copyWith(lastSentNotification: tx.time)); + } + } } @override diff --git a/lib/g1/transaction_parser.dart b/lib/g1/transaction_parser.dart index 656f208b8a5da88025b1cc1446004749d8a12587..ea53fef387cea00c34326965982a78c97079f1e6 100644 --- a/lib/g1/transaction_parser.dart +++ b/lib/g1/transaction_parser.dart @@ -52,7 +52,8 @@ TransactionsAndBalanceState transactionParser(String txData) { transactions: tx, balance: balance, lastChecked: DateTime.now()); } -TransactionsAndBalanceState transactionsGvaParser(Map<String, dynamic> txData) { +TransactionsAndBalanceState transactionsGvaParser( + Map<String, dynamic> txData, TransactionsAndBalanceState state) { // Balance final dynamic rawBalance = txData['balance']; final double? amount; @@ -89,7 +90,8 @@ TransactionsAndBalanceState transactionsGvaParser(Map<String, dynamic> txData) { sendingRaw as Map<String, dynamic>, TransactionType.sending); txs.insert(0, tx); } - return TransactionsAndBalanceState( + + return state.copyWith( transactions: txs, balance: amount, lastChecked: DateTime.now()); } diff --git a/lib/main.dart b/lib/main.dart index 07afe7e0a62650d3d669961236ec4a69696cd852..93224a7456cab416baf93be16ae17c64b3b86e8b 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,6 +1,7 @@ import 'dart:io'; import 'package:connectivity_wrapper/connectivity_wrapper.dart'; +import 'package:cron/cron.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:filesystem_picker/filesystem_picker.dart'; import 'package:flutter/foundation.dart'; @@ -32,6 +33,7 @@ import 'data/models/node_type.dart'; import 'data/models/payment_cubit.dart'; import 'data/models/transaction_cubit.dart'; import 'g1/api.dart'; +import 'notification_controller.dart'; import 'shared_prefs.dart'; import 'ui/contacts_cache.dart'; import 'ui/logger.dart'; @@ -40,6 +42,7 @@ import 'ui/ui_helpers.dart'; void main() async { Bloc.observer = AppBlocObserver(); + await NotificationController.initializeLocalNotifications(); /// Initialize packages WidgetsFlutterBinding.ensureInitialized(); @@ -237,6 +240,10 @@ PageViewModel createPageViewModel( class GinkgoApp extends StatefulWidget { const GinkgoApp({super.key}); + // The navigator key is necessary to navigate using static methods + static final GlobalKey<NavigatorState> navigatorKey = + GlobalKey<NavigatorState>(); + @override State<GinkgoApp> createState() => _GinkgoAppState(); } @@ -268,6 +275,24 @@ class _GinkgoAppState extends State<GinkgoApp> { super.initState(); ContactsCache().init(); NodeManager().loadFromCubit(context.read<NodeListCubit>()); + // Only after at least the action method is set, the notification events are delivered + NotificationController.startListeningNotificationEvents(); + final Cron cron = Cron(); + cron.schedule(Schedule.parse(kReleaseMode ? '*/10 * * * *' : '*/2 * * * *'), + () async { + logger('---------- fetchTransactions via cron'); + fetchTransactions(context); + }); + Once.runHourly('load_nodes', callback: () { + logger('load nodes via once'); + _loadNodes(); + }, fallback: () { + _printNodeStatus(prefix: 'After once hourly having'); + }); + Once.runDaily('clear_errors', callback: () { + logger('clearErrors via once'); + NodeManager().cleanErrorStats(); + }); } @override @@ -280,18 +305,6 @@ class _GinkgoAppState extends State<GinkgoApp> { Widget build(BuildContext context) { return BlocBuilder<NodeListCubit, NodeListState>( builder: (BuildContext nodeContext, NodeListState state) { - Once.runHourly('load_nodes', - callback: () => _loadNodes(), - fallback: () { - _printNodeStatus(prefix: 'After once hourly having'); - }); - Once.runCustom('clear_errors', callback: () { - NodeManager().cleanErrorStats(); - }, duration: const Duration(minutes: 90)); - Once.runCustom('fetch_transactions', callback: () { - fetchTransactions(context); - }, duration: const Duration(minutes: 10)); - fetchTransactions(context); return ConnectivityAppWrapper( app: FilesystemPickerDefaultOptions( fileTileSelectMode: FileTileSelectMode.wholeTile, @@ -308,6 +321,8 @@ class _GinkgoAppState extends State<GinkgoApp> { darkTheme: ThemeData(useMaterial3: true, colorScheme: darkColorScheme), + navigatorKey: GinkgoApp.navigatorKey, + /// Theme stuff /// Localization stuff @@ -319,6 +334,7 @@ class _GinkgoAppState extends State<GinkgoApp> { ? const SkeletonScreen() : const AppIntro(), builder: (BuildContext buildContext, Widget? widget) { + NotificationController.locale = context.locale; return ResponsiveWrapper.builder( BouncingScrollWrapper.builder( context, diff --git a/lib/notification_controller.dart b/lib/notification_controller.dart new file mode 100644 index 0000000000000000000000000000000000000000..03ee23fd6f12d58ca02d464a2efe6042fa4b615d --- /dev/null +++ b/lib/notification_controller.dart @@ -0,0 +1,251 @@ +import 'package:awesome_notifications/awesome_notifications.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; + +import 'config/theme.dart'; +import 'main.dart'; +import 'ui/logger.dart'; +import 'ui/ui_helpers.dart'; + +// ignore: avoid_classes_with_only_static_members +/// ********************************************* +/// NOTIFICATION CONTROLLER +/// ********************************************* +/// +class NotificationController { + static ReceivedAction? initialAction; + static Locale locale = const Locale('en', 'UK'); + + /// ********************************************* + /// INITIALIZATIONS + /// ********************************************* + /// + static Future<void> initializeLocalNotifications() async { + await AwesomeNotifications().initialize( + null, //'resource://drawable/res_app_icon',// + <NotificationChannel>[ + NotificationChannel( + channelKey: 'alerts', + channelName: 'Alerts', + channelDescription: 'Notification tests as alerts', + playSound: true, + onlyAlertOnce: true, + groupAlertBehavior: GroupAlertBehavior.Children, + importance: NotificationImportance.High, + defaultPrivacy: NotificationPrivacy.Private, + defaultColor: lightColorScheme.primary, + ledColor: lightColorScheme.primary) + ], + debug: true); + + // Get initial notification action is optional + initialAction = await AwesomeNotifications().getInitialNotificationAction(); + } + + /// ********************************************* + /// NOTIFICATION EVENTS LISTENER + /// ********************************************* + /// Notifications events are only delivered after call this method + static Future<void> startListeningNotificationEvents() async { + AwesomeNotifications() + .setListeners(onActionReceivedMethod: onActionReceivedMethod); + } + + /// ********************************************* + /// NOTIFICATION EVENTS + /// ********************************************* + /// + @pragma('vm:entry-point') + static Future<void> onActionReceivedMethod( + ReceivedAction receivedAction) async { + if (receivedAction.actionType == ActionType.SilentAction || + receivedAction.actionType == ActionType.SilentBackgroundAction) { + // For background actions, you must hold the execution until the end + logger( + 'Message sent via notification input: "${receivedAction.buttonKeyInput}"'); + // await executeLongTaskInBackground(); + } else { + // FIXME (vjrj): go to transactions tab + GinkgoApp.navigatorKey.currentState?.pushNamedAndRemoveUntil( + '/notification-page', + (Route<dynamic> route) => + (route.settings.name != '/notification-page') || route.isFirst, + arguments: receivedAction); + } + } + + /// ********************************************* + /// REQUESTING NOTIFICATION PERMISSIONS + /// ********************************************* + /// + static Future<bool> displayNotificationRationale() async { + bool userAuthorized = false; + final BuildContext context = GinkgoApp.navigatorKey.currentContext!; + await showDialog( + context: context, + builder: (BuildContext ctx) { + return AlertDialog( + title: Text(tr('request_notifications_perms'), + style: Theme.of(context).textTheme.titleLarge), + content: Column( + mainAxisSize: MainAxisSize.min, + children: <Widget>[ + Row( + children: <Widget>[ + Expanded( + child: Image.asset( + 'assets/img/animated-bell.gif', + height: MediaQuery.of(context).size.height * 0.3, + fit: BoxFit.fitWidth, + ), + ), + ], + ), + const SizedBox(height: 20), + Text(tr('allow_notifications_desc')), + ], + ), + actions: <Widget>[ + TextButton( + onPressed: () { + Navigator.of(ctx).pop(); + }, + child: Text( + tr('deny_notifications_btn'), + style: Theme.of(context) + .textTheme + .titleLarge + ?.copyWith(color: Colors.red), + )), + TextButton( + onPressed: () async { + userAuthorized = true; + Navigator.of(ctx).pop(); + }, + child: Text(tr('allow_notifications_btn'), + style: Theme.of(context) + .textTheme + .titleLarge + ?.copyWith(color: lightColorScheme.primary)), + ), + ], + ); + }); + return userAuthorized && + await AwesomeNotifications().requestPermissionToSendNotifications(); + } + + /// ********************************************* + /// NOTIFICATION CREATION METHODS + /// ********************************************* + /// + static Future<void> createNewNotification(String id, + {required double amount, String? to, String? from}) async { + final String title = from != null + ? tr('notification_new_payment_title') + : tr('notification_new_sent_title'); + final String desc = from != null + ? tr('notification_new_payment_desc', namedArgs: <String, String>{ + 'amount': formatAmountWithLocale(locale.languageCode, amount), + 'from': from, + }) + : tr('notification_new_sent_desc', namedArgs: <String, String>{ + 'amount': formatAmountWithLocale(locale.languageCode, amount), + 'to': to!, + }); + if (kIsWeb) { + // dart:html cannot be used in Android + /* if (html.Notification.permission != 'granted') { + await html.Notification.requestPermission(); + } + if (html.Notification.permission == 'granted') { + final html.Notification notification = html.Notification( + title, body: desc, + // icon: + ); + // html.Notification.show(); + } */ + } else { + bool isAllowed = await AwesomeNotifications().isNotificationAllowed(); + if (!isAllowed) { + isAllowed = await displayNotificationRationale(); + } + if (!isAllowed) { + return; + } + + await AwesomeNotifications().createNotification( + content: NotificationContent( + id: -1, + // -1 is replaced by a random number + channelKey: 'alerts', + title: title, + body: desc, + largeIcon: + 'https://git.duniter.org/vjrj/ginkgo/-/raw/master/assets/img/coin.png', + bigPicture: 'https://git.duniter.org/vjrj/ginkgo/-/raw/master/assets/img/gbrevedot_color.svg', + //'asset://assets/images/balloons-in-sky.jpg', + notificationLayout: NotificationLayout.BigPicture, + payload: <String, String>{'notificationId': id}), + actionButtons: <NotificationActionButton>[ + NotificationActionButton( + key: 'notification_open', label: tr('notification_open')), + /* NotificationActionButton( + key: 'REPLY', + label: 'Reply Message', + requireInputText: true, + actionType: ActionType.SilentAction), */ + /* NotificationActionButton( + key: 'DISMISS', + label: 'Dismiss', + actionType: ActionType.DismissAction, + isDangerousOption: true) */ + ]); + } + } + + static Future<void> scheduleNewNotification() async { + bool isAllowed = await AwesomeNotifications().isNotificationAllowed(); + if (!isAllowed) { + isAllowed = await displayNotificationRationale(); + } + if (!isAllowed) { + return; + } + + await AwesomeNotifications().createNotification( + content: NotificationContent( + id: -1, + // -1 is replaced by a random number + channelKey: 'alerts', + title: 'Huston! The eagle has landed!', + body: + "A small step for a man, but a giant leap to Flutter's community!", + bigPicture: 'https://storage.googleapis.com/cms-storage-bucket/d406c736e7c4c57f5f61.png', + largeIcon: 'https://storage.googleapis.com/cms-storage-bucket/0dbfcc7a59cd1cf16282.png', + //'asset://assets/images/balloons-in-sky.jpg', + notificationLayout: NotificationLayout.BigPicture, + payload: <String, String>{ + 'notificationId': '1234567890' + }), + actionButtons: <NotificationActionButton>[ + NotificationActionButton(key: 'REDIRECT', label: 'Redirect'), + NotificationActionButton( + key: 'DISMISS', + label: 'Dismiss', + actionType: ActionType.DismissAction, + isDangerousOption: true) + ], + schedule: NotificationCalendar.fromDate( + date: DateTime.now().add(const Duration(seconds: 10)))); + } + + static Future<void> resetBadgeCounter() async { + await AwesomeNotifications().resetGlobalBadge(); + } + + static Future<void> cancelNotifications() async { + await AwesomeNotifications().cancelAll(); + } +} diff --git a/lib/ui/screens/fifth_screen.dart b/lib/ui/screens/fifth_screen.dart index a641ec63702923cb4f7a946a71adf9318ad67255..7567c6c2125a278b0bcc998acdfae23b784bc6d3 100644 --- a/lib/ui/screens/fifth_screen.dart +++ b/lib/ui/screens/fifth_screen.dart @@ -7,6 +7,7 @@ import 'package:share_plus/share_plus.dart'; import '../../data/models/app_cubit.dart'; import '../../data/models/app_state.dart'; import '../../data/models/node_type.dart'; +import '../../notification_controller.dart'; import '../../shared_prefs.dart'; import '../ui_helpers.dart'; import '../widgets/bottom_widget.dart'; @@ -25,8 +26,7 @@ class FifthScreen extends StatelessWidget { @override Widget build(BuildContext context) { return BlocBuilder<AppCubit, AppState>( - builder: (BuildContext context, AppState state) => - Scaffold( + builder: (BuildContext context, AppState state) => Scaffold( appBar: AppBar(title: Text(tr('bottom_nav_fifth'))), drawer: const CardDrawer(), body: ListView( @@ -43,6 +43,7 @@ class FifthScreen extends StatelessWidget { ), onChanged: (Locale? newLocale) { context.setLocale(newLocale!); + NotificationController.locale = newLocale; }, items: const <DropdownMenuItem<Locale>>[ DropdownMenuItem<Locale>( @@ -89,9 +90,8 @@ class FifthScreen extends StatelessWidget { GridItem( title: 'share_your_key', icon: Icons.share, - onTap: () => - Share.share( - SharedPreferencesHelper().getPubKey())), + onTap: () => Share.share( + SharedPreferencesHelper().getPubKey())), GridItem( title: 'copy_your_key', icon: Icons.copy, @@ -140,7 +140,6 @@ class FifthScreen extends StatelessWidget { }, ); }), - ]), if (state.expertMode) const TextDivider(text: 'technical_info_title'), @@ -155,7 +154,7 @@ class FifthScreen extends StatelessWidget { title: 'code_card_title', icon: Icons.code_rounded, url: - Uri.parse('https://git.duniter.org/vjrj/ginkgo')), + Uri.parse('https://git.duniter.org/vjrj/ginkgo')), const BottomWidget(), SwitchListTile( title: Text(tr('expert_mode')), diff --git a/lib/ui/screens/first_screen.dart b/lib/ui/screens/first_screen.dart index edac8f14a3ad9dce37f846a95f604ad354bc6f31..85f05a73bb2e5f2f7d2c309b28e245799f84a20c 100644 --- a/lib/ui/screens/first_screen.dart +++ b/lib/ui/screens/first_screen.dart @@ -10,7 +10,7 @@ import '../widgets/bottom_widget.dart'; import '../widgets/card_drawer.dart'; import '../widgets/first_screen/credit_card.dart'; import '../widgets/first_screen/pay_contact_search_button.dart'; -import 'pay_form.dart'; +import '../widgets/first_screen/pay_form.dart'; class FirstScreen extends StatefulWidget { const FirstScreen({super.key}); diff --git a/lib/ui/ui_helpers.dart b/lib/ui/ui_helpers.dart index 930c10af193518a775ce73c1fd29b33b084d3842..1ecbc266581856106173070a161bd3bb88cef5c7 100644 --- a/lib/ui/ui_helpers.dart +++ b/lib/ui/ui_helpers.dart @@ -36,6 +36,11 @@ void showTooltip(BuildContext context, String title, String message) { } void copyPublicKeyToClipboard(BuildContext context) { + /* final DataWriterItem item = DataWriterItem(); + item.add(Formats.plainText(SharedPreferencesHelper().getPubKey())); + ClipboardWriter.instance.write(<DataWriterItem>[item]).then((dynamic value) => + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(tr('key_copied_to_clipboard'))))); */ FlutterClipboard.copy(SharedPreferencesHelper().getPubKey()).then( (dynamic value) => ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text(tr('key_copied_to_clipboard'))))); @@ -121,10 +126,15 @@ bool smallScreen(BuildContext context) => MediaQuery.of(context).size.width <= smallScreenWidth; String formatAmount(BuildContext context, double amount) { + return formatAmountWithLocale( + Localizations.localeOf(context).toString(), amount); +} + +String formatAmountWithLocale(String locale, double amount) { final NumberFormat currencyFormatter = NumberFormat.currency( // in English $10 is G110 ... confusing symbol: 'Äž1 ', - locale: Localizations.localeOf(context).toString(), + locale: locale, decimalDigits: 2, ); return currencyFormatter.format(amount); @@ -221,3 +231,10 @@ Future<Directory?> getAppSpecificExternalFilesDirectory( } return getExternalStorageDirectory(); } + +ImageIcon get g1nkgoIcon => ImageIcon( + AssetImage(ginkgoIconLocation), + size: 24, + ); + +String get ginkgoIconLocation => assets('img/favicon.png'); diff --git a/lib/ui/widgets/card_drawer.dart b/lib/ui/widgets/card_drawer.dart index 6fe4a51f0f566ef8c47d3c74c4fb110ff08d191f..9f6041829e6bcfa4c94ffcb3a2e8d2d433033c2b 100644 --- a/lib/ui/widgets/card_drawer.dart +++ b/lib/ui/widgets/card_drawer.dart @@ -14,10 +14,7 @@ class CardDrawer extends StatelessWidget { @override Widget build(BuildContext context) { final List<CesiumCard> cards = SharedPreferencesHelper().cesiumCards; - final ImageIcon g1nkgoIcon = ImageIcon( - AssetImage(assets('img/favicon.png')), - size: 24, - ); + return FutureBuilder<PackageInfo>( future: PackageInfo.fromPlatform(), builder: (BuildContext context, AsyncSnapshot<PackageInfo> snapshot) { diff --git a/lib/ui/screens/g1_textfield.dart b/lib/ui/widgets/first_screen/g1_textfield.dart similarity index 96% rename from lib/ui/screens/g1_textfield.dart rename to lib/ui/widgets/first_screen/g1_textfield.dart index e3db739e9d83cc1ab62bf5e0e31eb7fc9c1f7b65..a2cd1aff3e1068f6423b9c81922209305c9838df 100644 --- a/lib/ui/screens/g1_textfield.dart +++ b/lib/ui/widgets/first_screen/g1_textfield.dart @@ -3,9 +3,9 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_svg/flutter_svg.dart'; -import '../../data/models/payment_cubit.dart'; -import '../../data/models/payment_state.dart'; -import '../ui_helpers.dart'; +import '../../../data/models/payment_cubit.dart'; +import '../../../data/models/payment_state.dart'; +import '../../ui_helpers.dart'; class G1PayAmountField extends StatefulWidget { const G1PayAmountField({super.key}); diff --git a/lib/ui/screens/pay_form.dart b/lib/ui/widgets/first_screen/pay_form.dart similarity index 95% rename from lib/ui/screens/pay_form.dart rename to lib/ui/widgets/first_screen/pay_form.dart index 8d4ea40a87452f6d37bb1cd6cb4c252045296c2b..1512436e792c49dae0a5a539aaf10ce58e623f09 100644 --- a/lib/ui/screens/pay_form.dart +++ b/lib/ui/widgets/first_screen/pay_form.dart @@ -5,14 +5,14 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -import '../../data/models/node_list_cubit.dart'; -import '../../data/models/node_type.dart'; -import '../../data/models/payment_cubit.dart'; -import '../../data/models/payment_state.dart'; -import '../../data/models/transaction_cubit.dart'; -import '../../g1/api.dart'; -import '../logger.dart'; -import '../ui_helpers.dart'; +import '../../../data/models/node_list_cubit.dart'; +import '../../../data/models/node_type.dart'; +import '../../../data/models/payment_cubit.dart'; +import '../../../data/models/payment_state.dart'; +import '../../../data/models/transaction_cubit.dart'; +import '../../../g1/api.dart'; +import '../../logger.dart'; +import '../../ui_helpers.dart'; import 'g1_textfield.dart'; class PayForm extends StatefulWidget { diff --git a/lib/ui/widgets/fourth_screen/transaction_page.dart b/lib/ui/widgets/fourth_screen/transaction_page.dart index aa26228d101b86765ef883b64e43f295c231e97d..be5fc90a3317e8e4ba6021094bdf21ded53ecb82 100644 --- a/lib/ui/widgets/fourth_screen/transaction_page.dart +++ b/lib/ui/widgets/fourth_screen/transaction_page.dart @@ -83,6 +83,7 @@ class _TransactionsAndBalanceWidgetState builder: (BuildContext lContext, BoxConstraints constraints) => IconButton( + // icon: const Icon(Icons.account_balance_wallet), icon: const Icon(Icons.savings), onPressed: () { if (Backdrop.of(lContext).isBackLayerConcealed) { diff --git a/lib/ui/widgets/third_screen/contacts_page.dart b/lib/ui/widgets/third_screen/contacts_page.dart index c36f7fb4ff0520a4c717875f7b329fe3d8f5c9b2..128aca994993fa12bf7e470e914e63759cd6ab9c 100644 --- a/lib/ui/widgets/third_screen/contacts_page.dart +++ b/lib/ui/widgets/third_screen/contacts_page.dart @@ -88,6 +88,16 @@ class _ContactsPageState extends State<ContactsPage> { icon: Icons.delete, label: tr('delete_contact'), ), + if (showShare()) + SlidableAction( + onPressed: (BuildContext c) => + Share.share(contact.pubKey), + backgroundColor: + Theme.of(context).secondaryHeaderColor, + foregroundColor: Theme.of(context).primaryColor, + icon: Icons.share, + label: tr('share_this_key'), + ), /* SlidableAction( onPressed: (BuildContext c) {}, backgroundColor: const Color(0xFF21B7CA), @@ -120,16 +130,6 @@ class _ContactsPageState extends State<ContactsPage> { icon: Icons.copy, label: tr('copy_contact_key'), ), - if (showShare()) - SlidableAction( - onPressed: (BuildContext c) => - Share.share(contact.pubKey), - backgroundColor: - Theme.of(context).secondaryHeaderColor, - foregroundColor: Theme.of(context).primaryColor, - icon: Icons.share, - label: tr('share_this_key'), - ), SlidableAction( onPressed: (BuildContext c) { onSent(c, contact); diff --git a/pubspec.lock b/pubspec.lock index 83f8c8cc96feea6a539d1bac7f31ba29bb076dc0..5a1edf2bfea59cb6e26a9786781269d9d4ecc835 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -57,6 +57,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.10.0" + awesome_notifications: + dependency: "direct main" + description: + name: awesome_notifications + sha256: "2b430c75cc879d6cfd52bb6eb2b5c1591ed425347816408cdcbd3f6916bba14c" + url: "https://pub.dev" + source: hosted + version: "0.7.4+1" backdrop: dependency: "direct main" description: @@ -265,6 +273,14 @@ packages: url: "https://pub.dev" source: hosted version: "5.0.2" + cron: + dependency: "direct main" + description: + name: cron + sha256: d98aa8cdad0cccdb6b098e6a1fb89339c180d8a229145fa4cd8c6fc538f0e35f + url: "https://pub.dev" + source: hosted + version: "0.5.1" cross_file: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 6e6fd1c340241ac47faabb780a0ec59577156b79..d952124a7683354939212e3b6e6b67434696fef5 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.14-SNAPSHOT +version: 0.0.15 environment: sdk: ">=2.17.1 <3.0.0" @@ -71,6 +71,8 @@ dependencies: filesystem_picker: ^3.1.0 path: ^1.8.2 path_provider: ^2.0.14 + awesome_notifications: ^0.7.4+1 + cron: ^0.5.1 dev_dependencies: flutter_test: @@ -115,6 +117,7 @@ flutter: - assets/img/logo-cesium.png - assets/tx.json - assets/gva-tx.json + - assets/img/animated-bell.gif fonts: - family: Nunito diff --git a/test/transactions_test.dart b/test/transactions_test.dart index 5cad3a18e26053f1991bc9324410edb010e88760..ad223b29b87001dc7cec5fe313e3698249b7be0d 100644 --- a/test/transactions_test.dart +++ b/test/transactions_test.dart @@ -7,6 +7,11 @@ import 'package:ginkgo/data/models/transaction_type.dart'; import 'package:ginkgo/g1/transaction_parser.dart'; void main() { + final TransactionsAndBalanceState emptyState = TransactionsAndBalanceState( + transactions: const <Transaction>[], + balance: 0, + lastChecked: DateTime(1970)); + test('Test parsing', () async { TestWidgetsFlutterBinding.ensureInitialized(); final String txData = await rootBundle.loadString('assets/tx.json'); @@ -29,7 +34,8 @@ void main() { final String txData = await rootBundle.loadString('assets/gva-tx.json'); final TransactionsAndBalanceState result = transactionsGvaParser( (jsonDecode(txData) as Map<String, dynamic>)['data'] - as Map<String, dynamic>); + as Map<String, dynamic>, + emptyState); expect(result.balance, equals(3)); final List<Transaction> txs = result.transactions; for (final Transaction tx in txs) { @@ -79,7 +85,8 @@ void main() { }'''; final TransactionsAndBalanceState emptyResult = transactionsGvaParser( (jsonDecode(emptyTx) as Map<String, dynamic>)['data'] - as Map<String, dynamic>); + as Map<String, dynamic>, + emptyState); expect(emptyResult.balance, equals(0)); final List<Transaction> emptyTxs = emptyResult.transactions; expect(emptyTxs.length, equals(0));