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/screens/registered_screen.dart b/app/lib/screens/registered_screen.dart index c443412f..7374759a 100644 --- a/app/lib/screens/registered_screen.dart +++ b/app/lib/screens/registered_screen.dart @@ -52,7 +52,7 @@ class _RegisteredScreenState extends State ), ), Container( - padding: const EdgeInsets.only(left: 10, right: 10, top: 50), + padding: const EdgeInsets.only(left: 10, right: 10, top: 10), height: MediaQuery.of(context).size.height * 0.6, width: MediaQuery.of(context).size.width, child: Column( @@ -75,8 +75,8 @@ class _RegisteredScreenState extends State 'Your portal to ThreeFold: access your wallets, your digital identity, your farms, and ThreeFold updates with ease.'), ]), ), - ), - const Spacer(), + ), + const Spacer(flex: 1), const Row( crossAxisAlignment: CrossAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center, @@ -121,13 +121,20 @@ class _RegisteredScreenState extends State name: 'Identity', icon: Icons.person, pageNumber: 5), HomeCardWidget( name: 'Settings', icon: Icons.settings, pageNumber: 7), - // HomeCardWidget( - // name: 'Notifications', - // icon: Icons.notifications, - // pageNumber: 9), ], ), - const SizedBox(height: 40), + const Row( + crossAxisAlignment: CrossAxisAlignment.center, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + HomeCardWidget( + name: 'Notifications', + icon: Icons.notifications, + pageNumber: 10, + fullWidth: true), + ], + ), + const Spacer(flex: 1), const Row( children: [Spacer(), CrispChatbot(), SizedBox(width: 20)], ) diff --git a/app/lib/services/background_service.dart b/app/lib/services/background_service.dart index ed2b9e0b..fa56f972 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'; @@ -13,6 +16,7 @@ void backgroundFetchHeadlessTask(HeadlessTask task) async { BackgroundFetch.finish(taskId); return; } + final bool notificationsEnabled = await isNodeStatusNotificationEnabled(); logger.i( @@ -24,7 +28,20 @@ void backgroundFetchHeadlessTask(HeadlessTask task) async { BackgroundFetch.finish(taskId); return; } - await checkNodeStatus(taskId); + + final container = ProviderContainer(); + + try { + await checkNodeStatus(taskId); + await checkContractsAndNotify(container, taskId); + + logger.i('[BackgroundFetch] Background tasks completed successfully for task: $taskId'); + } catch (e) { + logger.e('[BackgroundFetch] Error in background tasks for task $taskId: $e'); + } finally { + container.dispose(); + BackgroundFetch.finish(taskId); + } } Future checkNodeStatus(String taskId) async { @@ -88,12 +105,56 @@ Future checkNodeStatus(String taskId) async { : '${nodesToNotify.length} Nodes Offline 🚨', body: bodyBuffer.toString().trim(), groupKey: 'offline_nodes', + type: NotificationType.nodeStatus, + additionalData: { + 'nodeCount': nodesToNotify.length, + 'nodeIds': nodesToNotify.map((n) => n.nodeId).toList(), + }, ); } catch (e) { logger.e('[BackgroundFetch] Error in checkNodeStatus for task $taskId: $e'); - } finally { - logger.i('[BackgroundFetch] Finishing task $taskId'); - BackgroundFetch.finish(taskId); + } +} + +Future checkContractsAndNotify( + ProviderContainer container, String taskId) async { + try { + 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'.hashCode, + title: 'Contract Grace Period Alert! ⏳', + body: notificationBody, + groupKey: 'contract_alerts', + type: NotificationType.contractAlert, + additionalData: { + 'contractCount': allContractsInGracePeriod.length, + 'contractIds': allContractsInGracePeriod.map((c) => c.contract_id).toList(), + }, + ); + } + } + } catch (e, stack) { + logger.e( + '[ContractsCheck] Error during contracts check for task $taskId: $e', + error: e, + stackTrace: stack); + rethrow; } } @@ -119,4 +180,4 @@ String _formatDowntime(Duration duration) { } else { return '${duration.inMinutes} ${duration.inMinutes == 1 ? 'minute' : 'minutes'}'; } -} +} \ No newline at end of file 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 59200bb4..f061d00a 100644 --- a/app/lib/services/notification_service.dart +++ b/app/lib/services/notification_service.dart @@ -6,6 +6,18 @@ import 'package:threebotlogin/helpers/logger.dart'; import 'package:threebotlogin/main.dart'; import 'package:threebotlogin/widgets/custom_dialog.dart'; +enum NotificationType { + nodeStatus, + contractAlert, + general, +} + +class NotificationChannels { + static const String nodeStatus = 'node_status_channel'; + static const String contractAlert = 'contract_alert_channel'; + static const String general = 'general_channel'; +} + @pragma('vm:entry-point') Future onActionReceivedMethod(ReceivedAction receivedAction) async { logger.i('[NotificationService] Action received: ${receivedAction.title}'); @@ -39,7 +51,7 @@ class NotificationService { null, [ NotificationChannel( - channelKey: 'node_status_channel', + channelKey: NotificationChannels.nodeStatus, channelName: 'Node Status', channelDescription: 'Notify user when node goes offline', importance: NotificationImportance.High, @@ -48,6 +60,26 @@ class NotificationService { enableLights: true, criticalAlerts: true, ), + NotificationChannel( + channelKey: NotificationChannels.contractAlert, + channelName: 'Contract Alerts', + channelDescription: 'Notify user about contract status changes', + importance: NotificationImportance.High, + channelShowBadge: true, + enableVibration: true, + enableLights: true, + criticalAlerts: true, + ), + NotificationChannel( + channelKey: NotificationChannels.general, + channelName: 'General Notifications', + channelDescription: 'General app notifications', + importance: NotificationImportance.Default, + channelShowBadge: true, + enableVibration: false, + enableLights: false, + criticalAlerts: false, + ), ], debug: true, ); @@ -74,32 +106,39 @@ class NotificationService { required String body, String? groupKey, bool isGroupSummary = false, + NotificationType type = NotificationType.general, + Map? additionalData, }) async { try { if (!_isInitialized) { await initNotification(); } + final String channelKey = _getChannelKey(type); + final payload = json.encode({ 'title': title, 'body': body, + 'type': type.name, + 'additionalData': additionalData ?? {}, }); _notificationCount++; await _updateBadgeCount(); + final bool isCritical = type == NotificationType.nodeStatus || type == NotificationType.contractAlert; + await AwesomeNotifications().createNotification( content: NotificationContent( id: id, - channelKey: 'node_status_channel', + channelKey: channelKey, title: title, body: body, payload: {'data': payload}, notificationLayout: NotificationLayout.Default, category: NotificationCategory.Message, - wakeUpScreen: true, - fullScreenIntent: true, - criticalAlert: true, + wakeUpScreen: isCritical, + criticalAlert: isCritical, autoDismissible: true, displayOnForeground: true, displayOnBackground: true, @@ -120,6 +159,17 @@ class NotificationService { } } + String _getChannelKey(NotificationType type) { + switch (type) { + case NotificationType.nodeStatus: + return NotificationChannels.nodeStatus; + case NotificationType.contractAlert: + return NotificationChannels.contractAlert; + case NotificationType.general: + return NotificationChannels.general; + } + } + Future _updateBadgeCount() async { try { await AwesomeNotifications().setGlobalBadgeCounter(_notificationCount); @@ -170,17 +220,21 @@ class NotificationService { try { logger.i('[NotificationService] Attempting to show dialog...'); + + final notificationType = _pendingPayload!['type'] ?? 'general'; + final dialogConfig = _getDialogConfig(notificationType); + showDialog( context: navigatorKey.currentContext!, barrierDismissible: false, - routeSettings: const RouteSettings(name: 'node_status_dialog'), + routeSettings: RouteSettings(name: '${notificationType}_dialog'), builder: (BuildContext context) { logger.i('[NotificationService] Building dialog widget'); return WillPopScope( onWillPop: () async => false, child: CustomDialog( - type: DialogType.Warning, - image: Icons.warning, + type: dialogConfig.dialogType, + image: dialogConfig.icon, title: _pendingPayload!['title'], description: _pendingPayload!['body'], actions: [ @@ -211,6 +265,27 @@ class NotificationService { }); } + _DialogConfig _getDialogConfig(String notificationType) { + switch (notificationType) { + case 'nodeStatus': + return _DialogConfig( + dialogType: DialogType.Warning, + icon: Icons.warning, + ); + case 'contractAlert': + return _DialogConfig( + dialogType: DialogType.Warning, + icon: Icons.schedule, + ); + case 'general': + default: + return _DialogConfig( + dialogType: DialogType.Info, + icon: Icons.info, + ); + } + } + Future _decrementNotificationCount() async { if (_notificationCount > 0) { _notificationCount--; @@ -236,3 +311,13 @@ class NotificationService { _decrementNotificationCount(); } } + +class _DialogConfig { + final DialogType dialogType; + final IconData icon; + + _DialogConfig({ + required this.dialogType, + required this.icon, + }); +} \ No newline at end of file