Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(notifications_app): Display rich notifications #2456

Open
wants to merge 4 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,9 @@ import 'package:neon_framework/utils.dart';
import 'package:nextcloud/core.dart' as core;

/// Widget to display a Deck card from a rich object.
class TalkRichObjectDeckCard extends StatelessWidget {
/// Creates a new Talk rich object Deck card.
const TalkRichObjectDeckCard({
class NeonRichObjectDeckCard extends StatelessWidget {
/// Creates a new Neon rich object Deck card.
const NeonRichObjectDeckCard({
required this.parameter,
super.key,
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,9 @@ import 'package:neon_framework/widgets.dart';
import 'package:nextcloud/core.dart' as core;

/// Widget used to render rich object parameters with unknown types.
class TalkRichObjectFallback extends StatelessWidget {
/// Creates a new Talk rich object fallback
const TalkRichObjectFallback({
class NeonRichObjectFallback extends StatelessWidget {
/// Creates a new Neon rich object fallback
const NeonRichObjectFallback({
required this.parameter,
required this.textStyle,
super.key,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,9 @@ import 'package:neon_framework/widgets.dart';
import 'package:nextcloud/core.dart' as core;

/// Displays a file from a rich object.
class TalkRichObjectFile extends StatelessWidget {
/// Creates a new Talk rich object file.
const TalkRichObjectFile({
class NeonRichObjectFile extends StatelessWidget {
/// Creates a new Neon rich object file.
const NeonRichObjectFile({
required this.parameter,
required this.textStyle,
super.key,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,9 @@ import 'package:neon_framework/widgets.dart';
import 'package:nextcloud/core.dart' as core;

/// Displays a mention chip from a rich object.
class TalkRichObjectMention extends StatelessWidget {
/// Create a new Talk rich object mention.
const TalkRichObjectMention({
class NeonRichObjectMention extends StatelessWidget {
/// Create a new Neon rich object mention.
const NeonRichObjectMention({
required this.parameter,
required this.textStyle,
super.key,
Expand Down
173 changes: 173 additions & 0 deletions packages/neon_framework/lib/src/widgets/rich_text/rich_text.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
import 'package:built_collection/built_collection.dart';
import 'package:built_value/json_object.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter/widgets.dart';
import 'package:intersperse/intersperse.dart';
import 'package:neon_framework/src/widgets/rich_text/deck_card.dart';
import 'package:neon_framework/src/widgets/rich_text/fallback.dart';
import 'package:neon_framework/src/widgets/rich_text/file.dart';
import 'package:neon_framework/src/widgets/rich_text/mention.dart';
import 'package:nextcloud/core.dart' as core;

export 'package:neon_framework/src/widgets/rich_text/deck_card.dart';
export 'package:neon_framework/src/widgets/rich_text/fallback.dart';
export 'package:neon_framework/src/widgets/rich_text/file.dart';
export 'package:neon_framework/src/widgets/rich_text/mention.dart';

/// Renders the [text] as a rich [TextSpan].
TextSpan buildRichTextSpan({
required String text,
required BuiltMap<String, BuiltMap<String, JsonObject>> parameters,
required BuiltList<String> references,
required TextStyle style,
required void Function(String reference) onReferenceClicked,
bool isPreview = false,
}) {
if (isPreview) {
text = text.replaceAll('\n', ' ');
}

final unusedParameters = <String, core.RichObjectParameter>{};

var parts = [text];
for (final entry in parameters.entries) {
final newParts = <String>[];

var found = false;
for (final part in parts) {
final p = part.split('{${entry.key}}');
newParts.addAll(p.intersperse('{${entry.key}}'));
if (p.length > 1) {
found = true;
}
}

if (!found) {
unusedParameters[entry.key] = core.RichObjectParameter.fromJson(
entry.value.map((key, value) => MapEntry(key, value.toString())).toMap(),
);
}

parts = newParts;
}
for (final reference in references) {
final newParts = <String>[];

for (final part in parts) {
final p = part.split(reference);
newParts.addAll(p.intersperse(reference));
}

parts = newParts;
}

final children = <InlineSpan>[];

for (final entry in unusedParameters.entries) {
if (entry.key == core.RichObjectParameter_Type.file.value) {
children
..add(
buildRichObjectParameter(
parameter: entry.value,
textStyle: style,
isPreview: isPreview,
),
)
..add(const TextSpan(text: '\n'));
}
}

for (final part in parts) {
if (part.isEmpty) {
continue;
}

var match = false;
for (final entry in parameters.entries) {
if ('{${entry.key}}' == part) {
children.add(
buildRichObjectParameter(
parameter: core.RichObjectParameter.fromJson(
entry.value.map((key, value) => MapEntry(key, value.toString())).toMap(),
),
textStyle: style,
isPreview: isPreview,
),
);
match = true;
break;
}
}
for (final reference in references) {
if (reference == part) {
final gestureRecognizer = TapGestureRecognizer()..onTap = () => onReferenceClicked(reference);

children.add(
TextSpan(
text: part,
style: style.copyWith(
decoration: TextDecoration.underline,
decorationThickness: 2,
),
recognizer: gestureRecognizer,
),
);
match = true;
break;
}
}

if (!match) {
children.add(
TextSpan(
style: style,
text: part,
),
);
}
}

return TextSpan(
style: style,
children: children,
);
}

/// Renders a rich object [parameter] to be interactive.
InlineSpan buildRichObjectParameter({
required core.RichObjectParameter parameter,
required TextStyle? textStyle,
required bool isPreview,
}) {
if (isPreview) {
return TextSpan(
text: parameter.name,
style: textStyle,
);
}

return WidgetSpan(
alignment: PlaceholderAlignment.middle,
child: switch (parameter.type) {
core.RichObjectParameter_Type.user ||
core.RichObjectParameter_Type.call ||
core.RichObjectParameter_Type.guest ||
core.RichObjectParameter_Type.userGroup =>
NeonRichObjectMention(
parameter: parameter,
textStyle: textStyle,
),
core.RichObjectParameter_Type.file => NeonRichObjectFile(
parameter: parameter,
textStyle: textStyle,
),
core.RichObjectParameter_Type.deckCard => NeonRichObjectDeckCard(
parameter: parameter,
),
_ => NeonRichObjectFallback(
parameter: parameter,
textStyle: textStyle,
),
},
);
}
1 change: 1 addition & 0 deletions packages/neon_framework/lib/widgets.dart
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ export 'package:neon_framework/src/widgets/image.dart' hide NeonImage;
export 'package:neon_framework/src/widgets/linear_progress_indicator.dart';
export 'package:neon_framework/src/widgets/list_view.dart';
export 'package:neon_framework/src/widgets/relative_time.dart';
export 'package:neon_framework/src/widgets/rich_text/rich_text.dart';
export 'package:neon_framework/src/widgets/server_icon.dart';
export 'package:neon_framework/src/widgets/user_avatar.dart' hide NeonUserStatusIndicator;
export 'package:neon_framework/src/widgets/user_status_icon.dart';
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import 'package:built_collection/built_collection.dart';
import 'package:flutter/material.dart';
import 'package:intersperse/intersperse.dart';
import 'package:neon_framework/models.dart';
Expand All @@ -21,6 +22,34 @@ class NotificationsNotification extends StatelessWidget {

@override
Widget build(BuildContext context) {
final subject = notification.subjectRichParameters!.isNotEmpty
? Text.rich(
buildRichTextSpan(
text: notification.subjectRich!,
parameters: notification.subjectRichParameters!,
references: BuiltList(),
style: Theme.of(context).textTheme.bodyLarge!,
onReferenceClicked: (_) {},
),
)
: Text(notification.subject);

final message = notification.messageRichParameters!.isNotEmpty
? Text.rich(
buildRichTextSpan(
text: notification.messageRich!,
parameters: notification.messageRichParameters!,
references: BuiltList(),
style: Theme.of(context).textTheme.bodyMedium!,
onReferenceClicked: (_) {},
),
overflow: TextOverflow.ellipsis,
)
: Text(
notification.message,
overflow: TextOverflow.ellipsis,
);

return Dismissible(
key: Key(notification.notificationId.toString()),
direction: DismissDirection.startToEnd,
Expand All @@ -30,15 +59,11 @@ class NotificationsNotification extends StatelessWidget {
onDelete();
},
child: ListTile(
title: Text(notification.subject),
title: subject,
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (notification.message.isNotEmpty)
Text(
notification.message,
overflow: TextOverflow.ellipsis,
),
if (notification.message.isNotEmpty) message,
RelativeTime(
date: tz.TZDateTime.parse(tz.UTC, notification.datetime),
),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ dependencies:

dev_dependencies:
build_runner: ^2.4.12
built_value: ^8.9.2
custom_lint: ^0.6.5
flutter_test:
sdk: flutter
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,9 @@ void main() {
when(() => notification.notificationId).thenReturn(i);
when(() => notification.app).thenReturn('app');
when(() => notification.subject).thenReturn('subject');
when(() => notification.subjectRichParameters).thenReturn(BuiltMap());
when(() => notification.message).thenReturn('message');
when(() => notification.messageRichParameters).thenReturn(BuiltMap());
when(() => notification.datetime).thenReturn(tz.TZDateTime.now(tz.UTC).toIso8601String());
when(() => notification.actions).thenReturn(BuiltList());
when(() => notification.icon).thenReturn('');
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import 'package:built_collection/built_collection.dart';
import 'package:built_value/json_object.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:mocktail/mocktail.dart';
import 'package:neon_framework/models.dart';
Expand Down Expand Up @@ -45,7 +46,9 @@ void main() {
when(() => notification.notificationId).thenReturn(0);
when(() => notification.app).thenReturn('app');
when(() => notification.subject).thenReturn('subject');
when(() => notification.subjectRichParameters).thenReturn(BuiltMap());
when(() => notification.message).thenReturn('message');
when(() => notification.messageRichParameters).thenReturn(BuiltMap());
when(() => notification.datetime).thenReturn(tz.TZDateTime.now(tz.UTC).toIso8601String());
when(() => notification.actions).thenReturn(BuiltList([primaryAction, secondaryAction]));
when(() => notification.icon).thenReturn('');
Expand All @@ -56,7 +59,7 @@ void main() {
account = MockAccount();
});

testWidgets('Notification', (tester) async {
testWidgets('Plain', (tester) async {
await tester.pumpWidgetWithAccessibility(
TestApp(
localizationsDelegates: NotificationsLocalizations.localizationsDelegates,
Expand All @@ -77,7 +80,7 @@ void main() {
expect(find.text('subject'), findsOne);
expect(find.text('now'), findsOne);
expect(find.byType(NotificationsAction), findsExactly(2));
await expectLater(find.byType(TestApp), matchesGoldenFile('goldens/notification.png'));
await expectLater(find.byType(TestApp), matchesGoldenFile('goldens/notification_plain.png'));

await tester.tap(find.byType(NotificationsNotification));
verify(() => urlLauncher.launchUrl('https://cloud.example.com:8443/link', any())).called(1);
Expand All @@ -86,4 +89,46 @@ void main() {
await tester.pumpAndSettle();
verify(callback.call).called(1);
});

testWidgets('Rich', (tester) async {
when(() => notification.subjectRich).thenReturn('subject {user}');
when(() => notification.subjectRichParameters).thenReturn(
BuiltMap({
'user': BuiltMap<String, JsonObject>({
'type': JsonObject('user'),
'id': JsonObject('user'),
'name': JsonObject('user'),
}),
}),
);
when(() => notification.messageRich).thenReturn('message {call}');
when(() => notification.messageRichParameters).thenReturn(
BuiltMap({
'call': BuiltMap<String, JsonObject>({
'type': JsonObject('call'),
'id': JsonObject('call'),
'name': JsonObject('call'),
'icon-url': JsonObject('call'),
}),
}),
);

await tester.pumpWidgetWithAccessibility(
TestApp(
localizationsDelegates: NotificationsLocalizations.localizationsDelegates,
supportedLocales: NotificationsLocalizations.supportedLocales,
providers: [
Provider<BuiltSet<AppImplementation>>.value(value: BuiltSet()),
Provider<Account>.value(value: account),
],
child: NotificationsNotification(
notification: notification,
onDelete: callback,
),
),
);

expect(find.byType(NeonRichObjectMention), findsExactly(2));
await expectLater(find.byType(TestApp), matchesGoldenFile('goldens/notification_rich.png'));
});
}
Loading