Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 16 additions & 13 deletions app/lib/apps/notifications/notifications_user_data.dart
Original file line number Diff line number Diff line change
@@ -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<List<String>?> getNotificationSettings() async {
final prefs = await SharedPreferences.getInstance();
Expand All @@ -10,19 +11,21 @@ Future<List<String>?> getNotificationSettings() async {
}

Future<bool> 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<void> 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<bool> isContractNotificationEnabled() async {
final prefs = await SharedPreferences.getInstance();
return prefs.getBool(_contractNotificationsEnabledKey) ?? true;
}

Future<void> setContractNotificationEnabled(bool enabled) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setBool(_contractNotificationsEnabledKey, enabled);
}
32 changes: 15 additions & 17 deletions app/lib/main.dart
Original file line number Diff line number Diff line change
Expand Up @@ -46,19 +46,6 @@ Future<void> 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,
Expand All @@ -71,14 +58,25 @@ Future<void> 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<void> setGlobalValues() async {
Expand Down Expand Up @@ -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(
Expand All @@ -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(
Expand Down
24 changes: 20 additions & 4 deletions app/lib/screens/notifications_screen.dart
Original file line number Diff line number Diff line change
Expand Up @@ -12,17 +12,21 @@ class NotificationsScreen extends StatefulWidget {
class _NotificationsScreenState extends State<NotificationsScreen> {
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;
});
}
Expand Down Expand Up @@ -69,6 +73,18 @@ class _NotificationsScreenState extends State<NotificationsScreen> {
},
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),
),
],
),
);
Expand Down
23 changes: 15 additions & 8 deletions app/lib/screens/registered_screen.dart
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ class _RegisteredScreenState extends State<RegisteredScreen>
),
),
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(
Expand All @@ -75,8 +75,8 @@ class _RegisteredScreenState extends State<RegisteredScreen>
'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,
Expand Down Expand Up @@ -121,13 +121,20 @@ class _RegisteredScreenState extends State<RegisteredScreen>
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)],
)
Expand Down
71 changes: 66 additions & 5 deletions app/lib/services/background_service.dart
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -13,6 +16,7 @@ void backgroundFetchHeadlessTask(HeadlessTask task) async {
BackgroundFetch.finish(taskId);
return;
}

final bool notificationsEnabled = await isNodeStatusNotificationEnabled();

logger.i(
Expand All @@ -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<void> checkNodeStatus(String taskId) async {
Expand Down Expand Up @@ -88,12 +105,56 @@ Future<void> 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<void> checkContractsAndNotify(
ProviderContainer container, String taskId) async {
try {
final List<ContractInfo> 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) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  • I think we may include more info about contract to be user-friendly, like farm or node. what do u think ?

Copy link
Contributor Author

@zaelgohary zaelgohary Jun 19, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Surely can be done but there's a contract page where users can check all their contracts details. Maybe we can refer to the contracts page but after its PR is merged? Or redirect to the contract details page on notification click?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's a good idae

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I opened the issue below. If all works well, we can approve this PR.

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;
}
}

Expand All @@ -119,4 +180,4 @@ String _formatDowntime(Duration duration) {
} else {
return '${duration.inMinutes} ${duration.inMinutes == 1 ? 'minute' : 'minutes'}';
}
}
}
42 changes: 42 additions & 0 deletions app/lib/services/contract_check_service.dart
Original file line number Diff line number Diff line change
@@ -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<ContractCheckService>((ref) {
return ContractCheckService(ref);
});

class ContractCheckService {
final Ref _ref;

ContractCheckService(this._ref);

Future<List<ContractInfo>> checkContractsState() async {
List<ContractInfo> allContracts = [];

try {
final walletsNotifierInstance = _ref.read(walletsNotifier.notifier);
await walletsNotifierInstance.waitUntilListed();

final List<Wallet> wallets = _ref.read(walletsNotifier);
if (wallets.isEmpty) return [];

for (final w in wallets) {
final twinId = await getTwinId(w.tfchainSecret);
if (twinId != 0) {
List<ContractInfo> contracts =
await getGracePeriodContractsByTwinId(twinId);
allContracts.addAll(contracts);
}
}
return allContracts;
} catch (e) {
logger.e('[ContractCheckService] Error checking contract state: $e');
return [];
}
}
}
14 changes: 14 additions & 0 deletions app/lib/services/gridproxy_service.dart
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -65,3 +66,16 @@ Future<bool> isFarmNameAvailable(String name) async {
throw Exception('Failed to get farms due to $e');
}
}

Future<List<ContractInfo>> 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');
}
}
Loading