Release recipient inbox SDKs 0.1.0 preview

This commit is contained in:
WRNexus SDK Publisher committed 2026-10-09 14:27:00 +05:30
commit d0781a9f3b
23 files changed
+1846

No files matched your search

+287
View File
@@ -0,0 +1,287 @@
import 'dart:async';
import 'dart:convert';
import 'package:http/http.dart' as http;
import 'models.dart';
/// Never pass a business API key. Tokens must be issued by your application backend.
class InboxClient {
final Uri endpoint;
final FutureOr<String> Function() token;
final Duration timeout;
final http.Client _http;
final http.Client Function() _streamFactory;
final Set<void Function()> _stoppers = {};
bool _closed = false;
InboxClient({
required this.token,
Uri? endpoint,
this.timeout = const Duration(seconds: 15),
http.Client? transport,
http.Client Function()? streamTransport,
}) : endpoint = endpoint ?? Uri.parse('https://notifications.wrnexus.com'),
_http = transport ?? http.Client(),
_streamFactory = streamTransport ?? http.Client.new {
final u = this.endpoint;
if (u.host.isEmpty ||
u.userInfo.isNotEmpty ||
u.hasQuery ||
u.hasFragment ||
!['', '/'].contains(u.path) ||
(u.scheme != 'https' &&
!(u.scheme == 'http' &&
['localhost', '127.0.0.1', '::1'].contains(u.host))) ||
timeout <= Duration.zero) {
throw const InboxException(
'invalid-endpoint',
'Use an HTTPS origin or localhost with a positive timeout.',
);
}
}
void close() {
if (_closed) return;
_closed = true;
for (final stop in _stoppers.toList()) {
stop();
}
_http.close();
}
void _checkOpen() {
if (_closed) {
throw const InboxException('closed', 'The recipient session is closed.');
}
}
Future<Map<String, String>> _headers({bool stream = false}) async {
_checkOpen();
final value = await token();
_checkOpen();
if (value.isEmpty || value.contains('\r') || value.contains('\n')) {
throw const InboxException(
'recipient-token-required',
'Pass a recipient token.',
);
}
return {
'authorization': 'Bearer $value',
'accept': stream ? 'text/event-stream' : 'application/json',
'content-type': 'application/json',
};
}
Uri _url(String path) => endpoint.resolve('/api/v1/inbox/$path');
InboxException _failure(int status, String body) {
try {
final j = jsonDecode(body) as Json;
final e = j['error'] as Json?;
return InboxException(
e?['code'] as String? ?? 'request-failed',
e?['message'] as String? ?? 'Request failed with HTTP $status.',
status,
);
} catch (_) {
return InboxException(
'request-failed',
'Request failed with HTTP $status.',
status,
);
}
}
Future<Json> _call(String method, String path, [Json? body]) async {
_checkOpen();
try {
final headers = await _headers();
final response =
await (method == 'GET'
? _http.get(_url(path), headers: headers)
: _http.post(
_url(path),
headers: headers,
body: jsonEncode(body),
))
.timeout(timeout);
_checkOpen();
if (response.statusCode < 200 || response.statusCode >= 300) {
throw _failure(response.statusCode, response.body);
}
return jsonDecode(response.body) as Json;
} on TimeoutException {
throw const InboxException('timeout', 'The request timed out.');
} on http.ClientException {
throw const InboxException(
'network-error',
'Unable to connect to the inbox.',
);
}
}
Future<InboxSnapshot> messages({int? limit}) async {
if (limit != null && (limit < 1 || limit > 100)) {
throw const InboxException(
'invalid-limit',
'Limit must be between 1 and 100.',
);
}
return InboxSnapshot.fromJson(
await _call('GET', 'messages${limit == null ? '' : '?limit=$limit'}'),
);
}
Future<void> markRead(String id) async {
await _call('POST', 'messages', {'id': id, 'action': 'read'});
}
Future<void> archive(String id) async {
await _call('POST', 'messages', {'id': id, 'action': 'archive'});
}
Future<InboxPreferences> preferences() async =>
InboxPreferences.fromJson(await _call('GET', 'preferences'));
Future<void> savePreferences(PreferenceInput input) async {
await _call('POST', 'preferences', input.toJson());
}
Future<InboxDevices> devices() async =>
InboxDevices.fromJson(await _call('GET', 'devices'));
/// Use the same installationId when the provider token rotates.
Future<DeviceResult> registerMobilePush(MobileRegistration input) async =>
DeviceResult.fromJson(await _call('POST', 'devices', input.toJson()));
Future<DeviceResult> unregisterMobilePush(String id) async =>
DeviceResult.fromJson(
await _call('POST', 'devices', {
'action': 'unregister-mobile',
'deviceId': id,
}),
);
Future<DeviceResult> unregisterWebPush(String id) async =>
DeviceResult.fromJson(
await _call('POST', 'devices', {
'action': 'unregister',
'deviceId': id,
}),
);
Uri? logoUri(InboxBranding branding) {
final path = branding.logoUrl;
return path != null &&
RegExp(r'^/api/v1/inbox/branding-logo/[a-f0-9]{64}$').hasMatch(path)
? endpoint.resolve(path)
: null;
}
static Uri? safeActionUri(String? text) {
final u = text == null ? null : Uri.tryParse(text);
return u != null &&
u.scheme == 'https' &&
u.host.isNotEmpty &&
u.userInfo.isEmpty
? u
: null;
}
/// Cancel the subscription on pause/dispose. It cancels its HTTP connection immediately.
Stream<InboxSnapshot> updates() {
late StreamController<InboxSnapshot> controller;
http.Client? active;
bool stopped = false;
final done = Completer<void>();
void stop() {
if (stopped) return;
stopped = true;
active?.close();
if (!done.isCompleted) done.complete();
_stoppers.remove(stop);
}
Future<void> run() async {
_stoppers.add(stop);
try {
while (!stopped && !_closed) {
active = _streamFactory();
try {
final request = http.Request('GET', _url('stream'));
request.headers.addAll(await _headers(stream: true));
if (stopped) break;
final response = await active!.send(request).timeout(timeout);
if (response.statusCode < 200 || response.statusCode >= 300) {
throw _failure(
response.statusCode,
await response.stream.bytesToString(),
);
}
final parser = InboxEventParser();
await for (final line
in response.stream
.transform(utf8.decoder)
.transform(const LineSplitter())) {
if (stopped || _closed) break;
final snapshot = parser.line(line);
if (snapshot != null) controller.add(snapshot);
}
} on InboxException {
rethrow;
} on FormatException {
rethrow;
} catch (_) {
if (stopped || _closed) break;
} finally {
active?.close();
active = null;
}
if (!stopped && !_closed) {
await Future.any([
Future<void>.delayed(const Duration(seconds: 2)),
done.future,
]);
}
}
} catch (error, stack) {
if (!stopped && !_closed) controller.addError(error, stack);
} finally {
stop();
unawaited(controller.close());
}
}
controller = StreamController<InboxSnapshot>(
onListen: () {
unawaited(run());
},
onCancel: stop,
);
return controller.stream;
}
}
class InboxEventParser {
String _event = '';
final List<String> _data = [];
int _size = 0;
InboxSnapshot? line(String line) {
if (line.isEmpty) {
final event = _event;
final data = _data.join('\n');
_event = '';
_data.clear();
_size = 0;
return event == 'inbox' && data.isNotEmpty
? InboxSnapshot.fromJson(jsonDecode(data) as Json)
: null;
}
_size += utf8.encode(line).length;
if (_size > 2097152) {
throw const InboxException(
'stream-too-large',
'Inbox frame exceeded the size limit.',
);
}
if (line.startsWith('event:')) _event = line.substring(6).trim();
if (line.startsWith('data:')) {
var value = line.substring(5);
if (value.startsWith(' ')) value = value.substring(1);
_data.add(value);
}
return null;
}
}
+323
View File
@@ -0,0 +1,323 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'client.dart';
import 'models.dart';
/// Real recipient content only. The host owns navigation and custom-action handling.
class WRNexusInboxView extends StatefulWidget {
final InboxClient client;
final void Function(InboxMessage message, InboxAction? action, Uri? url)?
onAction;
const WRNexusInboxView({super.key, required this.client, this.onAction});
@override
State<WRNexusInboxView> createState() => _InboxViewState();
}
class _InboxViewState extends State<WRNexusInboxView>
with WidgetsBindingObserver {
InboxSnapshot? _snapshot;
InboxBranding? _branding;
String? _error;
final Set<String> _busy = {};
StreamSubscription<InboxSnapshot>? _subscription;
int _generation = 0;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
unawaited(_load());
}
@override
void didUpdateWidget(covariant WRNexusInboxView old) {
super.didUpdateWidget(old);
if (old.client != widget.client) {
_generation++;
_snapshot = null;
_branding = null;
_error = null;
_busy.clear();
unawaited(_load());
}
}
@override
void dispose() {
_generation++;
_subscription?.cancel();
WidgetsBinding.instance.removeObserver(this);
super.dispose();
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
if (state == AppLifecycleState.resumed) {
unawaited(_load());
} else {
_generation++;
_subscription?.cancel();
_subscription = null;
}
}
bool _current(int generation) => mounted && generation == _generation;
Future<void> _load() async {
final generation = ++_generation;
final previous = _subscription;
_subscription = null;
if (previous != null) {
unawaited(previous.cancel());
}
if (!_current(generation)) return;
setState(() => _error = null);
try {
final preferences = await widget.client.preferences();
final snapshot = await widget.client.messages();
if (!_current(generation)) return;
setState(() {
_branding = preferences.branding;
_snapshot = snapshot;
});
_subscription = widget.client.updates().listen(
(value) {
if (_current(generation)) setState(() => _snapshot = value);
},
onError: (Object error) {
if (_current(generation)) setState(() => _error = error.toString());
},
);
} catch (error) {
if (_current(generation)) setState(() => _error = error.toString());
}
}
Future<void> _mutate(InboxMessage message, bool archive) async {
final generation = _generation;
final client = widget.client;
setState(() => _busy.add(message.id));
try {
if (archive) {
await client.archive(message.id);
} else {
await client.markRead(message.id);
}
final snapshot = await client.messages();
if (_current(generation)) setState(() => _snapshot = snapshot);
} catch (error) {
if (_current(generation)) setState(() => _error = error.toString());
} finally {
if (_current(generation)) setState(() => _busy.remove(message.id));
}
}
Color? _color(String? text) =>
text != null && RegExp(r'^#[a-fA-F0-9]{6}$').hasMatch(text)
? Color(0xff000000 | int.parse(text.substring(1), radix: 16))
: null;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final brand = _color(_branding?.brandColor) ?? theme.colorScheme.primary;
final logo = _branding == null ? null : widget.client.logoUri(_branding!);
return Theme(
data: theme.copyWith(
colorScheme: theme.colorScheme.copyWith(
primary: brand.computeLuminance() > 0.18
? theme.colorScheme.onSurface
: brand,
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Container(
decoration: BoxDecoration(
border: Border(bottom: BorderSide(color: brand, width: 2)),
),
padding: const EdgeInsets.all(16),
child: Row(
children: [
if (logo != null)
Padding(
padding: const EdgeInsets.only(right: 12),
child: Image.network(
logo.toString(),
width: 36,
height: 36,
fit: BoxFit.contain,
excludeFromSemantics: true,
errorBuilder: (_, _, _) =>
const SizedBox(width: 36, height: 36),
),
),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
_branding?.displayName ?? 'Notifications',
style: theme.textTheme.titleLarge,
),
if (_snapshot != null)
Text(
'${_snapshot!.unreadCount} unread',
style: theme.textTheme.bodySmall,
),
],
),
),
IconButton(
tooltip: 'Refresh notifications',
onPressed: () => unawaited(_load()),
icon: const Icon(Icons.refresh),
),
],
),
),
if (_error != null)
MaterialBanner(
content: Text(_error!),
actions: [
TextButton(
onPressed: () => unawaited(_load()),
child: const Text('Retry'),
),
],
),
Expanded(
child: _snapshot == null
? (_error == null
? const Center(
child: CircularProgressIndicator(
semanticsLabel: 'Loading notifications',
),
)
: const SizedBox())
: _snapshot!.messages.isEmpty
? Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.notifications_none,
color: brand,
size: 40,
),
const SizedBox(height: 12),
Text(
'No notifications yet',
style: theme.textTheme.titleMedium,
),
const SizedBox(height: 8),
const Text('Your notifications will appear here.'),
],
),
),
)
: RefreshIndicator(
onRefresh: _load,
child: ListView.separated(
physics: const AlwaysScrollableScrollPhysics(),
itemCount: _snapshot!.messages.length,
separatorBuilder: (_, _) => const Divider(height: 1),
itemBuilder: (context, index) {
final message = _snapshot!.messages[index];
final busy = _busy.contains(message.id);
final url = InboxClient.safeActionUri(message.url);
return Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (message.readAt == null)
Padding(
padding: const EdgeInsets.only(
top: 7,
right: 8,
),
child: Semantics(
label: 'Unread',
child: Container(
width: 8,
height: 8,
decoration: BoxDecoration(
color: brand,
shape: BoxShape.circle,
),
),
),
),
Expanded(
child: Text(
message.title,
style: theme.textTheme.titleMedium,
),
),
],
),
const SizedBox(height: 8),
Text(message.body),
const SizedBox(height: 8),
Text(
message.createdAt,
style: theme.textTheme.bodySmall,
),
Wrap(
spacing: 8,
children: [
if (message.readAt == null)
TextButton(
onPressed: busy
? null
: () => unawaited(
_mutate(message, false),
),
child: const Text('Mark read'),
),
TextButton(
onPressed: busy
? null
: () =>
unawaited(_mutate(message, true)),
child: const Text('Archive'),
),
if (url != null && widget.onAction != null)
TextButton(
onPressed: () =>
widget.onAction!(message, null, url),
child: const Text('Open'),
),
for (final action in message.actions)
if (widget.onAction != null &&
(action.url == null ||
InboxClient.safeActionUri(
action.url,
) !=
null))
TextButton(
onPressed: () => widget.onAction!(
message,
action,
InboxClient.safeActionUri(action.url),
),
child: Text(action.label),
),
],
),
],
),
);
},
),
),
),
],
),
);
}
}
+261
View File
@@ -0,0 +1,261 @@
typedef Json = Map<String, dynamic>;
class InboxAction {
final String id, label;
final String? url;
InboxAction.fromJson(Json j)
: id = j['id'] as String,
label = j['label'] as String,
url = j['url'] as String?;
}
class InboxMessage {
final String id, recipientId, title, body, purpose, category, createdAt;
final String? url, readAt, archivedAt;
final List<InboxAction> actions;
InboxMessage.fromJson(Json j)
: id = j['id'] as String,
recipientId = j['recipient_id'] as String,
title = j['title'] as String,
body = j['body'] as String,
purpose = j['purpose'] as String,
category = j['category'] as String,
createdAt = j['created_at'] as String,
url = j['url'] as String?,
readAt = j['read_at'] as String?,
archivedAt = j['archived_at'] as String?,
actions = (j['actions'] as List? ?? [])
.map((v) => InboxAction.fromJson(v as Json))
.toList(growable: false);
}
class InboxSnapshot {
final List<InboxMessage> messages;
final int unreadCount;
InboxSnapshot.fromJson(Json j)
: messages = (j['messages'] as List)
.map((v) => InboxMessage.fromJson(v as Json))
.toList(growable: false),
unreadCount = j['unreadCount'] as int;
}
class InboxBranding {
final String? displayName, brandColor, brandTextColor, logoUrl;
InboxBranding.fromJson(Json j)
: displayName = j['displayName'] as String?,
brandColor = j['brandColor'] as String?,
brandTextColor = j['brandTextColor'] as String?,
logoUrl = j['logoUrl'] as String?;
}
class QuietHours {
final String start, end;
final List<int> days;
const QuietHours({
required this.start,
required this.end,
required this.days,
});
QuietHours.fromJson(Json j)
: start = j['start'] as String,
end = j['end'] as String,
days = (j['days'] as List).cast<int>();
Json toJson() => {'start': start, 'end': end, 'days': days};
}
class DeliveryRules {
final String timezone;
final QuietHours? quietHours;
final int? marketingDailyLimit;
const DeliveryRules({
required this.timezone,
this.quietHours,
this.marketingDailyLimit,
});
DeliveryRules.fromJson(Json j)
: timezone = j['timezone'] as String,
quietHours = j['quietHours'] == null
? null
: QuietHours.fromJson(j['quietHours'] as Json),
marketingDailyLimit = j['marketingDailyLimit'] as int?;
Json toJson() => {
'timezone': timezone,
'quietHours': quietHours?.toJson(),
'marketingDailyLimit': marketingDailyLimit,
};
}
class CategoryPreference {
final String category;
final bool inAppEnabled, marketingEnabled;
CategoryPreference.fromJson(Json j)
: category = j['category'] as String,
inAppEnabled = j['in_app_enabled'] == 1,
marketingEnabled = j['marketing_enabled'] == 1;
}
class ChannelPreference {
final String channel, category;
final bool enabled;
ChannelPreference.fromJson(Json j)
: channel = j['channel'] as String,
category = j['category'] as String,
enabled = j['enabled'] == 1;
}
class InboxPreferences {
final InboxBranding? branding;
final DeliveryRules deliveryRules;
final List<CategoryPreference> preferences;
final List<ChannelPreference> channelPreferences;
InboxPreferences.fromJson(Json j)
: branding = j['branding'] == null
? null
: InboxBranding.fromJson(j['branding'] as Json),
deliveryRules = DeliveryRules.fromJson(j['deliveryRules'] as Json),
preferences = (j['preferences'] as List)
.map((v) => CategoryPreference.fromJson(v as Json))
.toList(growable: false),
channelPreferences = (j['channelPreferences'] as List)
.map((v) => ChannelPreference.fromJson(v as Json))
.toList(growable: false);
}
class PreferenceInput {
final String category;
final bool inAppEnabled, marketingEnabled;
final Map<String, bool>? externalChannels;
final DeliveryRules? deliveryRules;
const PreferenceInput({
required this.category,
required this.inAppEnabled,
required this.marketingEnabled,
this.externalChannels,
this.deliveryRules,
});
Json toJson() => {
'category': category,
'inAppEnabled': inAppEnabled,
'marketingEnabled': marketingEnabled,
if (externalChannels != null) 'externalChannels': externalChannels,
if (deliveryRules != null) 'deliveryRules': deliveryRules!.toJson(),
};
}
enum MobilePlatform { android, ios }
class MobileRegistration {
final String connectionId, installationId, name, token, consentEvidence;
final MobilePlatform platform;
final bool marketingConsent;
const MobileRegistration({
required this.connectionId,
required this.installationId,
required this.name,
required this.platform,
required this.token,
this.marketingConsent = false,
this.consentEvidence = '',
});
Json toJson() => {
'action': 'register-mobile',
'connectionId': connectionId,
'installationId': installationId,
'name': name,
'platform': platform.name,
'token': token,
'marketingConsent': marketingConsent,
'consentEvidence': consentEvidence,
};
}
class MobileDevice {
final String id,
connectionId,
installationId,
name,
platform,
createdAt,
updatedAt;
final bool active;
final int revision;
MobileDevice.fromJson(Json j)
: id = j['id'] as String,
connectionId = j['connection_id'] as String,
installationId = j['installation_id'] as String,
name = j['name'] as String,
platform = j['platform'] as String,
createdAt = j['created_at'] as String,
updatedAt = j['updated_at'] as String,
active = j['active'] == 1,
revision = j['revision'] as int;
}
class WebDevice {
final String id, connectionId, name, createdAt, updatedAt;
final bool active;
WebDevice.fromJson(Json j)
: id = j['id'] as String,
connectionId = j['connection_id'] as String,
name = j['name'] as String,
createdAt = j['created_at'] as String,
updatedAt = j['updated_at'] as String,
active = j['active'] == 1;
}
class MobileConnection {
final String id, provider, applicationId;
final bool isDefault;
MobileConnection.fromJson(Json j)
: id = j['id'] as String,
provider = j['provider'] as String,
applicationId = j['applicationId'] as String,
isDefault = j['isDefault'] as bool;
}
class WebConnection {
final String id, publicKey;
final bool isDefault;
WebConnection.fromJson(Json j)
: id = j['id'] as String,
publicKey = j['publicKey'] as String,
isDefault = j['isDefault'] as bool;
}
class InboxDevices {
final List<MobileDevice> mobileDevices;
final List<WebDevice> devices;
final List<MobileConnection> mobileConnections;
final List<WebConnection> connections;
InboxDevices.fromJson(Json j)
: mobileDevices = (j['mobileDevices'] as List)
.map((v) => MobileDevice.fromJson(v as Json))
.toList(growable: false),
devices = (j['devices'] as List)
.map((v) => WebDevice.fromJson(v as Json))
.toList(growable: false),
mobileConnections = (j['mobileConnections'] as List)
.map((v) => MobileConnection.fromJson(v as Json))
.toList(growable: false),
connections = (j['connections'] as List)
.map((v) => WebConnection.fromJson(v as Json))
.toList(growable: false);
}
class DeviceResult {
final String id;
final int? revision;
final bool replayed;
DeviceResult.fromJson(Json j)
: id = j['id'] as String,
revision = j['revision'] as int?,
replayed = j['replayed'] as bool;
}
class InboxException implements Exception {
final String code, message;
final int status;
const InboxException(this.code, this.message, [this.status = 0]);
@override
String toString() => message;
}
+5
View File
@@ -0,0 +1,5 @@
library;
export 'src/models.dart';
export 'src/client.dart';
export 'src/inbox_view.dart';