diff --git a/app/lib/apps/notifications/notifications_user_data.dart b/app/lib/apps/notifications/notifications_user_data.dart index 3c65da1a..944e76b5 100644 --- a/app/lib/apps/notifications/notifications_user_data.dart +++ b/app/lib/apps/notifications/notifications_user_data.dart @@ -1,7 +1,8 @@ import 'package:shared_preferences/shared_preferences.dart'; -import 'package:threebotlogin/helpers/logger.dart'; const String nodeStatusNotificationEnabledKey = 'nodeStatusNotificationEnabled'; +const String _contractNotificationsEnabledKey = + 'contract_notifications_enabled'; Future?> getNotificationSettings() async { final prefs = await SharedPreferences.getInstance(); @@ -10,19 +11,21 @@ Future?> getNotificationSettings() async { } Future isNodeStatusNotificationEnabled() async { - try { - final prefs = await SharedPreferences.getInstance(); - return prefs.getBool(nodeStatusNotificationEnabledKey) ?? true; - } catch (e) { - return true; - } + final prefs = await SharedPreferences.getInstance(); + return prefs.getBool(nodeStatusNotificationEnabledKey) ?? true; } Future setNodeStatusNotificationEnabled(bool value) async { - try { - final prefs = await SharedPreferences.getInstance(); - await prefs.setBool(nodeStatusNotificationEnabledKey, value); - } catch (e) { - logger.e('Error saving notification preference: $e'); - } + final prefs = await SharedPreferences.getInstance(); + await prefs.setBool(nodeStatusNotificationEnabledKey, value); +} + +Future isContractNotificationEnabled() async { + final prefs = await SharedPreferences.getInstance(); + return prefs.getBool(_contractNotificationsEnabledKey) ?? true; +} + +Future setContractNotificationEnabled(bool enabled) async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setBool(_contractNotificationsEnabledKey, enabled); } diff --git a/app/lib/main.dart b/app/lib/main.dart index df41efe4..245ebd50 100644 --- a/app/lib/main.dart +++ b/app/lib/main.dart @@ -46,19 +46,6 @@ Future main() async { await NotificationService().initNotification(); BackgroundFetch.registerHeadlessTask(backgroundFetchHeadlessTask); - - bool initDone = await getInitDone(); - String? doubleName = await getDoubleName(); - - await setGlobalValues(); - bool registered = doubleName != null; - - runApp( - ProviderScope( - child: MyApp(initDone: initDone, registered: registered), - ), - ); - BackgroundFetch.configure( BackgroundFetchConfig( minimumFetchInterval: 15, @@ -71,14 +58,25 @@ Future main() async { ), (String taskId) async { logger.i('[BackgroundFetch] Task: $taskId'); - await checkNodeStatus(taskId); - BackgroundFetch.finish(taskId); + backgroundFetchHeadlessTask(HeadlessTask(taskId, false)); }, (String taskId) async { logger.i('[BackgroundFetch] Timeout: $taskId'); BackgroundFetch.finish(taskId); }, ); + + bool initDone = await getInitDone(); + String? doubleName = await getDoubleName(); + + await setGlobalValues(); + bool registered = doubleName != null; + + runApp( + ProviderScope( + child: MyApp(initDone: initDone, registered: registered), + ), + ); } Future setGlobalValues() async { @@ -129,7 +127,7 @@ class MyApp extends ConsumerWidget { backgroundColor: kColorScheme.primary, foregroundColor: kColorScheme.onPrimary, ), - cardTheme: const CardTheme().copyWith( + cardTheme: const CardThemeData().copyWith( color: kColorScheme.surfaceContainer, margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8)), elevatedButtonTheme: ElevatedButtonThemeData( @@ -156,7 +154,7 @@ class MyApp extends ConsumerWidget { backgroundColor: kDarkColorScheme.primaryContainer, foregroundColor: kDarkColorScheme.onPrimaryContainer, ), - cardTheme: const CardTheme().copyWith( + cardTheme: const CardThemeData().copyWith( color: kDarkColorScheme.surfaceContainer, margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8)), elevatedButtonTheme: ElevatedButtonThemeData( diff --git a/app/lib/screens/notifications_screen.dart b/app/lib/screens/notifications_screen.dart index 5bef1331..93e2ad24 100644 --- a/app/lib/screens/notifications_screen.dart +++ b/app/lib/screens/notifications_screen.dart @@ -12,17 +12,21 @@ class NotificationsScreen extends StatefulWidget { class _NotificationsScreenState extends State { bool loading = true; late bool _nodeStatusNotificationEnabled; + late bool _contractNotificationsEnabled; @override void initState() { super.initState(); - _loadNotificationPreference(); + _loadNotificationPreferences(); } - void _loadNotificationPreference() async { - final bool enabled = await isNodeStatusNotificationEnabled(); + void _loadNotificationPreferences() async { + final bool nodeEnabled = await isNodeStatusNotificationEnabled(); + final bool contractEnabled = + await isContractNotificationEnabled(); setState(() { - _nodeStatusNotificationEnabled = enabled; + _nodeStatusNotificationEnabled = nodeEnabled; + _contractNotificationsEnabled = contractEnabled; loading = false; }); } @@ -69,6 +73,18 @@ class _NotificationsScreenState extends State { }, secondary: const Icon(Icons.monitor_heart), ), + SwitchListTile( + title: + const Text('Enable contract grace period notifications'), + value: _contractNotificationsEnabled, + onChanged: (bool newValue) { + setState(() { + _contractNotificationsEnabled = newValue; + }); + setContractNotificationEnabled(newValue); + }, + secondary: const Icon(Icons.description), + ), ], ), ); diff --git a/app/lib/services/background_service.dart b/app/lib/services/background_service.dart index 2d6261b6..5519c8dc 100644 --- a/app/lib/services/background_service.dart +++ b/app/lib/services/background_service.dart @@ -1,6 +1,9 @@ import 'package:background_fetch/background_fetch.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:gridproxy_client/models/contracts.dart'; import 'package:threebotlogin/apps/notifications/notifications_user_data.dart'; import 'package:threebotlogin/models/farm.dart'; +import 'package:threebotlogin/services/contract_check_service.dart'; import 'package:threebotlogin/services/nodes_check_service.dart'; import 'notification_service.dart'; import 'package:threebotlogin/helpers/logger.dart'; @@ -9,29 +12,91 @@ void backgroundFetchHeadlessTask(HeadlessTask task) async { final String taskId = task.taskId; final bool timeout = task.timeout; - if (timeout) { - BackgroundFetch.finish(taskId); - return; - } - final bool notificationsEnabled = await isNodeStatusNotificationEnabled(); + final container = ProviderContainer(); - logger.i( - 'Background Fetch Headless Task: $taskId, Notifications Enabled: $notificationsEnabled'); + try { + if (timeout) { + logger.w('[BackgroundFetch] Task timed out: $taskId'); + BackgroundFetch.finish(taskId); + return; + } - if (!notificationsEnabled) { logger.i( - '[BackgroundFetch] Node status notifications are disabled. Finishing task: $taskId'); + '[BackgroundFetch] Headless Task: $taskId started. Time: ${DateTime.now()}'); + + // Run contract and node checks concurrently + await Future.wait([ + _checkContractsAndNotify(container, taskId), + _checkNodesAndNotify(taskId), + ]); + } catch (e, stack) { + logger.e('[BackgroundFetch] Error during task $taskId: $e', + error: e, stackTrace: stack); + } finally { + container.dispose(); BackgroundFetch.finish(taskId); - return; + logger.i('[BackgroundFetch] Task finished: $taskId'); } - await checkNodeStatus(taskId); } -Future checkNodeStatus(String taskId) async { +Future _checkContractsAndNotify( + ProviderContainer container, String taskId) async { try { - final offlineNodes = await NodeCheckService.pingNodesInBackground(); + final List allContractsInGracePeriod = await container + .read(contractCheckServiceProvider) + .checkContractsState(); + + if (allContractsInGracePeriod.isNotEmpty) { + final bool contractNotificationsEnabled = + await isContractNotificationEnabled(); + logger.i( + '[ContractsCheck] Contracts in grace period: ${allContractsInGracePeriod.length}. Contract Notifications enabled: $contractNotificationsEnabled'); + + if (contractNotificationsEnabled) { + String notificationBody = + 'You have ${allContractsInGracePeriod.length} contract(s) in grace period.'; + final String contractIds = + allContractsInGracePeriod.map((c) => c.contract_id).join(', '); + notificationBody += '\nContract IDs: $contractIds'; + + await NotificationService().showNotification( + id: 'contract_grace_period', + title: 'Contract Grace Period Alert! ⏳', + body: notificationBody, + groupKey: 'contract_alerts', + ); + } + } + } catch (e, stack) { + logger.e( + '[ContractsCheck] Error during contracts check for task $taskId: $e', + error: e, + stackTrace: stack); + rethrow; + } +} + +Future _checkNodesAndNotify(String taskId) async { + try { + final bool nodeNotificationsEnabled = + await isNodeStatusNotificationEnabled(); + logger.i( + '[NodesCheck] Node Notifications Enabled: $nodeNotificationsEnabled for task $taskId'); + + if (!nodeNotificationsEnabled) { + logger.i( + '[NodesCheck] Node notifications are disabled by user setting. Exiting _checkNodesAndNotify for task $taskId.'); + return; + } - if (offlineNodes.isEmpty) return; + final offlineNodes = await NodeCheckService.pingNodesInBackground(); + if (offlineNodes.isEmpty) { + logger.i( + '[NodesCheck] No raw offline nodes found from pingNodesInBackground(). Exiting _checkNodesAndNotify for task $taskId.'); + return; + } + logger.i( + '[NodesCheck] Found ${offlineNodes.length} raw offline nodes for task $taskId.'); final StringBuffer bodyBuffer = StringBuffer(); final List nodesToNotify = []; @@ -43,17 +108,16 @@ Future checkNodeStatus(String taskId) async { for (final node in offlineNodes) { final nodeUpdatedAtMs = node.updatedAt! * 1000; + // Filter out nodes updated more than 7 days ago if (nodeUpdatedAtMs <= sevenDaysAgoTimestampMs) continue; final downtime = Duration(milliseconds: nowInMs - nodeUpdatedAtMs); - final checkInterval = _getCheckInterval(downtime); bool passesIntervalCheck = false; if (downtime.inMinutes > 0 && checkInterval.inMinutes > 0) { passesIntervalCheck = downtime.inMinutes % checkInterval.inMinutes < 15; } - if (passesIntervalCheck) { nodesToNotify.add(node); final formattedDowntime = _formatDowntime(downtime); @@ -65,17 +129,17 @@ Future checkNodeStatus(String taskId) async { if (nodesToNotify.isEmpty) return; await NotificationService().showNotification( - id: nodesToNotify.hashCode, + id: 'offline_nodes_alert', title: nodesToNotify.length == 1 ? 'Node Alert 🚨' : '${nodesToNotify.length} Nodes Offline 🚨', body: bodyBuffer.toString().trim(), groupKey: 'offline_nodes', ); - } catch (e) { - logger.e('Error in checkNodeStatus for task $taskId: $e'); - } finally { - BackgroundFetch.finish(taskId); + } catch (e, stack) { + logger.e('[NodesCheck] Error in node check for task $taskId: $e', + error: e, stackTrace: stack); + rethrow; } } @@ -98,7 +162,9 @@ String _formatDowntime(Duration duration) { return '${duration.inDays} days'; } else if (duration.inHours > 0) { return '${duration.inHours} hours'; - } else { + } else if (duration.inMinutes > 0) { return '${duration.inMinutes} minutes'; + } else { + return '${duration.inSeconds} seconds'; } } diff --git a/app/lib/services/contract_check_service.dart b/app/lib/services/contract_check_service.dart new file mode 100644 index 00000000..416a1160 --- /dev/null +++ b/app/lib/services/contract_check_service.dart @@ -0,0 +1,42 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:gridproxy_client/models/contracts.dart'; +import 'package:threebotlogin/helpers/logger.dart'; +import 'package:threebotlogin/models/wallet.dart'; +import 'package:threebotlogin/providers/wallets_provider.dart'; +import 'package:threebotlogin/services/gridproxy_service.dart'; +import 'package:threebotlogin/services/tfchain_service.dart'; + +final contractCheckServiceProvider = Provider((ref) { + return ContractCheckService(ref); +}); + +class ContractCheckService { + final Ref _ref; + + ContractCheckService(this._ref); + + Future> checkContractsState() async { + List allContracts = []; + + try { + final walletsNotifierInstance = _ref.read(walletsNotifier.notifier); + await walletsNotifierInstance.waitUntilListed(); + + final List wallets = _ref.read(walletsNotifier); + if (wallets.isEmpty) return []; + + for (final w in wallets) { + final twinId = await getTwinId(w.tfchainSecret); + if (twinId != 0) { + List contracts = + await getGracePeriodContractsByTwinId(twinId); + allContracts.addAll(contracts); + } + } + return allContracts; + } catch (e) { + logger.e('[ContractCheckService] Error checking contract state: $e'); + return []; + } + } +} diff --git a/app/lib/services/gridproxy_service.dart b/app/lib/services/gridproxy_service.dart index 1b215d32..f6626457 100644 --- a/app/lib/services/gridproxy_service.dart +++ b/app/lib/services/gridproxy_service.dart @@ -1,4 +1,5 @@ import 'package:gridproxy_client/gridproxy_client.dart'; +import 'package:gridproxy_client/models/contracts.dart'; import 'package:gridproxy_client/models/nodes.dart'; import 'package:threebotlogin/helpers/globals.dart'; import 'package:threebotlogin/main.reflectable.dart'; @@ -65,3 +66,16 @@ Future isFarmNameAvailable(String name) async { throw Exception('Failed to get farms due to $e'); } } + +Future> getGracePeriodContractsByTwinId(int twinId) async { + try { + initializeReflectable(); + final gridproxyUrl = Globals().gridproxyUrl; + GridProxyClient client = GridProxyClient(gridproxyUrl); + final contracts = + await client.contracts.list(ContractInfoQueryParams(twin_id: twinId, state: ContractState.GracePeriod)); + return contracts; + } catch (e) { + throw Exception('Failed to get contracts due to $e'); + } +} diff --git a/app/lib/services/notification_service.dart b/app/lib/services/notification_service.dart index 2157d626..e9d827fe 100644 --- a/app/lib/services/notification_service.dart +++ b/app/lib/services/notification_service.dart @@ -1,138 +1,211 @@ -import 'dart:convert'; -import 'package:flutter/material.dart'; import 'package:flutter_local_notifications/flutter_local_notifications.dart'; -import 'package:threebotlogin/helpers/logger.dart'; import 'package:threebotlogin/main.dart'; +import 'package:threebotlogin/helpers/logger.dart'; +import 'package:flutter/material.dart'; +import 'dart:convert'; + import 'package:threebotlogin/widgets/custom_dialog.dart'; -class NotificationService { - static final NotificationService _instance = NotificationService._internal(); - factory NotificationService() => _instance; - NotificationService._internal(); +@pragma('vm:entry-point') +void notificationTapBackground(NotificationResponse notificationResponse) { + if (notificationResponse.payload != null) { + NotificationService._handleNotificationTapStatic( + notificationResponse.payload!); + } +} - final notificationsPlugin = FlutterLocalNotificationsPlugin(); - bool _isInitialized = false; +class NotificationService { + static final FlutterLocalNotificationsPlugin + _flutterLocalNotificationsPlugin = FlutterLocalNotificationsPlugin(); Future initNotification() async { - if (_isInitialized) return; - - final NotificationAppLaunchDetails? launchDetails = - await notificationsPlugin.getNotificationAppLaunchDetails(); + const AndroidInitializationSettings initializationSettingsAndroid = + AndroidInitializationSettings('@mipmap/launcher_icon'); - if (launchDetails?.didNotificationLaunchApp ?? false) { - _handleNotificationTap(launchDetails?.notificationResponse); - } - - const initSettingsAndroid = - AndroidInitializationSettings('@mipmap/ic_launcher'); - const initSettingsIOS = DarwinInitializationSettings( + const DarwinInitializationSettings initializationSettingsIOS = + DarwinInitializationSettings( requestAlertPermission: true, requestBadgePermission: true, requestSoundPermission: true, ); - const initSettings = InitializationSettings( - android: initSettingsAndroid, - iOS: initSettingsIOS, + const InitializationSettings initializationSettings = + InitializationSettings( + android: initializationSettingsAndroid, + iOS: initializationSettingsIOS, ); - await notificationsPlugin.initialize( - initSettings, - onDidReceiveNotificationResponse: (details) => - _handleNotificationTap(details), + await _flutterLocalNotificationsPlugin.initialize( + initializationSettings, + onDidReceiveNotificationResponse: + (NotificationResponse notificationResponse) async { + if (notificationResponse.payload != null) { + // Ensure the app is brought to foreground + await _flutterLocalNotificationsPlugin.cancelAll(); + NotificationService._handleNotificationTapStatic( + notificationResponse.payload!); + } + }, + onDidReceiveBackgroundNotificationResponse: notificationTapBackground, ); - await notificationsPlugin - .resolvePlatformSpecificImplementation< - AndroidFlutterLocalNotificationsPlugin>() - ?.requestNotificationsPermission(); + } - _isInitialized = true; + static AndroidNotificationDetails _androidNotificationDetails( + String groupKey) { + return AndroidNotificationDetails( + 'channel ID', + 'channel name', + channelDescription: 'channel description', + importance: Importance.max, + priority: Priority.high, + groupKey: groupKey, + setAsGroupSummary: false, + ); + } + + static DarwinNotificationDetails _iOSNotificationDetails() { + return const DarwinNotificationDetails( + presentAlert: true, + presentBadge: true, + presentSound: true, + ); } Future showNotification({ - int id = 0, + required String id, required String title, required String body, - String? groupKey, - bool isGroupSummary = false, + required String groupKey, }) async { + final int notificationId = id.hashCode; + await _flutterLocalNotificationsPlugin.show( + notificationId, + title, + body, + NotificationDetails( + android: _androidNotificationDetails(groupKey), + iOS: _iOSNotificationDetails(), + ), + payload: jsonEncode({ + 'title': title, + 'body': body, + 'groupKey': groupKey, + }), + ); + logger.i( + '[NotificationService] Notification shown: ID $notificationId, Title: "$title"'); + } + + static void _handleNotificationTapStatic(String payload) async { + logger.i( + '[NotificationService Static] Notification tapped, payload: $payload'); + try { - if (!_isInitialized) { - await initNotification(); + final Map data = jsonDecode(payload); + final String groupKey = data['groupKey'] as String; + final String title = data['title'] as String; + final String body = data['body'] as String; + + logger.i( + '[NotificationService Static] Processing tapped notification with groupKey: $groupKey'); + + // Wait for app to be in foreground if needed + await Future.delayed(const Duration(milliseconds: 500)); + + if (navigatorKey.currentContext == null) { + logger.w( + '[NotificationService Static] Context is null, retrying in 1 second...'); + await Future.delayed(const Duration(seconds: 1)); } - final androidDetails = AndroidNotificationDetails( - 'node_status_channel', - 'Node Status', - channelDescription: 'Notify user when node goes offline', - importance: Importance.max, - priority: Priority.high, - groupKey: groupKey, - ); - - final iosDetails = DarwinNotificationDetails( - presentAlert: true, - presentBadge: true, - presentSound: true, - threadIdentifier: groupKey, - interruptionLevel: InterruptionLevel.timeSensitive); - - final notificationDetails = NotificationDetails( - android: androidDetails, - iOS: iosDetails, - ); - - final payload = json.encode({ - 'title': title, - 'body': body, - }); - - await notificationsPlugin.show( - id, - title, - body, - notificationDetails, - payload: payload, - ); - } catch (e) { - logger.e('[NotificationService] Failed to show notification: $e'); + if (navigatorKey.currentContext != null) { + if (groupKey == 'contract_alerts') { + await NotificationService.showContractAlertDialog( + title: title, + body: body, + ); + } else if (groupKey == 'offline_nodes') { + await NotificationService.showNodeAlertDialog( + title: title, + body: body, + ); + } + } else { + logger.w( + '[NotificationService Static] Could not get valid context after retry'); + } + } catch (e, stack) { + logger.e( + '[NotificationService Static] Error handling notification tap: $e', + error: e, + stackTrace: stack); } } - void _handleNotificationTap(NotificationResponse? response) { - if (response?.payload != null) { - final Map payload = json.decode(response!.payload!); - showNodeStatusDialog( - navigatorKey.currentContext!, - payload['title'], - payload['body'], - ); + static Future showContractAlertDialog({ + required String title, + required String body, + }) async { + if (navigatorKey.currentContext == null) { + logger.w( + '[NotificationService Static] Cannot show contract alert dialog, navigatorKey.currentContext is null.'); + return; } + + await NotificationService._showAppDialog( + navigatorKey.currentContext!, + title: title, + content: Text(body), + icon: Icons.assignment_outlined, + ); + logger.i('[NotificationService Static] Dialog shown: $title'); } - void showNodeStatusDialog(BuildContext context, String title, String body) { - try { - if (!context.mounted) return; - - showDialog( - context: context, - builder: (BuildContext context) => CustomDialog( - type: DialogType.Warning, - image: Icons.warning, - title: title, - description: body, - actions: [ - TextButton( - child: const Text('Close'), - onPressed: () { - Navigator.pop(context); - }, - ), - ], - ), - ); - } catch (e) { - logger.e('[NotificationService] Failed to show dialog: $e'); + static Future showNodeAlertDialog({ + required String title, + required String body, + }) async { + if (navigatorKey.currentContext == null) { + logger.w( + '[NotificationService Static] Cannot show node alert dialog, navigatorKey.currentContext is null.'); + return; } + + await NotificationService._showAppDialog( + navigatorKey.currentContext!, + title: title, + content: Text(body), + icon: Icons.power_off_outlined, + ); + logger.i('[NotificationService Static] Dialog shown: $title'); + } + + static Future _showAppDialog( + BuildContext context, { + required String title, + required Text content, + required IconData icon, + }) { + return showDialog( + context: context, + builder: (BuildContext context) { + return PopScope( + canPop: false, + child: CustomDialog( + image: icon, + title: title, + description: content.data, + actions: [ + TextButton( + child: const Text('Close'), + onPressed: () { + Navigator.pop(context); + }, + ), + ], + ), + ); + }, + ); } }