firebase_fcm 0.0.1
firebase_fcm: ^0.0.1 copied to clipboard
A Flutter package to construct and dispatch Firebase Cloud Messaging (FCM) v1 notifications from Dart using Service Account OAuth2 credentials.
example/lib/main.dart
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:firebase_fcm/firebase_fcm.dart';
void main() {
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Firebase FCM Sender',
debugShowCheckedModeBanner: false,
theme: ThemeData(
useMaterial3: true,
brightness: Brightness.dark,
colorScheme: ColorScheme.fromSeed(
seedColor: const Color(0xFFFF9100), // Firebase Accent Orange
brightness: Brightness.dark,
),
scaffoldBackgroundColor: const Color(0xFF121214),
cardTheme: CardThemeData(
color: const Color(0xFF1E1E22),
elevation: 4,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
),
),
home: const NotificationSenderScreen(),
);
}
}
class NotificationSenderScreen extends StatefulWidget {
const NotificationSenderScreen({super.key});
@override
State<NotificationSenderScreen> createState() => _NotificationSenderScreenState();
}
class _NotificationSenderScreenState extends State<NotificationSenderScreen> {
final _formKey = GlobalKey<FormState>();
// Controllers
final _serviceAccountController = TextEditingController();
final _titleController = TextEditingController();
final _bodyController = TextEditingController();
final _tokenController = TextEditingController();
final _topicController = TextEditingController();
bool _sendToTopic = false;
bool _isDebug = true;
bool _isLoading = false;
// Key-value pairs for custom data payload
final List<MapEntry<TextEditingController, TextEditingController>> _customData = [];
// Logs or Status message
String _statusMessage = 'Ready to send';
Color _statusColor = Colors.grey;
@override
void initState() {
super.initState();
// Default values for standard notification properties
_titleController.text = 'New message received!';
_bodyController.text = 'You have a message waiting in your inbox.';
_topicController.text = 'F2chat';
_tokenController.text = '';
// Add default custom data key-values
_addDataRow('type', 'chat');
_addDataRow('chatType', 'seller');
}
void _addDataRow([String key = '', String val = '']) {
setState(() {
_customData.add(MapEntry(
TextEditingController(text: key),
TextEditingController(text: val),
));
});
}
void _removeDataRow(int index) {
setState(() {
_customData[index].key.dispose();
_customData[index].value.dispose();
_customData.removeAt(index);
});
}
void _loadSampleServiceAccount() {
const sample = r'''{
"type": "service_account",
"project_id": "fir-notificaiton-5c0fd",
"private_key_id": "123456789abcdef",
"private_key": "-----BEGIN PRIVATE KEY-----\nMIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQDh...\n-----END PRIVATE KEY-----\n",
"client_email": "firebase-adminsdk-xxxxx@fir-notificaiton-5c0fd.iam.gserviceaccount.com",
"client_id": "12345678901234567890",
"auth_uri": "https://accounts.google.com/o/oauth2/auth",
"token_uri": "https://oauth2.googleapis.com/token",
"auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs",
"client_x509_cert_url": "https://www.googleapis.com/robot/v1/metadata/x509/firebase-adminsdk-xxxxx%40fir-notificaiton-5c0fd.iam.gserviceaccount.com"
}''';
setState(() {
_serviceAccountController.text = sample;
});
}
Future<void> _sendNotification() async {
if (!_formKey.currentState!.validate()) {
return;
}
if (_serviceAccountController.text.trim().isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Please enter service account JSON'),
backgroundColor: Colors.redAccent,
),
);
return;
}
// Parse Service Account JSON
Map<String, dynamic> serviceAccount;
try {
String rawJson = _serviceAccountController.text.trim();
// Automatically sanitize and remove trailing commas before parsing
rawJson = rawJson.replaceAllMapped(RegExp(r',\s*([}\]])'), (Match m) => m[1]!);
serviceAccount = jsonDecode(rawJson);
} catch (e) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Invalid Service Account JSON format: $e'),
backgroundColor: Colors.redAccent,
),
);
return;
}
// Build data map from user input rows
final Map<String, String> dataMap = {};
for (var entry in _customData) {
final key = entry.key.text.trim();
final val = entry.value.text.trim();
if (key.isNotEmpty && val.isNotEmpty) {
dataMap[key] = val;
}
}
setState(() {
_isLoading = true;
_statusMessage = 'Sending notification... Check terminal debugPrint output.';
_statusColor = Colors.amber;
});
try {
await sendFcmNotification(
title: _titleController.text.trim(),
body: _bodyController.text.trim(),
token: _sendToTopic ? null : _tokenController.text.trim(),
topicPath: _sendToTopic ? _topicController.text.trim() : null,
sendToTopic: _sendToTopic,
serviceAccountJson: serviceAccount,
data: dataMap.isNotEmpty ? dataMap : null,
isDebug: _isDebug,
);
if (!mounted) return;
setState(() {
_statusMessage = 'Success! Notification request completed. Check IDE debug output.';
_statusColor = Colors.greenAccent;
});
showDialog(
context: context,
builder: (ctx) => AlertDialog(
title: const Row(
children: [
Icon(Icons.check_circle, color: Colors.greenAccent),
SizedBox(width: 8),
Text('Request Dispatched'),
],
),
content: const Text(
'The FCM send call was executed successfully. If debug mode is enabled, full response logs are printed in the terminal.',
),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx),
child: const Text('OK'),
),
],
),
);
} catch (e) {
if (!mounted) return;
setState(() {
_statusMessage = 'Error: $e';
_statusColor = Colors.redAccent;
});
showDialog(
context: context,
builder: (ctx) => AlertDialog(
title: const Row(
children: [
Icon(Icons.error_outline, color: Colors.redAccent),
SizedBox(width: 8),
Text('Execution Failed'),
],
),
content: Text(
'Failed to execute FCM request. Ensure your Service Account JSON credentials and network connection are correct.\n\nDetails:\n$e',
),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx),
child: const Text('Dismiss'),
),
],
),
);
} finally {
if (mounted) {
setState(() {
_isLoading = false;
});
}
}
}
@override
void dispose() {
_serviceAccountController.dispose();
_titleController.dispose();
_bodyController.dispose();
_tokenController.dispose();
_topicController.dispose();
for (var entry in _customData) {
entry.key.dispose();
entry.value.dispose();
}
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text(
'Firebase FCM Portal',
style: TextStyle(fontWeight: FontWeight.w800, letterSpacing: 0.5),
),
elevation: 0,
backgroundColor: Colors.transparent,
flexibleSpace: Container(
decoration: const BoxDecoration(
gradient: LinearGradient(
colors: [Color(0xFFE65100), Color(0xFFFF8F00)],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
),
),
),
body: SingleChildScrollView(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 20.0),
child: Form(
key: _formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Header Details
const Text(
'FCM Send Console',
style: TextStyle(fontSize: 22, fontWeight: FontWeight.bold),
),
const SizedBox(height: 4),
const Text(
'Test FCM V1 notification push with OAuth2 authorization using a service account.',
style: TextStyle(color: Colors.grey, fontSize: 13),
),
const SizedBox(height: 20),
// CARD 1: Service Account Credentials
Card(
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
const Icon(Icons.vpn_key_rounded, color: Color(0xFFFF9100)),
const SizedBox(width: 8),
const Expanded(
child: Text(
'1. Service Account Credentials',
style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
),
),
TextButton.icon(
onPressed: _loadSampleServiceAccount,
icon: const Icon(Icons.content_paste_go, size: 16),
label: const Text('Load Sample', style: TextStyle(fontSize: 12)),
),
],
),
const SizedBox(height: 8),
TextFormField(
controller: _serviceAccountController,
maxLines: 8,
style: const TextStyle(fontFamily: 'monospace', fontSize: 12),
decoration: const InputDecoration(
hintText: 'Paste the contents of your Firebase Service Account JSON file here...',
alignLabelWithHint: true,
),
validator: (value) {
if (value == null || value.trim().isEmpty) {
return 'Service account credentials JSON is required';
}
return null;
},
),
],
),
),
),
const SizedBox(height: 16),
// CARD 2: Notification Payload
Card(
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Row(
children: [
Icon(Icons.notification_important_rounded, color: Color(0xFFFF9100)),
SizedBox(width: 8),
Expanded(
child: Text(
'2. Notification Payload',
style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
),
),
],
),
const SizedBox(height: 16),
TextFormField(
controller: _titleController,
decoration: const InputDecoration(
labelText: 'Notification Title',
prefixIcon: Icon(Icons.title),
),
validator: (value) =>
value == null || value.trim().isEmpty ? 'Title is required' : null,
),
const SizedBox(height: 12),
TextFormField(
controller: _bodyController,
decoration: const InputDecoration(
labelText: 'Notification Body',
prefixIcon: Icon(Icons.notes),
),
validator: (value) =>
value == null || value.trim().isEmpty ? 'Body is required' : null,
),
],
),
),
),
const SizedBox(height: 16),
// CARD 3: Destination (Token vs Topic)
Card(
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Row(
children: [
Icon(Icons.send_rounded, color: Color(0xFFFF9100)),
SizedBox(width: 8),
Expanded(
child: Text(
'3. Destination Settings',
style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
),
),
],
),
const SizedBox(height: 12),
SwitchListTile(
title: const Text('Send to Topic instead of Token'),
subtitle: Text(
_sendToTopic
? 'Delivers to topic subscribers'
: 'Delivers to a specific device token',
style: const TextStyle(fontSize: 12),
),
value: _sendToTopic,
onChanged: (val) {
setState(() {
_sendToTopic = val;
});
},
),
const Divider(height: 20),
if (_sendToTopic)
TextFormField(
controller: _topicController,
decoration: const InputDecoration(
labelText: 'Topic Path',
hintText: 'e.g., F2chat',
prefixIcon: Icon(Icons.tag),
),
validator: (value) => _sendToTopic && (value == null || value.trim().isEmpty)
? 'Topic path is required'
: null,
)
else
TextFormField(
controller: _tokenController,
decoration: const InputDecoration(
labelText: 'Device Token',
hintText: 'Paste target device registration token',
prefixIcon: Icon(Icons.devices_other),
),
validator: (value) => !_sendToTopic && (value == null || value.trim().isEmpty)
? 'Device token is required'
: null,
),
],
),
),
),
const SizedBox(height: 16),
// CARD 4: Custom Data Payload
Card(
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
const Icon(Icons.data_object_rounded, color: Color(0xFFFF9100)),
const SizedBox(width: 8),
const Expanded(
child: Text(
'4. Custom Data (Optional)',
style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
),
),
IconButton(
icon: const Icon(Icons.add_circle_outline, color: Color(0xFFFF9100)),
tooltip: 'Add Field',
onPressed: () => _addDataRow(),
),
],
),
const SizedBox(height: 8),
if (_customData.isEmpty)
const Padding(
padding: EdgeInsets.symmetric(vertical: 12.0),
child: Text(
'No custom data rows added. Default fallback data will be sent.',
style: TextStyle(
color: Colors.grey,
fontSize: 12,
fontStyle: FontStyle.italic,
),
),
)
else
ListView.builder(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
itemCount: _customData.length,
itemBuilder: (context, index) {
return Padding(
padding: const EdgeInsets.only(bottom: 8.0),
child: Row(
children: [
Expanded(
child: TextFormField(
controller: _customData[index].key,
decoration: const InputDecoration(
labelText: 'Key',
isDense: true,
contentPadding: EdgeInsets.symmetric(horizontal: 10, vertical: 10),
),
),
),
const SizedBox(width: 8),
Expanded(
child: TextFormField(
controller: _customData[index].value,
decoration: const InputDecoration(
labelText: 'Value',
isDense: true,
contentPadding: EdgeInsets.symmetric(horizontal: 10, vertical: 10),
),
),
),
IconButton(
icon: const Icon(Icons.delete_outline, color: Colors.redAccent),
onPressed: () => _removeDataRow(index),
),
],
),
);
},
),
],
),
),
),
const SizedBox(height: 16),
// CARD 5: Configuration & Debug Options
Card(
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Row(
children: [
Icon(Icons.settings_rounded, color: Color(0xFFFF9100)),
SizedBox(width: 8),
Expanded(
child: Text(
'5. Settings & Logs',
style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
),
),
],
),
const SizedBox(height: 8),
SwitchListTile(
title: const Text('Verbose Debug Mode'),
subtitle: const Text('Logs full request details & credentials in the console.'),
value: _isDebug,
onChanged: (val) {
setState(() {
_isDebug = val;
});
},
),
const Divider(),
const Text(
'Status Monitor:',
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 12),
),
const SizedBox(height: 4),
Container(
width: double.infinity,
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: const Color(0x4D000000), // 30% black
borderRadius: BorderRadius.circular(8),
border: Border.all(color: const Color(0x33FFFFFF)), // 20% white
),
child: Text(
_statusMessage,
style: TextStyle(
color: _statusColor,
fontSize: 12,
fontFamily: 'monospace',
),
),
),
],
),
),
),
const SizedBox(height: 24),
// Trigger Button
SizedBox(
width: double.infinity,
height: 52,
child: ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xFFFF8F00),
foregroundColor: Colors.white,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(14),
),
elevation: 4,
),
onPressed: _isLoading ? null : _sendNotification,
child: _isLoading
? const CircularProgressIndicator(color: Colors.white)
: const Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.send_time_extension_rounded),
SizedBox(width: 8),
Text(
'Dispatch FCM Notification',
style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
),
],
),
),
),
const SizedBox(height: 40),
],
),
),
),
),
);
}
}