Release recipient inbox SDKs 0.1.0 preview
This commit is contained in:
commit
d0781a9f3b
23 files changed
+1846
No files matched your search
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 WRNexus contributors
|
||||
|
||||
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.
|
||||
@@ -0,0 +1,21 @@
|
||||
# WRNexus Inbox 0.1.0 preview
|
||||
|
||||
Flutter 3.35+/Dart 3.9+ recipient inbox client and `WRNexusInboxView`. Distribution uses a versioned Git package; the release report records the published repository when available. Use `sdk/flutter` locally while publication is pending.
|
||||
|
||||
```dart
|
||||
import 'package:wrnexus_inbox/wrnexus_inbox.dart';
|
||||
final inbox = InboxClient(token: () => yourBackend.fetchRecipientToken());
|
||||
WRNexusInboxView(client: inbox, onAction: (message, action, url) {
|
||||
// Route the HTTPS URL in your app, or handle action?.id.
|
||||
});
|
||||
```
|
||||
|
||||
The view needs bounded height, for example a `Scaffold.body` or `Expanded`. It loads organization branding, shows unread count and messages, and supports read/archive, pull-to-refresh and custom actions. Text renders as text. Unsafe action URLs are refused. It pauses streaming with app lifecycle and cancels on dispose. Replacing `client` clears the previous recipient's state before loading another.
|
||||
|
||||
Methods: `messages(limit:)`, `markRead(id)`, `archive(id)`, `preferences()`, `savePreferences(input)`, `devices()`, `registerMobilePush(input)`, `unregisterMobilePush(id)`, `unregisterWebPush(id)` and `updates()`. Errors expose `InboxException.code`, `status` and `message`. Each request/reconnect fetches a fresh recipient token. Delivery-rule null values clear saved rules; omitted fields preserve existing values. Writes never retry automatically.
|
||||
|
||||
Your backend issues recipient tokens; never include a business API key. For push, configure an active Notifications FCM/APNs connection. Initialize the host's provider SDK, request notification permission, persist a random installation ID, and register the token using `MobileRegistration` with `MobilePlatform.android` or `.ios`. Subscribe to the provider's token-refresh callback and register the replacement using the same installation ID. Retain the returned device ID per signed-in recipient and record actual marketing consent/evidence.
|
||||
|
||||
Before logout unregister that recipient's stored device ID, dispose the view/subscription and call `inbox.close()`. If unregister fails, have your backend deactivate the device. A new recipient gets a new client; do not reuse inbox state across accounts. Provider setup and permission handling remain with the host app.
|
||||
|
||||
Validation: `flutter analyze` and `flutter test`. Tests exercise transport, errors, actions, read/archive, disposal and a branded widget golden. The golden uses explicitly labeled test fixture data. Real Android/iOS push delivery remains a separate device check.
|
||||
@@ -0,0 +1 @@
|
||||
include: package:flutter_lints/flutter.yaml
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
library;
|
||||
|
||||
export 'src/models.dart';
|
||||
export 'src/client.dart';
|
||||
export 'src/inbox_view.dart';
|
||||
@@ -0,0 +1,16 @@
|
||||
name: wrnexus_inbox
|
||||
description: Recipient-scoped WRNexus inbox, mobile push registration and branded Flutter inbox widget.
|
||||
version: 0.1.0
|
||||
homepage: https://docs.wrnexus.com/sdk
|
||||
publish_to: none
|
||||
environment:
|
||||
sdk: '>=3.9.0 <4.0.0'
|
||||
flutter: '>=3.35.0'
|
||||
dependencies:
|
||||
flutter:
|
||||
sdk: flutter
|
||||
http: ^1.5.0
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
sdk: flutter
|
||||
flutter_lints: ^6.0.0
|
||||
@@ -0,0 +1,113 @@
|
||||
import 'dart:convert';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:http/testing.dart';
|
||||
import 'package:wrnexus_inbox/wrnexus_inbox.dart';
|
||||
|
||||
void main() {
|
||||
test(
|
||||
'recipient transport, token refresh, writes and nullable delivery rules',
|
||||
() async {
|
||||
final calls = <http.Request>[];
|
||||
var tokens = 0;
|
||||
final client = InboxClient(
|
||||
token: () => 'recipient-${++tokens}',
|
||||
transport: MockClient((r) async {
|
||||
calls.add(r);
|
||||
if (r.method == 'GET') {
|
||||
return http.Response('{"messages":[],"unreadCount":3}', 200);
|
||||
}
|
||||
return http.Response('{"id":"d","revision":2,"replayed":false}', 200);
|
||||
}),
|
||||
);
|
||||
expect((await client.messages(limit: 20)).unreadCount, 3);
|
||||
await client.markRead('m');
|
||||
await client.archive('m');
|
||||
await client.savePreferences(
|
||||
const PreferenceInput(
|
||||
category: '*',
|
||||
inAppEnabled: true,
|
||||
marketingEnabled: false,
|
||||
deliveryRules: DeliveryRules(timezone: 'UTC'),
|
||||
),
|
||||
);
|
||||
await client.registerMobilePush(
|
||||
const MobileRegistration(
|
||||
connectionId: 'c',
|
||||
installationId: 'i',
|
||||
name: 'Phone',
|
||||
platform: MobilePlatform.android,
|
||||
token: 'fcm',
|
||||
marketingConsent: false,
|
||||
),
|
||||
);
|
||||
await client.unregisterMobilePush('d');
|
||||
await client.unregisterWebPush('w');
|
||||
expect(
|
||||
calls.map((r) => r.headers['authorization']),
|
||||
List.generate(7, (i) => 'Bearer recipient-${i + 1}'),
|
||||
);
|
||||
expect(calls.first.url.path, '/api/v1/inbox/messages');
|
||||
expect(calls.first.url.query, 'limit=20');
|
||||
expect(jsonDecode(calls[1].body), {'id': 'm', 'action': 'read'});
|
||||
expect((jsonDecode(calls[3].body) as Map)['deliveryRules'], {
|
||||
'timezone': 'UTC',
|
||||
'quietHours': null,
|
||||
'marketingDailyLimit': null,
|
||||
});
|
||||
expect((jsonDecode(calls[4].body) as Map)['action'], 'register-mobile');
|
||||
client.close();
|
||||
await expectLater(
|
||||
client.messages(),
|
||||
throwsA(isA<InboxException>().having((e) => e.code, 'code', 'closed')),
|
||||
);
|
||||
},
|
||||
);
|
||||
test('API errors stop without retry', () async {
|
||||
var calls = 0;
|
||||
final client = InboxClient(
|
||||
token: () => 'expired',
|
||||
transport: MockClient((_) async {
|
||||
calls++;
|
||||
return http.Response(
|
||||
'{"error":{"code":"expired-token","message":"Expired"}}',
|
||||
401,
|
||||
);
|
||||
}),
|
||||
);
|
||||
await expectLater(
|
||||
client.markRead('m'),
|
||||
throwsA(
|
||||
isA<InboxException>()
|
||||
.having((e) => e.status, 'status', 401)
|
||||
.having((e) => e.code, 'code', 'expired-token'),
|
||||
),
|
||||
);
|
||||
expect(calls, 1);
|
||||
client.close();
|
||||
});
|
||||
test('origin, actions and frame parsing', () {
|
||||
for (final text in [
|
||||
'http://example.com',
|
||||
'https://u:p@example.com',
|
||||
'https://example.com/a',
|
||||
'https://example.com?q=x',
|
||||
]) {
|
||||
expect(
|
||||
() => InboxClient(endpoint: Uri.parse(text), token: () => 'r'),
|
||||
throwsA(isA<InboxException>()),
|
||||
);
|
||||
}
|
||||
expect(InboxClient.safeActionUri('javascript:alert(1)'), isNull);
|
||||
expect(InboxClient.safeActionUri('https://u:p@example.com'), isNull);
|
||||
final parser = InboxEventParser();
|
||||
expect(parser.line(': heartbeat'), isNull);
|
||||
expect(parser.line(''), isNull);
|
||||
parser.line('event: inbox');
|
||||
parser.line('data: {"messages":[],"unreadCount":2}');
|
||||
expect(parser.line('')!.unreadCount, 2);
|
||||
parser.line('event: inbox');
|
||||
parser.line('data: broken');
|
||||
expect(() => parser.line(''), throwsFormatException);
|
||||
});
|
||||
}
|
||||
Binary file not shown.
@@ -0,0 +1,93 @@
|
||||
Copyright 2020 The Inter Project Authors (https://github.com/rsms/inter)
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
https://scripts.sil.org/OFL
|
||||
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 14 KiB |
@@ -0,0 +1,206 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:http/testing.dart';
|
||||
import 'package:wrnexus_inbox/wrnexus_inbox.dart';
|
||||
|
||||
class IdleStreamClient extends http.BaseClient {
|
||||
final controller = StreamController<List<int>>();
|
||||
@override
|
||||
Future<http.StreamedResponse> send(http.BaseRequest request) async =>
|
||||
http.StreamedResponse(controller.stream, 200);
|
||||
@override
|
||||
void close() {
|
||||
unawaited(controller.close());
|
||||
}
|
||||
}
|
||||
|
||||
void main() {
|
||||
setUpAll(() async {
|
||||
final loader = FontLoader('Inter');
|
||||
loader.addFont(
|
||||
Future.value(
|
||||
ByteData.sublistView(await File('test/fonts/Inter.ttf').readAsBytes()),
|
||||
),
|
||||
);
|
||||
await loader.load();
|
||||
final iconPath =
|
||||
'${Platform.environment['FLUTTER_ROOT'] ?? '../../.wrnexus/tools/flutter'}/bin/cache/artifacts/material_fonts/MaterialIcons-Regular.otf';
|
||||
final icons = FontLoader('MaterialIcons');
|
||||
icons.addFont(
|
||||
Future.value(ByteData.sublistView(await File(iconPath).readAsBytes())),
|
||||
);
|
||||
await icons.load();
|
||||
});
|
||||
testWidgets('branding, actions, read/archive and recipient change', (
|
||||
tester,
|
||||
) async {
|
||||
var read = false, archived = false;
|
||||
final writes = <Map<String, dynamic>>[];
|
||||
final streams = <IdleStreamClient>[];
|
||||
final client = InboxClient(
|
||||
token: () => 'recipient',
|
||||
streamTransport: () {
|
||||
final c = IdleStreamClient();
|
||||
streams.add(c);
|
||||
return c;
|
||||
},
|
||||
transport: MockClient((r) async {
|
||||
if (r.method == 'POST') {
|
||||
final body = jsonDecode(r.body) as Map<String, dynamic>;
|
||||
writes.add(body);
|
||||
read = body['action'] == 'read' || read;
|
||||
archived = body['action'] == 'archive' || archived;
|
||||
return http.Response('{"id":"m"}', 200);
|
||||
}
|
||||
if (r.url.path.endsWith('preferences')) {
|
||||
return http.Response(
|
||||
jsonEncode({
|
||||
'branding': {'displayName': 'Acme', 'brandColor': '#123456'},
|
||||
'deliveryRules': {
|
||||
'timezone': 'UTC',
|
||||
'quietHours': null,
|
||||
'marketingDailyLimit': null,
|
||||
},
|
||||
'preferences': [],
|
||||
'channelPreferences': [],
|
||||
}),
|
||||
200,
|
||||
);
|
||||
}
|
||||
return http.Response(
|
||||
jsonEncode({
|
||||
'messages': archived
|
||||
? []
|
||||
: [
|
||||
{
|
||||
'id': 'm',
|
||||
'recipient_id': 'r',
|
||||
'title': 'Order ready',
|
||||
'body': 'Your order is ready for collection.',
|
||||
'purpose': 'transactional',
|
||||
'category': 'orders',
|
||||
'created_at': '2026-10-09T00:00:00Z',
|
||||
'read_at': read ? '2026-10-09' : null,
|
||||
'archived_at': null,
|
||||
'url': 'javascript:alert(1)',
|
||||
'actions': [
|
||||
{'id': 'track', 'label': 'Track order'},
|
||||
],
|
||||
},
|
||||
],
|
||||
'unreadCount': read || archived ? 0 : 1,
|
||||
}),
|
||||
200,
|
||||
);
|
||||
}),
|
||||
);
|
||||
final actions = <String>[];
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
theme: ThemeData(
|
||||
fontFamily: 'Inter',
|
||||
scaffoldBackgroundColor: Colors.white,
|
||||
),
|
||||
home: Scaffold(
|
||||
body: RepaintBoundary(
|
||||
key: const Key('inbox'),
|
||||
child: ColoredBox(
|
||||
color: Colors.white,
|
||||
child: WRNexusInboxView(
|
||||
client: client,
|
||||
onAction: (m, a, u) => actions.add(a!.id),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Acme'), findsOneWidget);
|
||||
expect(find.text('Order ready'), findsOneWidget);
|
||||
expect(find.text('Open'), findsNothing);
|
||||
await tester.tap(find.text('Track order'));
|
||||
expect(actions, ['track']);
|
||||
await expectLater(
|
||||
find.byKey(const Key('inbox')),
|
||||
matchesGoldenFile('goldens/branded-inbox.png'),
|
||||
);
|
||||
await tester.tap(find.text('Mark read'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(writes.last, {'id': 'm', 'action': 'read'});
|
||||
expect(find.text('Mark read'), findsNothing);
|
||||
await tester.tap(find.text('Archive'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('No notifications yet'), findsOneWidget);
|
||||
await tester.pumpWidget(const SizedBox());
|
||||
client.close();
|
||||
expect(streams.every((s) => s.controller.isClosed), isTrue);
|
||||
});
|
||||
testWidgets(
|
||||
'replacing the client clears the previous recipient before the new request resolves',
|
||||
(tester) async {
|
||||
final pending = Completer<http.Response>();
|
||||
String settings(String name) => jsonEncode({
|
||||
'branding': {'displayName': name},
|
||||
'deliveryRules': {'timezone': 'UTC'},
|
||||
'preferences': [],
|
||||
'channelPreferences': [],
|
||||
});
|
||||
InboxClient make(String name, {bool wait = false}) => InboxClient(
|
||||
token: () => name,
|
||||
streamTransport: IdleStreamClient.new,
|
||||
transport: MockClient((request) async {
|
||||
if (request.url.path.endsWith('preferences')) {
|
||||
return wait ? pending.future : http.Response(settings(name), 200);
|
||||
}
|
||||
return http.Response(
|
||||
jsonEncode({
|
||||
'messages': [
|
||||
{
|
||||
'id': name,
|
||||
'recipient_id': name,
|
||||
'title': '$name message',
|
||||
'body': 'Private content',
|
||||
'purpose': 'transactional',
|
||||
'category': 'orders',
|
||||
'created_at': '2026-10-09',
|
||||
},
|
||||
],
|
||||
'unreadCount': 1,
|
||||
}),
|
||||
200,
|
||||
);
|
||||
}),
|
||||
);
|
||||
final first = make('First');
|
||||
final second = make('Second', wait: true);
|
||||
Widget app(InboxClient client) => MaterialApp(
|
||||
home: Scaffold(
|
||||
body: WRNexusInboxView(
|
||||
key: const ValueKey('same-view'),
|
||||
client: client,
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpWidget(app(first));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('First message'), findsOneWidget);
|
||||
await tester.pumpWidget(app(second));
|
||||
await tester.pump();
|
||||
expect(find.text('First message'), findsNothing);
|
||||
expect(find.text('First'), findsNothing);
|
||||
pending.complete(http.Response(settings('Second'), 200));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Second message'), findsOneWidget);
|
||||
expect(find.text('First message'), findsNothing);
|
||||
await tester.pumpWidget(const SizedBox());
|
||||
first.close();
|
||||
second.close();
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'package:wrnexus_inbox/src/client.dart';
|
||||
import 'package:wrnexus_inbox/src/models.dart';
|
||||
|
||||
Future<void> main() async {
|
||||
final server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0);
|
||||
var tokens = 0, streams = 0;
|
||||
final calls = <String>[];
|
||||
final bodies = <Json>[];
|
||||
final serving = server.listen((request) async {
|
||||
calls.add('${request.method} ${request.uri}');
|
||||
if (request.headers.value('authorization') !=
|
||||
'Bearer recipient-${calls.length}') {
|
||||
throw StateError('Recipient authorization/refresh mismatch');
|
||||
}
|
||||
if (request.uri.path.endsWith('stream')) {
|
||||
streams++;
|
||||
if (streams == 3) {
|
||||
request.response.statusCode = 401;
|
||||
request.response.write(
|
||||
'{"error":{"code":"recipient-token-invalid","message":"Expired"}}',
|
||||
);
|
||||
} else {
|
||||
request.response.headers.contentType = ContentType(
|
||||
'text',
|
||||
'event-stream',
|
||||
);
|
||||
request.response.write(
|
||||
'event: inbox\r\ndata: {"messages":[],"unreadCount":$streams}\r\n\r\n',
|
||||
);
|
||||
}
|
||||
} else if (request.method == 'POST') {
|
||||
final body = jsonDecode(await utf8.decoder.bind(request).join()) as Json;
|
||||
bodies.add(body);
|
||||
request.response.write(
|
||||
'{"ok":true,"id":"d","revision":2,"replayed":false}',
|
||||
);
|
||||
} else if (request.uri.path.endsWith('preferences')) {
|
||||
request.response.write(
|
||||
'{"branding":{"displayName":"HTTP fixture"},"deliveryRules":{"timezone":"UTC","quietHours":null,"marketingDailyLimit":null},"preferences":[],"channelPreferences":[]}',
|
||||
);
|
||||
} else if (request.uri.path.endsWith('devices')) {
|
||||
request.response.write(
|
||||
'{"devices":[],"mobileDevices":[],"connections":[],"mobileConnections":[]}',
|
||||
);
|
||||
} else {
|
||||
request.response.write('{"messages":[],"unreadCount":4}');
|
||||
}
|
||||
await request.response.close();
|
||||
});
|
||||
final client = InboxClient(
|
||||
endpoint: Uri.parse('http://127.0.0.1:${server.port}'),
|
||||
token: () => 'recipient-${++tokens}',
|
||||
);
|
||||
void require(bool value, String message) {
|
||||
if (!value) throw StateError(message);
|
||||
}
|
||||
|
||||
try {
|
||||
require((await client.messages(limit: 20)).unreadCount == 4, 'Snapshot');
|
||||
await client.markRead('m');
|
||||
await client.archive('m');
|
||||
await client.preferences();
|
||||
await client.devices();
|
||||
await client.savePreferences(
|
||||
const PreferenceInput(
|
||||
category: '*',
|
||||
inAppEnabled: true,
|
||||
marketingEnabled: false,
|
||||
deliveryRules: DeliveryRules(timezone: 'UTC'),
|
||||
),
|
||||
);
|
||||
await client.registerMobilePush(
|
||||
const MobileRegistration(
|
||||
connectionId: 'c',
|
||||
installationId: 'i',
|
||||
name: 'Phone',
|
||||
platform: MobilePlatform.ios,
|
||||
token: 'rotated',
|
||||
marketingConsent: false,
|
||||
),
|
||||
);
|
||||
await client.unregisterMobilePush('d');
|
||||
await client.unregisterWebPush('w');
|
||||
final received = <int>[];
|
||||
try {
|
||||
await for (final snapshot in client.updates()) {
|
||||
received.add(snapshot.unreadCount);
|
||||
}
|
||||
} on InboxException catch (error) {
|
||||
require(error.status == 401, 'Expected terminal auth error');
|
||||
}
|
||||
require(received.join(',') == '1,2', 'CRLF frames/reconnect');
|
||||
require(streams == 3, 'Terminal authentication must stop retries');
|
||||
require(calls.first == 'GET /api/v1/inbox/messages?limit=20', 'Query');
|
||||
require(
|
||||
bodies[0]['action'] == 'read' && bodies[1]['action'] == 'archive',
|
||||
'Writes',
|
||||
);
|
||||
require(
|
||||
(bodies[2]['deliveryRules'] as Json).containsKey('quietHours'),
|
||||
'Explicit nullable setting',
|
||||
);
|
||||
require(
|
||||
bodies[3]['installationId'] == 'i' && bodies[3]['token'] == 'rotated',
|
||||
'Rotation',
|
||||
);
|
||||
stdout.writeln(
|
||||
jsonEncode({
|
||||
'localHttpChecks': 'passed',
|
||||
'requests': calls.length,
|
||||
'streamSnapshots': received,
|
||||
'authorizationRefresh': true,
|
||||
'providerSendsSubmitted': false,
|
||||
}),
|
||||
);
|
||||
} finally {
|
||||
client.close();
|
||||
await serving.cancel();
|
||||
await server.close(force: true);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user