diff --git a/flutter_sdk/nitroping/CHANGELOG.md b/flutter_sdk/nitroping/CHANGELOG.md new file mode 100644 index 0000000..bd0c2ed --- /dev/null +++ b/flutter_sdk/nitroping/CHANGELOG.md @@ -0,0 +1,10 @@ +# Changelog + +## 0.0.1 + +- Initial release +- Device registration for iOS and Android +- Push notification handling with firebase_messaging +- Event tracking (delivered, opened, clicked) +- Automatic token refresh handling +- Debug logging support diff --git a/flutter_sdk/nitroping/LICENSE b/flutter_sdk/nitroping/LICENSE new file mode 100644 index 0000000..17c1722 --- /dev/null +++ b/flutter_sdk/nitroping/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024 NitroPing + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/flutter_sdk/nitroping/README.md b/flutter_sdk/nitroping/README.md new file mode 100644 index 0000000..528ed7f --- /dev/null +++ b/flutter_sdk/nitroping/README.md @@ -0,0 +1,234 @@ +# NitroPing Flutter SDK + +Flutter SDK for [NitroPing](https://github.com/productdevbook/nitroping) self-hosted push notification service. + +## Features + +- 📱 **Multi-platform**: iOS (APNs) and Android (FCM) support +- 🔔 **Easy integration**: Simple API for device registration +- 📊 **Event tracking**: Track delivered, opened, and clicked events +- 🔄 **Auto token refresh**: Automatically handles FCM token updates +- 🛡️ **Type-safe**: Full Dart type safety + +## Installation + +Add to your `pubspec.yaml`: + +```yaml +dependencies: + nitroping: + git: + url: https://github.com/productdevbook/nitroping.git + path: flutter_sdk/nitroping +``` + +## Requirements + +- Flutter 3.27.0+ +- Dart 3.6.0+ +- Firebase project configured for your app + +## Quick Start + +### 1. Configure Firebase + +Follow [Firebase Flutter setup](https://firebase.google.com/docs/flutter/setup) for your platform. + +### 2. Initialize NitroPing + +```dart +import 'package:firebase_core/firebase_core.dart'; +import 'package:firebase_messaging/firebase_messaging.dart'; +import 'package:nitroping/nitroping.dart'; + +void main() async { + WidgetsFlutterBinding.ensureInitialized(); + await Firebase.initializeApp(); + + // Configure NitroPing + await NitroPingClient.configure( + appId: 'your-nitroping-app-id', + apiUrl: 'https://your-server.com/api/graphql', + debug: true, // Enable debug logging + ); + + runApp(MyApp()); +} +``` + +### 3. Register Device + +```dart +class _MyHomePageState extends State { + DeviceRegistration? _device; + + @override + void initState() { + super.initState(); + _registerDevice(); + } + + Future _registerDevice() async { + try { + final device = await NitroPingClient.instance.registerDevice( + userId: 'user-123', // optional + ); + setState(() => _device = device); + print('Registered: ${device.id}'); + } on NitroPingPermissionDeniedException { + print('Permission denied'); + } on NitroPingException catch (e) { + print('Error: ${e.message}'); + } + } +} +``` + +### 4. Handle Notifications + +```dart +@override +void initState() { + super.initState(); + + // Setup message handlers + NitroPingClient.instance.setupMessageHandlers( + onMessage: (message) { + print('Foreground notification: ${message.notification?.title}'); + }, + onMessageOpenedApp: (message) { + print('App opened from notification'); + }, + ); +} +``` + +## API Reference + +### NitroPingClient + +#### Configuration + +```dart +// Configure with individual parameters +await NitroPingClient.configure( + appId: 'your-app-id', + apiUrl: 'https://your-server.com/api/graphql', + userId: 'default-user-id', // optional + debug: false, +); + +// Or with config object +await NitroPingClient.configureWithConfig( + NitroPingConfig( + appId: 'your-app-id', + apiUrl: 'https://your-server.com/api/graphql', + ), +); + +// For local development +await NitroPingClient.configureWithConfig( + NitroPingConfig.localhost(appId: 'your-app-id'), +); +``` + +#### Device Registration + +```dart +// Register device for push notifications +final device = await NitroPingClient.instance.registerDevice( + userId: 'user-123', // optional +); + +// Update user ID later +await NitroPingClient.instance.updateUserId('new-user-id'); + +// Access registered device +final device = NitroPingClient.instance.device; +final token = NitroPingClient.instance.deviceToken; +``` + +#### Event Tracking + +```dart +// Track notification events manually +await NitroPingClient.instance.trackDelivered('notification-id'); +await NitroPingClient.instance.trackOpened('notification-id'); +await NitroPingClient.instance.trackClicked('notification-id', action: 'view'); +``` + +### Exceptions + +```dart +try { + await NitroPingClient.instance.registerDevice(); +} on NitroPingNotConfiguredException { + // SDK not configured +} on NitroPingPermissionDeniedException { + // User denied notification permission +} on NitroPingNetworkException catch (e) { + // Network error + print('Status: ${e.statusCode}'); +} on NitroPingGraphQLException catch (e) { + // GraphQL API error + print('Errors: ${e.errors}'); +} on NitroPingRegistrationException { + // Device registration failed +} +``` + +## Background Notifications + +For background notification handling, set up a top-level handler: + +```dart +// Must be top-level function +@pragma('vm:entry-point') +Future _firebaseMessagingBackgroundHandler(RemoteMessage message) async { + await Firebase.initializeApp(); + + // Re-configure NitroPing for background isolate + await NitroPingClient.configure( + appId: 'your-app-id', + apiUrl: 'https://your-server.com/api/graphql', + ); + + final notification = NitroPingClient.instance.handleNotification(message); + print('Background notification: ${notification.title}'); +} + +void main() async { + WidgetsFlutterBinding.ensureInitialized(); + await Firebase.initializeApp(); + + FirebaseMessaging.onBackgroundMessage(_firebaseMessagingBackgroundHandler); + + // ... rest of initialization +} +``` + +## Platform Setup + +### Android + +Add to `android/app/src/main/AndroidManifest.xml`: + +```xml + +``` + +### iOS + +Add to `ios/Runner/Info.plist`: + +```xml +UIBackgroundModes + + fetch + remote-notification + +``` + +## License + +MIT License - see [LICENSE](LICENSE) file. diff --git a/flutter_sdk/nitroping/analysis_options.yaml b/flutter_sdk/nitroping/analysis_options.yaml new file mode 100644 index 0000000..2e8835e --- /dev/null +++ b/flutter_sdk/nitroping/analysis_options.yaml @@ -0,0 +1,8 @@ +include: package:flutter_lints/flutter.yaml + +linter: + rules: + prefer_single_quotes: true + always_declare_return_types: true + prefer_final_locals: true + avoid_print: false # Allow print for debug logging diff --git a/flutter_sdk/nitroping/example/lib/main.dart b/flutter_sdk/nitroping/example/lib/main.dart new file mode 100644 index 0000000..5928f61 --- /dev/null +++ b/flutter_sdk/nitroping/example/lib/main.dart @@ -0,0 +1,234 @@ +import 'package:firebase_core/firebase_core.dart'; +import 'package:firebase_messaging/firebase_messaging.dart'; +import 'package:flutter/material.dart'; +import 'package:nitroping/nitroping.dart'; + +/// Background notification handler (must be top-level function) +@pragma('vm:entry-point') +Future _firebaseMessagingBackgroundHandler(RemoteMessage message) async { + await Firebase.initializeApp(); + debugPrint('Background notification: ${message.notification?.title}'); +} + +void main() async { + WidgetsFlutterBinding.ensureInitialized(); + + // Initialize Firebase + await Firebase.initializeApp(); + + // Setup background handler + FirebaseMessaging.onBackgroundMessage(_firebaseMessagingBackgroundHandler); + + // Configure NitroPing + await NitroPingClient.configure( + appId: 'your-nitroping-app-id', // Replace with your app ID + apiUrl: 'http://localhost:3000/api/graphql', // Replace with your server URL + debug: true, + ); + + runApp(const MyApp()); +} + +class MyApp extends StatelessWidget { + const MyApp({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp( + title: 'NitroPing Example', + theme: ThemeData( + colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple), + useMaterial3: true, + ), + home: const HomePage(), + ); + } +} + +class HomePage extends StatefulWidget { + const HomePage({super.key}); + + @override + State createState() => _HomePageState(); +} + +class _HomePageState extends State { + DeviceRegistration? _device; + String? _error; + bool _isLoading = false; + final List _notifications = []; + + @override + void initState() { + super.initState(); + _setupMessageHandlers(); + } + + void _setupMessageHandlers() { + NitroPingClient.instance.setupMessageHandlers( + onMessage: (message) { + setState(() { + _notifications.insert( + 0, + '📬 ${message.notification?.title ?? 'New notification'}', + ); + }); + }, + onMessageOpenedApp: (message) { + setState(() { + _notifications.insert( + 0, + '👆 Opened: ${message.notification?.title ?? 'Notification'}', + ); + }); + }, + ); + } + + Future _registerDevice() async { + setState(() { + _isLoading = true; + _error = null; + }); + + try { + final device = await NitroPingClient.instance.registerDevice( + userId: 'demo-user', + ); + + setState(() { + _device = device; + _isLoading = false; + }); + } on NitroPingPermissionDeniedException { + setState(() { + _error = 'Notification permission denied'; + _isLoading = false; + }); + } on NitroPingException catch (e) { + setState(() { + _error = e.message; + _isLoading = false; + }); + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text('NitroPing Demo'), + backgroundColor: Theme.of(context).colorScheme.inversePrimary, + ), + body: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // Status Card + Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Device Status', + style: Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(height: 8), + if (_device != null) ...[ + Text('✅ Registered'), + const SizedBox(height: 4), + Text( + 'ID: ${_device!.id}', + style: Theme.of(context).textTheme.bodySmall, + ), + Text( + 'Platform: ${_device!.platform}', + style: Theme.of(context).textTheme.bodySmall, + ), + ] else ...[ + const Text('❌ Not registered'), + ], + if (_error != null) ...[ + const SizedBox(height: 8), + Text( + '⚠️ $_error', + style: TextStyle(color: Colors.red[700]), + ), + ], + ], + ), + ), + ), + + const SizedBox(height: 16), + + // Register Button + ElevatedButton.icon( + onPressed: _isLoading ? null : _registerDevice, + icon: _isLoading + ? const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.notifications), + label: Text(_device == null ? 'Register Device' : 'Re-register'), + ), + + const SizedBox(height: 24), + + // Token Display + if (NitroPingClient.instance.deviceToken != null) ...[ + Text( + 'Push Token:', + style: Theme.of(context).textTheme.titleSmall, + ), + const SizedBox(height: 4), + Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: Colors.grey[200], + borderRadius: BorderRadius.circular(8), + ), + child: SelectableText( + NitroPingClient.instance.deviceToken!, + style: const TextStyle( + fontSize: 10, + fontFamily: 'monospace', + ), + ), + ), + ], + + const SizedBox(height: 24), + + // Notifications List + Text( + 'Received Notifications (${_notifications.length})', + style: Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(height: 8), + Expanded( + child: _notifications.isEmpty + ? const Center( + child: Text('No notifications yet'), + ) + : ListView.builder( + itemCount: _notifications.length, + itemBuilder: (context, index) { + return ListTile( + dense: true, + title: Text(_notifications[index]), + ); + }, + ), + ), + ], + ), + ), + ); + } +} diff --git a/flutter_sdk/nitroping/example/pubspec.yaml b/flutter_sdk/nitroping/example/pubspec.yaml new file mode 100644 index 0000000..fb7762c --- /dev/null +++ b/flutter_sdk/nitroping/example/pubspec.yaml @@ -0,0 +1,24 @@ +name: nitroping_example +description: Example app demonstrating NitroPing Flutter SDK +publish_to: 'none' + +version: 1.0.0+1 + +environment: + sdk: ^3.6.0 + +dependencies: + flutter: + sdk: flutter + firebase_core: ^3.12.0 + firebase_messaging: ^15.2.4 + nitroping: + path: ../ + +dev_dependencies: + flutter_test: + sdk: flutter + flutter_lints: ^5.0.0 + +flutter: + uses-material-design: true diff --git a/flutter_sdk/nitroping/lib/nitroping.dart b/flutter_sdk/nitroping/lib/nitroping.dart new file mode 100644 index 0000000..0e7376b --- /dev/null +++ b/flutter_sdk/nitroping/lib/nitroping.dart @@ -0,0 +1,11 @@ +/// Flutter SDK for NitroPing push notification service. +/// +/// Provides easy integration with NitroPing self-hosted push notification +/// service for iOS (APNs) and Android (FCM). +library; + +export 'src/client.dart'; +export 'src/exceptions.dart'; +export 'src/models/config.dart'; +export 'src/models/device.dart'; +export 'src/models/notification.dart'; diff --git a/flutter_sdk/nitroping/lib/src/client.dart b/flutter_sdk/nitroping/lib/src/client.dart new file mode 100644 index 0000000..be2d051 --- /dev/null +++ b/flutter_sdk/nitroping/lib/src/client.dart @@ -0,0 +1,359 @@ +import 'dart:convert'; +import 'dart:io'; +import 'package:firebase_messaging/firebase_messaging.dart'; +import 'package:flutter/foundation.dart'; +import 'exceptions.dart'; +import 'graphql_service.dart'; +import 'models/config.dart'; +import 'models/device.dart'; +import 'models/notification.dart'; + +/// Main client for NitroPing push notification service. +/// +/// Usage: +/// ```dart +/// // Initialize in your app +/// await NitroPingClient.configure( +/// appId: 'your-app-id', +/// apiUrl: 'https://your-server.com/api/graphql', +/// ); +/// +/// // Register device for push notifications +/// final device = await NitroPingClient.instance.registerDevice(); +/// ``` +class NitroPingClient { + static NitroPingClient? _instance; + + /// Get the singleton instance. + /// Throws [NitroPingNotConfiguredException] if not configured. + static NitroPingClient get instance { + if (_instance == null) { + throw const NitroPingNotConfiguredException(); + } + return _instance!; + } + + /// Check if the client is configured. + static bool get isConfigured => _instance != null; + + final NitroPingConfig _config; + final GraphQLService _graphql; + final FirebaseMessaging _messaging; + + String? _deviceToken; + DeviceRegistration? _device; + + NitroPingClient._({ + required NitroPingConfig config, + FirebaseMessaging? messaging, + }) : _config = config, + _messaging = messaging ?? FirebaseMessaging.instance, + _graphql = GraphQLService( + apiUrl: config.apiUrl, + debug: config.debug, + ); + + /// Configure the NitroPing client. + /// + /// Must be called before using [instance]. + /// Typically called after [Firebase.initializeApp()]. + static Future configure({ + required String appId, + required String apiUrl, + String? userId, + bool debug = false, + }) async { + _instance = NitroPingClient._( + config: NitroPingConfig( + appId: appId, + apiUrl: apiUrl, + userId: userId, + debug: debug, + ), + ); + + if (debug) { + debugPrint('✅ NitroPing: Configured with appId: $appId'); + } + } + + /// Configure with a [NitroPingConfig] object. + static Future configureWithConfig(NitroPingConfig config) async { + _instance = NitroPingClient._(config: config); + + if (config.debug) { + debugPrint('✅ NitroPing: Configured with appId: ${config.appId}'); + } + } + + /// The current device registration, if available. + DeviceRegistration? get device => _device; + + /// The current device token, if available. + String? get deviceToken => _deviceToken; + + /// The current configuration. + NitroPingConfig get config => _config; + + /// Request notification permissions and register device. + /// + /// Returns the [DeviceRegistration] from the server. + Future registerDevice({ + String? userId, + }) async { + // Request permission + final settings = await _messaging.requestPermission( + alert: true, + badge: true, + sound: true, + ); + + if (settings.authorizationStatus == AuthorizationStatus.denied) { + throw const NitroPingPermissionDeniedException(); + } + + if (_config.debug) { + debugPrint('✅ NitroPing: Notification permission granted'); + } + + // Get FCM token + _deviceToken = await _messaging.getToken(); + if (_deviceToken == null) { + throw const NitroPingRegistrationException('Failed to get push token'); + } + + if (_config.debug) { + debugPrint('📱 NitroPing: Token: $_deviceToken'); + } + + // Determine platform + final platform = _getPlatform(); + + // Register with NitroPing server + _device = await _graphql.registerDevice( + appId: _config.appId, + token: _deviceToken!, + platform: platform, + userId: userId ?? _config.userId, + metadata: _getDeviceMetadata(), + ); + + if (_config.debug) { + debugPrint('✅ NitroPing: Device registered: ${_device!.id}'); + } + + // Listen for token refresh + _messaging.onTokenRefresh.listen(_handleTokenRefresh); + + return _device!; + } + + /// Update user ID for the current device. + Future updateUserId(String userId) async { + if (_deviceToken == null) { + throw const NitroPingRegistrationException( + 'Device not registered. Call registerDevice() first.', + ); + } + + _device = await _graphql.registerDevice( + appId: _config.appId, + token: _deviceToken!, + platform: _getPlatform(), + userId: userId, + metadata: _getDeviceMetadata(), + ); + + if (_config.debug) { + debugPrint('✅ NitroPing: User ID updated to: $userId'); + } + + return _device!; + } + + /// Handle incoming notification and parse NitroPing data. + NitroPingNotification handleNotification(RemoteMessage message) { + final notification = NitroPingNotification.fromRemoteMessageData( + message.data, + ); + + if (_config.debug) { + debugPrint('📬 NitroPing: Received notification: $notification'); + } + + // Auto-track delivery if tracking info is available + if (notification.hasTrackingInfo && _device != null) { + trackDelivered(notification.notificationId!); + } + + return notification; + } + + /// Track that a notification was delivered. + Future trackDelivered(String notificationId) async { + if (_device == null) { + if (_config.debug) { + debugPrint('⚠️ NitroPing: Cannot track - device not registered'); + } + return; + } + + try { + await _graphql.trackDelivered( + notificationId: notificationId, + deviceId: _device!.id, + platform: _getPlatform(), + userAgent: _getUserAgent(), + appVersion: _getAppVersion(), + osVersion: _getOsVersion(), + ); + + if (_config.debug) { + debugPrint('✅ NitroPing: Tracked delivery for $notificationId'); + } + } catch (e) { + if (_config.debug) { + debugPrint('❌ NitroPing: Failed to track delivery: $e'); + } + } + } + + /// Track that a notification was opened. + Future trackOpened(String notificationId) async { + if (_device == null) return; + + try { + await _graphql.trackOpened( + notificationId: notificationId, + deviceId: _device!.id, + platform: _getPlatform(), + userAgent: _getUserAgent(), + appVersion: _getAppVersion(), + osVersion: _getOsVersion(), + ); + + if (_config.debug) { + debugPrint('✅ NitroPing: Tracked open for $notificationId'); + } + } catch (e) { + if (_config.debug) { + debugPrint('❌ NitroPing: Failed to track open: $e'); + } + } + } + + /// Track that a notification was clicked (with optional action). + Future trackClicked(String notificationId, {String? action}) async { + if (_device == null) return; + + try { + await _graphql.trackClicked( + notificationId: notificationId, + deviceId: _device!.id, + platform: _getPlatform(), + userAgent: _getUserAgent(), + appVersion: _getAppVersion(), + osVersion: _getOsVersion(), + action: action, + ); + + if (_config.debug) { + debugPrint('✅ NitroPing: Tracked click for $notificationId'); + } + } catch (e) { + if (_config.debug) { + debugPrint('❌ NitroPing: Failed to track click: $e'); + } + } + } + + /// Setup message handlers for foreground and background notifications. + void setupMessageHandlers({ + void Function(RemoteMessage)? onMessage, + void Function(RemoteMessage)? onMessageOpenedApp, + }) { + // Foreground messages + FirebaseMessaging.onMessage.listen((message) { + final notification = handleNotification(message); + onMessage?.call(message); + + if (notification.hasTrackingInfo) { + trackDelivered(notification.notificationId!); + } + }); + + // When app is opened from notification + FirebaseMessaging.onMessageOpenedApp.listen((message) { + final notification = handleNotification(message); + onMessageOpenedApp?.call(message); + + if (notification.hasTrackingInfo) { + trackOpened(notification.notificationId!); + } + }); + } + + void _handleTokenRefresh(String newToken) { + if (_config.debug) { + debugPrint('🔄 NitroPing: Token refreshed'); + } + + _deviceToken = newToken; + + // Re-register with new token + if (_device != null) { + _graphql.registerDevice( + appId: _config.appId, + token: newToken, + platform: _getPlatform(), + userId: _device!.userId, + metadata: _getDeviceMetadata(), + ).then((device) { + _device = device; + if (_config.debug) { + debugPrint('✅ NitroPing: Re-registered with new token'); + } + }).catchError((e) { + if (_config.debug) { + debugPrint('❌ NitroPing: Failed to re-register: $e'); + } + }); + } + } + + String _getPlatform() { + if (kIsWeb) return 'WEB'; + if (Platform.isIOS) return 'IOS'; + if (Platform.isAndroid) return 'ANDROID'; + return 'UNKNOWN'; + } + + String _getDeviceMetadata() { + final metadata = { + 'platform': _getPlatform(), + 'isWeb': kIsWeb, + }; + + if (!kIsWeb) { + metadata['osVersion'] = Platform.operatingSystemVersion; + metadata['locale'] = Platform.localeName; + } + + return jsonEncode(metadata); + } + + String _getUserAgent() { + if (kIsWeb) return 'NitroPing Flutter/Web'; + return 'NitroPing Flutter/${Platform.operatingSystem}'; + } + + String? _getAppVersion() { + // Could be enhanced with package_info_plus + return null; + } + + String? _getOsVersion() { + if (kIsWeb) return null; + return Platform.operatingSystemVersion; + } +} diff --git a/flutter_sdk/nitroping/lib/src/exceptions.dart b/flutter_sdk/nitroping/lib/src/exceptions.dart new file mode 100644 index 0000000..5088cc6 --- /dev/null +++ b/flutter_sdk/nitroping/lib/src/exceptions.dart @@ -0,0 +1,61 @@ +/// Base exception for NitroPing SDK errors. +class NitroPingException implements Exception { + final String message; + final String? code; + final int? statusCode; + + const NitroPingException( + this.message, { + this.code, + this.statusCode, + }); + + @override + String toString() => 'NitroPingException: $message (code: $code)'; +} + +/// Thrown when the SDK is not properly configured. +class NitroPingNotConfiguredException extends NitroPingException { + const NitroPingNotConfiguredException() + : super( + 'NitroPing SDK not configured. Call NitroPingClient.configure() first.', + code: 'NOT_CONFIGURED', + ); +} + +/// Thrown when notification permission is denied. +class NitroPingPermissionDeniedException extends NitroPingException { + const NitroPingPermissionDeniedException() + : super( + 'Push notification permission denied.', + code: 'PERMISSION_DENIED', + ); +} + +/// Thrown when a network request fails. +class NitroPingNetworkException extends NitroPingException { + final Object? originalError; + + const NitroPingNetworkException( + super.message, { + this.originalError, + super.statusCode, + }) : super(code: 'NETWORK_ERROR'); +} + +/// Thrown when the GraphQL API returns an error. +class NitroPingGraphQLException extends NitroPingException { + final List errors; + + NitroPingGraphQLException(this.errors) + : super( + errors.join(', '), + code: 'GRAPHQL_ERROR', + ); +} + +/// Thrown when device registration fails. +class NitroPingRegistrationException extends NitroPingException { + const NitroPingRegistrationException([super.message = 'Device registration failed']) + : super(code: 'REGISTRATION_FAILED'); +} diff --git a/flutter_sdk/nitroping/lib/src/graphql_service.dart b/flutter_sdk/nitroping/lib/src/graphql_service.dart new file mode 100644 index 0000000..c87374c --- /dev/null +++ b/flutter_sdk/nitroping/lib/src/graphql_service.dart @@ -0,0 +1,218 @@ +import 'dart:convert'; +import 'dart:io'; +import 'package:http/http.dart' as http; +import 'exceptions.dart'; +import 'models/device.dart'; + +/// Internal GraphQL service for NitroPing API communication. +class GraphQLService { + final String apiUrl; + final bool debug; + + const GraphQLService({ + required this.apiUrl, + this.debug = false, + }); + + /// Register a device with NitroPing server. + Future registerDevice({ + required String appId, + required String token, + required String platform, + String? userId, + String? metadata, + }) async { + const mutation = ''' + mutation RegisterDevice(\$input: RegisterDeviceInput!) { + registerDevice(input: \$input) { + id + appId + token + platform + userId + status + metadata + lastSeenAt + createdAt + updatedAt + } + } + '''; + + final variables = { + 'input': { + 'appId': appId, + 'token': token, + 'platform': platform, + if (userId != null) 'userId': userId, + if (metadata != null) 'metadata': metadata, + }, + }; + + final result = await _request(mutation, variables); + + final registerDevice = result['data']?['registerDevice']; + if (registerDevice == null) { + throw const NitroPingRegistrationException(); + } + + return DeviceRegistration.fromJson(registerDevice as Map); + } + + /// Track notification delivered event. + Future trackDelivered({ + required String notificationId, + required String deviceId, + required String platform, + String? userAgent, + String? appVersion, + String? osVersion, + }) async { + await _trackEvent( + mutationName: 'trackNotificationDelivered', + operationName: 'TrackNotificationDelivered', + notificationId: notificationId, + deviceId: deviceId, + platform: platform, + userAgent: userAgent, + appVersion: appVersion, + osVersion: osVersion, + ); + } + + /// Track notification opened event. + Future trackOpened({ + required String notificationId, + required String deviceId, + required String platform, + String? userAgent, + String? appVersion, + String? osVersion, + }) async { + await _trackEvent( + mutationName: 'trackNotificationOpened', + operationName: 'TrackNotificationOpened', + notificationId: notificationId, + deviceId: deviceId, + platform: platform, + userAgent: userAgent, + appVersion: appVersion, + osVersion: osVersion, + ); + } + + /// Track notification clicked event. + Future trackClicked({ + required String notificationId, + required String deviceId, + required String platform, + String? userAgent, + String? appVersion, + String? osVersion, + String? action, + }) async { + await _trackEvent( + mutationName: 'trackNotificationClicked', + operationName: 'TrackNotificationClicked', + notificationId: notificationId, + deviceId: deviceId, + platform: platform, + userAgent: userAgent, + appVersion: appVersion, + osVersion: osVersion, + ); + } + + Future _trackEvent({ + required String mutationName, + required String operationName, + required String notificationId, + required String deviceId, + required String platform, + String? userAgent, + String? appVersion, + String? osVersion, + }) async { + final mutation = ''' + mutation $operationName(\$input: TrackEventInput!) { + $mutationName(input: \$input) { + success + message + } + } + '''; + + final variables = { + 'input': { + 'notificationId': notificationId, + 'deviceId': deviceId, + 'platform': platform, + if (userAgent != null) 'userAgent': userAgent, + if (appVersion != null) 'appVersion': appVersion, + if (osVersion != null) 'osVersion': osVersion, + }, + }; + + await _request(mutation, variables); + } + + Future> _request( + String query, + Map variables, + ) async { + final body = jsonEncode({ + 'query': query, + 'variables': variables, + }); + + if (debug) { + print('📤 NitroPing: Request to $apiUrl'); + print('📤 NitroPing: Body: $body'); + } + + try { + final response = await http.post( + Uri.parse(apiUrl), + headers: {'Content-Type': 'application/json'}, + body: body, + ); + + if (debug) { + print('📥 NitroPing: Response ${response.statusCode}'); + print('📥 NitroPing: Body: ${response.body}'); + } + + if (response.statusCode < 200 || response.statusCode >= 300) { + throw NitroPingNetworkException( + 'HTTP error: ${response.statusCode}', + statusCode: response.statusCode, + ); + } + + final json = jsonDecode(response.body) as Map; + + // Check for GraphQL errors + final errors = json['errors'] as List?; + if (errors != null && errors.isNotEmpty) { + final errorMessages = errors + .map((e) => (e as Map)['message'] as String?) + .where((m) => m != null) + .cast() + .toList(); + throw NitroPingGraphQLException(errorMessages); + } + + return json; + } on SocketException catch (e) { + throw NitroPingNetworkException( + 'Network error: ${e.message}', + originalError: e, + ); + } on FormatException catch (e) { + throw NitroPingNetworkException( + 'Invalid response format: ${e.message}', + originalError: e, + ); + } + } +} diff --git a/flutter_sdk/nitroping/lib/src/models/config.dart b/flutter_sdk/nitroping/lib/src/models/config.dart new file mode 100644 index 0000000..07b4a85 --- /dev/null +++ b/flutter_sdk/nitroping/lib/src/models/config.dart @@ -0,0 +1,35 @@ +/// Configuration for NitroPing client. +class NitroPingConfig { + /// Your NitroPing app ID. + final String appId; + + /// NitroPing API URL (GraphQL endpoint). + final String apiUrl; + + /// Optional default user ID for device registration. + final String? userId; + + /// Enable debug logging. + final bool debug; + + const NitroPingConfig({ + required this.appId, + required this.apiUrl, + this.userId, + this.debug = false, + }); + + /// Create config with default localhost URL. + factory NitroPingConfig.localhost({ + required String appId, + String? userId, + bool debug = true, + }) { + return NitroPingConfig( + appId: appId, + apiUrl: 'http://localhost:3000/api/graphql', + userId: userId, + debug: debug, + ); + } +} diff --git a/flutter_sdk/nitroping/lib/src/models/device.dart b/flutter_sdk/nitroping/lib/src/models/device.dart new file mode 100644 index 0000000..59fa781 --- /dev/null +++ b/flutter_sdk/nitroping/lib/src/models/device.dart @@ -0,0 +1,82 @@ +/// Registered device information from NitroPing server. +class DeviceRegistration { + /// Unique device ID. + final String id; + + /// App ID this device belongs to. + final String appId; + + /// Push token (FCM or APNs). + final String token; + + /// Platform: 'IOS' or 'ANDROID'. + final String platform; + + /// Optional user ID. + final String? userId; + + /// Device status: 'ACTIVE' or 'INACTIVE'. + final String status; + + /// Device metadata as JSON string. + final String? metadata; + + /// Last seen timestamp. + final DateTime? lastSeenAt; + + /// Creation timestamp. + final DateTime createdAt; + + /// Last update timestamp. + final DateTime updatedAt; + + const DeviceRegistration({ + required this.id, + required this.appId, + required this.token, + required this.platform, + this.userId, + required this.status, + this.metadata, + this.lastSeenAt, + required this.createdAt, + required this.updatedAt, + }); + + factory DeviceRegistration.fromJson(Map json) { + return DeviceRegistration( + id: json['id'] as String, + appId: json['appId'] as String, + token: json['token'] as String, + platform: json['platform'] as String, + userId: json['userId'] as String?, + status: json['status'] as String, + metadata: json['metadata'] as String?, + lastSeenAt: json['lastSeenAt'] != null + ? DateTime.parse(json['lastSeenAt'] as String) + : null, + createdAt: DateTime.parse(json['createdAt'] as String), + updatedAt: DateTime.parse(json['updatedAt'] as String), + ); + } + + Map toJson() { + return { + 'id': id, + 'appId': appId, + 'token': token, + 'platform': platform, + 'userId': userId, + 'status': status, + 'metadata': metadata, + 'lastSeenAt': lastSeenAt?.toIso8601String(), + 'createdAt': createdAt.toIso8601String(), + 'updatedAt': updatedAt.toIso8601String(), + }; + } + + @override + String toString() { + return 'DeviceRegistration(id: $id, platform: $platform, status: $status)'; + } +} diff --git a/flutter_sdk/nitroping/lib/src/models/notification.dart b/flutter_sdk/nitroping/lib/src/models/notification.dart new file mode 100644 index 0000000..3271fe9 --- /dev/null +++ b/flutter_sdk/nitroping/lib/src/models/notification.dart @@ -0,0 +1,63 @@ +/// Push notification payload from NitroPing. +class NitroPingNotification { + /// Notification title. + final String? title; + + /// Notification body. + final String? body; + + /// Notification icon URL. + final String? icon; + + /// Notification image URL. + final String? image; + + /// Badge count. + final int? badge; + + /// Custom data payload. + final Map? data; + + /// NitroPing notification ID (for tracking). + final String? notificationId; + + /// NitroPing device ID (for tracking). + final String? deviceId; + + const NitroPingNotification({ + this.title, + this.body, + this.icon, + this.image, + this.badge, + this.data, + this.notificationId, + this.deviceId, + }); + + /// Parse notification from FCM RemoteMessage data. + factory NitroPingNotification.fromRemoteMessageData( + Map data, + ) { + return NitroPingNotification( + title: data['title'] as String?, + body: data['body'] as String?, + icon: data['icon'] as String?, + image: data['image'] as String?, + badge: data['badge'] != null ? int.tryParse(data['badge'].toString()) : null, + data: data['data'] is Map + ? data['data'] as Map + : null, + notificationId: data['nitroping_notification_id'] as String?, + deviceId: data['nitroping_device_id'] as String?, + ); + } + + /// Check if this notification has tracking info. + bool get hasTrackingInfo => notificationId != null && deviceId != null; + + @override + String toString() { + return 'NitroPingNotification(title: $title, body: $body)'; + } +} diff --git a/flutter_sdk/nitroping/pubspec.yaml b/flutter_sdk/nitroping/pubspec.yaml new file mode 100644 index 0000000..8c90f6a --- /dev/null +++ b/flutter_sdk/nitroping/pubspec.yaml @@ -0,0 +1,24 @@ +name: nitroping +description: Flutter SDK for NitroPing self-hosted push notification service. Supports iOS (APNs) and Android (FCM). +version: 0.0.1 +homepage: https://github.com/productdevbook/nitroping +repository: https://github.com/productdevbook/nitroping +issue_tracker: https://github.com/productdevbook/nitroping/issues + +environment: + sdk: ^3.6.0 + flutter: ">=3.27.0" + +dependencies: + flutter: + sdk: flutter + firebase_messaging: ^15.2.4 + firebase_core: ^3.12.0 + http: ^1.3.0 + +dev_dependencies: + flutter_test: + sdk: flutter + flutter_lints: ^5.0.0 + +flutter: