adk_notifier 0.0.2
adk_notifier: ^0.0.2 copied to clipboard
A Flutter SDK for the ART Real-time Notification Service, supporting history, live events, and push device registration.
example/lib/main.dart
import 'dart:convert';
import 'dart:io';
import 'package:art_adk/art_adk.dart';
import 'package:adk_notifier/adk_notifier.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:http/http.dart' as http;
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
void main() => runApp(const NotificationsExampleApp());
class NotificationsExampleApp extends StatelessWidget {
const NotificationsExampleApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'ADK Notifications',
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.indigo),
useMaterial3: true,
),
home: const NotificationsPage(),
);
}
}
class NotificationsPage extends StatefulWidget {
const NotificationsPage({super.key});
@override
State<NotificationsPage> createState() => _NotificationsPageState();
}
class _NotificationsPageState extends State<NotificationsPage> {
final List<String> _log = <String>[];
final _recipientController = TextEditingController(text: 'USER_NAME');
final _tokenController = TextEditingController(text: 'FCM_TOKEN');
final _titleController = TextEditingController(text: 'Test notification');
final _bodyController = TextEditingController(
text: 'Sent from the Flutter example',
);
Adk? _adk;
NotificationsApi? _notifications;
Future<void> Function()? _removeLiveListener;
List<ArtNotification> _items = <ArtNotification>[];
String _status = 'Disconnected';
String _platform = 'android';
@override
void initState() {
super.initState();
LocalNotificationService.instance.init(onTap: _handleNotificationTap);
}
void _handleNotificationTap(String? payload) {
if (payload == null) return;
final data = jsonDecode(payload) as Map<String, dynamic>;
final id = data['id'] as String?;
if (id != null) {
_requireApi().markRead([id]);
}
}
@override
void dispose() {
_removeLiveListener?.call();
_adk?.disconnect();
for (final controller in <TextEditingController>[
_tokenController,
_recipientController,
_titleController,
_bodyController,
]) {
controller.dispose();
}
super.dispose();
}
Future<CredentialStore> _loadCredentials() async {
try {
final raw = await rootBundle.loadString('assets/adk-services.json');
final json = jsonDecode(raw) as Map<String, dynamic>;
return CredentialStore(
environment: json['Environment'] as String? ?? '',
projectKey: json['ProjectKey'] as String? ?? '',
orgTitle: json['Org-Title'] as String? ?? '',
clientID: json['Client-ID'] as String? ?? '',
clientSecret: json['Client-Secret'] as String? ?? '',
);
} catch (e) {
throw Exception('Failed to load assets/adk-services.json: $e');
}
}
Future<String> _fetchPasscode(CredentialStore creds) async {
final response = await http.post(
Uri.parse('PASSCODE_ENDPOINT'),
headers: <String, String>{
'Client-Id': creds.clientID,
'Client-Secret': creds.clientSecret,
'X-Org': creds.orgTitle,
'Environment': creds.environment,
'ProjectKey': creds.projectKey,
'Content-Type': 'application/json',
},
body: jsonEncode(<String, dynamic>{
'username': 'USER_NAME',
'first_name': 'USER_FIRST_NAME',
'last_name': 'USER_LAST_NAME',
}),
);
if (response.statusCode < 200 || response.statusCode >= 300) {
throw Exception('passcode request failed (${response.statusCode})');
}
final decoded = jsonDecode(response.body) as Map<String, dynamic>;
final data = decoded['data'];
final passcode = data is Map<String, dynamic>
? data['passcode'] as String?
: decoded['passcode'] as String?;
if (passcode == null || passcode.isEmpty) {
throw Exception('passcode missing in response');
}
return passcode;
}
Future<void> _connect() async {
try {
_logEvent('connecting...');
final credentials = await _loadCredentials();
final passcode = await _fetchPasscode(credentials);
final updatedCredentials = credentials.copyWith(accessToken: passcode);
final adk = Adk(
adkConfig: AdkConfig(
uri: 'YOUR_WEBSOCKET_URI',
authToken: passcode,
getCredentials: () => updatedCredentials,
),
);
adk.on('connection', (dynamic data) {
if (data is ConnectionDetail) {
_logEvent('connected · ${data.connectionId}');
} else {
_logEvent('connected · $data');
}
});
adk.on('close', (dynamic reason) => _logEvent('closed · $reason'));
await adk.connect();
setState(() => _adk = adk);
final api = NotificationsApi.fromAdk(adk,
options: const NotificationsOptions(
apiBaseUrl: "YOUR_BASE_URI",
debug: true,
),
);
_logEvent('Initialized NotificationsApi...');
_removeLiveListener = await api.onNew((notification) async {
_logEvent(
'[LIVE event] Received notification -> Id: "${notification.id}", Title: "${notification.title}"',
);
if (!mounted) return;
setState(() {
_items.removeWhere((e) => e.id == notification.id);
_items.insert(0, notification);
});
try {
await LocalNotificationService.instance.showWithData(
title: notification.title,
body: notification.body,
data: {'id': notification.id},
);
} catch (e) {
_logEvent('Local notification FAILED: $e');
}
_show('New notification: ${notification.title}');
});
setState(() {
_adk = adk;
_notifications = api;
_status = 'Connected';
});
await _loadHistory();
} catch (e) {
_logEvent('connect failed · $e');
}
}
void _logEvent(String message) {
debugPrint(message);
if (mounted) {
setState(() {
_log.add(
'[${DateTime.now().toIso8601String().substring(11, 19)}] $message');
});
}
}
Future<void> _loadHistory() async {
await _run('Loading history', () async {
final result = await _requireApi().list();
_logEvent(
'[loadHistory] Received ${result.notifications.length} item(s) out of ${result.total} total.',
);
setState(() => _items = List<ArtNotification>.of(result.notifications)); // growable copy
_show(
'Loaded ${result.notifications.length} of ${result.total} notifications',
);
});
}
Future<void> _markAllRead() async {
await _run(
'Marking read',
() async =>
_show('Marked ${await _requireApi().markRead()} notifications read'),
);
final result = await _requireApi().list();
setState(() => _items = List<ArtNotification>.of(result.notifications));
}
Future<void> _send() async {
await _run('Sending', () async {
_logEvent(
'Sending',
);
final result = await _requireApi().send(
SendInput(
recipients: _recipientController.text
.split(',')
.map((value) => value.trim())
.where((value) => value.isNotEmpty)
.toList(),
type: 'example.test',
title: _titleController.text,
body: _bodyController.text,
channels: [
"in_app",
"push",
],
),
);
await _loadHistory();
_show(
'Created ${result.created}; skipped ${result.skipped}',
);
});
}
Future<void> _registerDevice() async {
await _run('Registering device', () async {
final device = await _requireApi().registerDevice(
RegisterDeviceInput(
token: _tokenController.text.trim(),
platform: Platform.isAndroid
? "android"
: "ios",
),
);
_show(
device == null
? 'No device returned'
: 'Registered ${device.platform} device',
);
});
}
Future<void> _run(String busyText, Future<void> Function() action) async {
setState(() => _status = busyText);
try {
await action();
} catch (error) {
_show('Error: $error');
setState(() => _status = 'Error');
}
}
NotificationsApi _requireApi() =>
_notifications ?? (throw StateError('Connect first'));
void _show(String message) {
if (!mounted) return;
setState(() => _status = message);
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(message)));
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('ADK Notifications example')),
body: ListView(
padding: const EdgeInsets.all(16),
children: <Widget>[
const Text(
'Connection',
style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold),
),
const SizedBox(height: 28),
FilledButton.icon(
onPressed: _connect,
icon: const Icon(Icons.link),
label: const Text('Connect'),
),
const SizedBox(height: 8),
Text(_status),
const Divider(height: 32),
const Text(
'History and actions',
style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold),
),
Wrap(
spacing: 8,
runSpacing: 8,
children: <Widget>[
OutlinedButton(
onPressed: _loadHistory,
child: const Text('Load history'),
),
OutlinedButton(
onPressed: _markAllRead,
child: const Text('Mark all read'),
),
],
),
const SizedBox(height: 28),
_field(_recipientController, 'Recipients (comma separated)'),
_field(_titleController, 'Title'),
_field(_bodyController, 'Body'),
const SizedBox(height: 28),
FilledButton(
onPressed: _send,
child: const Text('Send test notification'),
),
const Divider(height: 32),
const Text(
'Push device',
style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold),
),
_field(_tokenController, 'FCM token'),
DropdownButtonFormField<String>(
initialValue: _platform,
decoration: const InputDecoration(labelText: 'Platform'),
items: const <String>['android', 'ios', 'web']
.map(
(value) => DropdownMenuItem(value: value, child: Text(value)),
)
.toList(),
onChanged: (value) =>
setState(() => _platform = value ?? 'android'),
),
FilledButton.tonal(
onPressed: _registerDevice,
child: const Text('Register device'),
),
const SizedBox(height: 20),
Text(
'Notifications (${_items.length})',
style: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold),
),
if (_items.isEmpty)
const Padding(
padding: EdgeInsets.symmetric(vertical: 24),
child: Text('No notifications loaded yet.'),
),
..._items.map(
(item) =>
Card(color: item.status == 'unread'
? Colors.blue.shade50
: null,
child: ListTile(
title: Text(item.title),
subtitle: Text(item.body),
onTap: () async {
await _requireApi().markRead([item.id]);
setState(() {
final index = _items.indexWhere((e) => e.id == item.id);
if (index != -1) {
_items[index] = ArtNotification(
id: item.id,
type: item.type,
title: item.title,
body: item.body,
createdAt: item.createdAt,
data: item.data,
status: 'read',
);
}
});
},
leading: Icon(
item.status == 'unread'
? Icons.notifications_active
: Icons.notifications_none,
),
),
)
),
],
),
);
}
Widget _field(
TextEditingController controller,
String label, {
bool secret = false,
}) {
return Padding(
padding: const EdgeInsets.only(bottom: 8),
child: TextField(
controller: controller,
obscureText: secret,
decoration: InputDecoration(
labelText: label,
border: const OutlineInputBorder(),
),
),
);
}
}
/// Called when the user taps a notification while the app is running
/// (foreground or backgrounded but still alive).
typedef NotificationTapCallback = void Function(String? payload);
@pragma('vm:entry-point')
void _onBackgroundNotificationTap(NotificationResponse response) {
debugPrint('[LocalNotificationService] Background tap: ${response.payload}');
}
class LocalNotificationService {
LocalNotificationService._();
static final LocalNotificationService instance = LocalNotificationService._();
final FlutterLocalNotificationsPlugin _plugin =
FlutterLocalNotificationsPlugin();
static const AndroidNotificationChannel _channel = AndroidNotificationChannel(
'adk_notifier',
'App notifications',
description: 'Notifications from the ADK Notifications API',
importance: Importance.high,
);
bool _initialized = false;
NotificationTapCallback? _onTap;
/// Initializes the plugin, creates the Android notification channel, and
/// requests notification permissions on both platforms. Safe to call
/// multiple times — subsequent calls are no-ops.
Future<void> init({NotificationTapCallback? onTap}) async {
if (_initialized) return;
_onTap = onTap;
const AndroidInitializationSettings androidSettings =
AndroidInitializationSettings('@mipmap/ic_launcher');
const DarwinInitializationSettings iosSettings = DarwinInitializationSettings(
requestAlertPermission: true,
requestBadgePermission: true,
requestSoundPermission: true,
);
await _plugin.initialize(
settings: const InitializationSettings(
android: androidSettings,
iOS: iosSettings,
),
onDidReceiveNotificationResponse: (NotificationResponse response) {
_onTap?.call(response.payload);
},
onDidReceiveBackgroundNotificationResponse: _onBackgroundNotificationTap,
);
await _plugin.resolvePlatformSpecificImplementation<AndroidFlutterLocalNotificationsPlugin>()?.createNotificationChannel(_channel);
final granted = await _plugin.resolvePlatformSpecificImplementation
<AndroidFlutterLocalNotificationsPlugin>()
?.requestNotificationsPermission();
debugPrint('[LocalNotificationService] Android permission granted: $granted');
await _plugin
.resolvePlatformSpecificImplementation<IOSFlutterLocalNotificationsPlugin>()
?.requestPermissions(alert: true, badge: true, sound: true);
_initialized = true;
}
/// Shows a notification in the system tray.
Future<void> show({
required String title,
required String body,
String? payload,
int? id,
}) async {
if (!_initialized) {
throw StateError(
'LocalNotificationService.show() called before init(). '
'Call LocalNotificationService.instance.init() first, '
'typically in initState().',
);
}
try {
await _plugin.show(
id: id ?? DateTime.now().millisecondsSinceEpoch ~/ 1000,
title: title,
body: body,
notificationDetails: NotificationDetails(
android: AndroidNotificationDetails(
_channel.id,
_channel.name,
channelDescription: _channel.description,
importance: Importance.high,
priority: Priority.high,
icon: '@mipmap/ic_launcher',
),
iOS: const DarwinNotificationDetails(
presentAlert: true,
presentBadge: true,
presentSound: true,
),
),
payload: payload,
);
debugPrint('[LocalNotificationService] show() completed');
} catch (e, st) {
debugPrint('[LocalNotificationService] show() FAILED: $e\n$st');
}
}
/// Convenience helper for showing a notification whose payload is a JSON
/// map — the common case of round-tripping an id back through [init]'s
/// `onTap` callback.
Future<void> showWithData({
required String title,
required String body,
required Map<String, dynamic> data,
int? id,
}) {
return show(title: title, body: body, payload: jsonEncode(data), id: id);
}
}