Skip to content

Commit 44f520b

Browse files
Merge pull request #2456 from nextcloud/feat/notifications_app/rich-notifications
2 parents 48f3bb8 + c81a183 commit 44f520b

25 files changed

+568
-504
lines changed
Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,9 @@ import 'package:neon_framework/utils.dart';
55
import 'package:nextcloud/core.dart' as core;
66

77
/// Widget to display a Deck card from a rich object.
8-
class TalkRichObjectDeckCard extends StatelessWidget {
9-
/// Creates a new Talk rich object Deck card.
10-
const TalkRichObjectDeckCard({
8+
class NeonRichObjectDeckCard extends StatelessWidget {
9+
/// Creates a new Neon rich object Deck card.
10+
const NeonRichObjectDeckCard({
1111
required this.parameter,
1212
super.key,
1313
});
Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,9 @@ import 'package:neon_framework/widgets.dart';
55
import 'package:nextcloud/core.dart' as core;
66

77
/// Widget used to render rich object parameters with unknown types.
8-
class TalkRichObjectFallback extends StatelessWidget {
9-
/// Creates a new Talk rich object fallback
10-
const TalkRichObjectFallback({
8+
class NeonRichObjectFallback extends StatelessWidget {
9+
/// Creates a new Neon rich object fallback
10+
const NeonRichObjectFallback({
1111
required this.parameter,
1212
required this.textStyle,
1313
super.key,

packages/neon_framework/packages/talk_app/lib/src/widgets/rich_object/file.dart renamed to packages/neon_framework/lib/src/widgets/rich_text/file.dart

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,9 @@ import 'package:neon_framework/widgets.dart';
55
import 'package:nextcloud/core.dart' as core;
66

77
/// Displays a file from a rich object.
8-
class TalkRichObjectFile extends StatelessWidget {
9-
/// Creates a new Talk rich object file.
10-
const TalkRichObjectFile({
8+
class NeonRichObjectFile extends StatelessWidget {
9+
/// Creates a new Neon rich object file.
10+
const NeonRichObjectFile({
1111
required this.parameter,
1212
required this.textStyle,
1313
super.key,
Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,9 @@ import 'package:neon_framework/widgets.dart';
88
import 'package:nextcloud/core.dart' as core;
99

1010
/// Displays a mention chip from a rich object.
11-
class TalkRichObjectMention extends StatelessWidget {
12-
/// Create a new Talk rich object mention.
13-
const TalkRichObjectMention({
11+
class NeonRichObjectMention extends StatelessWidget {
12+
/// Create a new Neon rich object mention.
13+
const NeonRichObjectMention({
1414
required this.parameter,
1515
required this.textStyle,
1616
super.key,
Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,173 @@
1+
import 'package:built_collection/built_collection.dart';
2+
import 'package:built_value/json_object.dart';
3+
import 'package:flutter/gestures.dart';
4+
import 'package:flutter/widgets.dart';
5+
import 'package:intersperse/intersperse.dart';
6+
import 'package:neon_framework/src/widgets/rich_text/deck_card.dart';
7+
import 'package:neon_framework/src/widgets/rich_text/fallback.dart';
8+
import 'package:neon_framework/src/widgets/rich_text/file.dart';
9+
import 'package:neon_framework/src/widgets/rich_text/mention.dart';
10+
import 'package:nextcloud/core.dart' as core;
11+
12+
export 'package:neon_framework/src/widgets/rich_text/deck_card.dart';
13+
export 'package:neon_framework/src/widgets/rich_text/fallback.dart';
14+
export 'package:neon_framework/src/widgets/rich_text/file.dart';
15+
export 'package:neon_framework/src/widgets/rich_text/mention.dart';
16+
17+
/// Renders the [text] as a rich [TextSpan].
18+
TextSpan buildRichTextSpan({
19+
required String text,
20+
required BuiltMap<String, BuiltMap<String, JsonObject>> parameters,
21+
required BuiltList<String> references,
22+
required TextStyle style,
23+
required void Function(String reference) onReferenceClicked,
24+
bool isPreview = false,
25+
}) {
26+
if (isPreview) {
27+
text = text.replaceAll('\n', ' ');
28+
}
29+
30+
final unusedParameters = <String, core.RichObjectParameter>{};
31+
32+
var parts = [text];
33+
for (final entry in parameters.entries) {
34+
final newParts = <String>[];
35+
36+
var found = false;
37+
for (final part in parts) {
38+
final p = part.split('{${entry.key}}');
39+
newParts.addAll(p.intersperse('{${entry.key}}'));
40+
if (p.length > 1) {
41+
found = true;
42+
}
43+
}
44+
45+
if (!found) {
46+
unusedParameters[entry.key] = core.RichObjectParameter.fromJson(
47+
entry.value.map((key, value) => MapEntry(key, value.toString())).toMap(),
48+
);
49+
}
50+
51+
parts = newParts;
52+
}
53+
for (final reference in references) {
54+
final newParts = <String>[];
55+
56+
for (final part in parts) {
57+
final p = part.split(reference);
58+
newParts.addAll(p.intersperse(reference));
59+
}
60+
61+
parts = newParts;
62+
}
63+
64+
final children = <InlineSpan>[];
65+
66+
for (final entry in unusedParameters.entries) {
67+
if (entry.key == core.RichObjectParameter_Type.file.value) {
68+
children
69+
..add(
70+
buildRichObjectParameter(
71+
parameter: entry.value,
72+
textStyle: style,
73+
isPreview: isPreview,
74+
),
75+
)
76+
..add(const TextSpan(text: '\n'));
77+
}
78+
}
79+
80+
for (final part in parts) {
81+
if (part.isEmpty) {
82+
continue;
83+
}
84+
85+
var match = false;
86+
for (final entry in parameters.entries) {
87+
if ('{${entry.key}}' == part) {
88+
children.add(
89+
buildRichObjectParameter(
90+
parameter: core.RichObjectParameter.fromJson(
91+
entry.value.map((key, value) => MapEntry(key, value.toString())).toMap(),
92+
),
93+
textStyle: style,
94+
isPreview: isPreview,
95+
),
96+
);
97+
match = true;
98+
break;
99+
}
100+
}
101+
for (final reference in references) {
102+
if (reference == part) {
103+
final gestureRecognizer = TapGestureRecognizer()..onTap = () => onReferenceClicked(reference);
104+
105+
children.add(
106+
TextSpan(
107+
text: part,
108+
style: style.copyWith(
109+
decoration: TextDecoration.underline,
110+
decorationThickness: 2,
111+
),
112+
recognizer: gestureRecognizer,
113+
),
114+
);
115+
match = true;
116+
break;
117+
}
118+
}
119+
120+
if (!match) {
121+
children.add(
122+
TextSpan(
123+
style: style,
124+
text: part,
125+
),
126+
);
127+
}
128+
}
129+
130+
return TextSpan(
131+
style: style,
132+
children: children,
133+
);
134+
}
135+
136+
/// Renders a rich object [parameter] to be interactive.
137+
InlineSpan buildRichObjectParameter({
138+
required core.RichObjectParameter parameter,
139+
required TextStyle? textStyle,
140+
required bool isPreview,
141+
}) {
142+
if (isPreview) {
143+
return TextSpan(
144+
text: parameter.name,
145+
style: textStyle,
146+
);
147+
}
148+
149+
return WidgetSpan(
150+
alignment: PlaceholderAlignment.middle,
151+
child: switch (parameter.type) {
152+
core.RichObjectParameter_Type.user ||
153+
core.RichObjectParameter_Type.call ||
154+
core.RichObjectParameter_Type.guest ||
155+
core.RichObjectParameter_Type.userGroup =>
156+
NeonRichObjectMention(
157+
parameter: parameter,
158+
textStyle: textStyle,
159+
),
160+
core.RichObjectParameter_Type.file => NeonRichObjectFile(
161+
parameter: parameter,
162+
textStyle: textStyle,
163+
),
164+
core.RichObjectParameter_Type.deckCard => NeonRichObjectDeckCard(
165+
parameter: parameter,
166+
),
167+
_ => NeonRichObjectFallback(
168+
parameter: parameter,
169+
textStyle: textStyle,
170+
),
171+
},
172+
);
173+
}

packages/neon_framework/lib/widgets.dart

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ export 'package:neon_framework/src/widgets/image.dart' hide NeonImage;
77
export 'package:neon_framework/src/widgets/linear_progress_indicator.dart';
88
export 'package:neon_framework/src/widgets/list_view.dart';
99
export 'package:neon_framework/src/widgets/relative_time.dart';
10+
export 'package:neon_framework/src/widgets/rich_text/rich_text.dart';
1011
export 'package:neon_framework/src/widgets/server_icon.dart';
1112
export 'package:neon_framework/src/widgets/user_avatar.dart' hide NeonUserStatusIndicator;
1213
export 'package:neon_framework/src/widgets/user_status_icon.dart';

packages/neon_framework/packages/notifications_app/lib/src/widgets/notification.dart

Lines changed: 31 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import 'package:built_collection/built_collection.dart';
12
import 'package:flutter/material.dart';
23
import 'package:intersperse/intersperse.dart';
34
import 'package:neon_framework/models.dart';
@@ -21,6 +22,34 @@ class NotificationsNotification extends StatelessWidget {
2122

2223
@override
2324
Widget build(BuildContext context) {
25+
final subject = notification.subjectRichParameters!.isNotEmpty
26+
? Text.rich(
27+
buildRichTextSpan(
28+
text: notification.subjectRich!,
29+
parameters: notification.subjectRichParameters!,
30+
references: BuiltList(),
31+
style: Theme.of(context).textTheme.bodyLarge!,
32+
onReferenceClicked: (_) {},
33+
),
34+
)
35+
: Text(notification.subject);
36+
37+
final message = notification.messageRichParameters!.isNotEmpty
38+
? Text.rich(
39+
buildRichTextSpan(
40+
text: notification.messageRich!,
41+
parameters: notification.messageRichParameters!,
42+
references: BuiltList(),
43+
style: Theme.of(context).textTheme.bodyMedium!,
44+
onReferenceClicked: (_) {},
45+
),
46+
overflow: TextOverflow.ellipsis,
47+
)
48+
: Text(
49+
notification.message,
50+
overflow: TextOverflow.ellipsis,
51+
);
52+
2453
return Dismissible(
2554
key: Key(notification.notificationId.toString()),
2655
direction: DismissDirection.startToEnd,
@@ -30,15 +59,11 @@ class NotificationsNotification extends StatelessWidget {
3059
onDelete();
3160
},
3261
child: ListTile(
33-
title: Text(notification.subject),
62+
title: subject,
3463
subtitle: Column(
3564
crossAxisAlignment: CrossAxisAlignment.start,
3665
children: [
37-
if (notification.message.isNotEmpty)
38-
Text(
39-
notification.message,
40-
overflow: TextOverflow.ellipsis,
41-
),
66+
if (notification.message.isNotEmpty) message,
4267
RelativeTime(
4368
date: tz.TZDateTime.parse(tz.UTC, notification.datetime),
4469
),

packages/neon_framework/packages/notifications_app/pubspec.yaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ dependencies:
2929

3030
dev_dependencies:
3131
build_runner: ^2.4.12
32+
built_value: ^8.9.2
3233
custom_lint: ^0.6.7
3334
flutter_test:
3435
sdk: flutter
Loading

packages/neon_framework/packages/notifications_app/test/main_page_test.dart

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,7 +96,9 @@ void main() {
9696
when(() => notification.notificationId).thenReturn(i);
9797
when(() => notification.app).thenReturn('app');
9898
when(() => notification.subject).thenReturn('subject');
99+
when(() => notification.subjectRichParameters).thenReturn(BuiltMap());
99100
when(() => notification.message).thenReturn('message');
101+
when(() => notification.messageRichParameters).thenReturn(BuiltMap());
100102
when(() => notification.datetime).thenReturn(tz.TZDateTime.now(tz.UTC).toIso8601String());
101103
when(() => notification.actions).thenReturn(BuiltList());
102104
when(() => notification.icon).thenReturn('');

packages/neon_framework/packages/notifications_app/test/notification_test.dart

Lines changed: 47 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import 'package:built_collection/built_collection.dart';
2+
import 'package:built_value/json_object.dart';
23
import 'package:flutter_test/flutter_test.dart';
34
import 'package:mocktail/mocktail.dart';
45
import 'package:neon_framework/models.dart';
@@ -45,7 +46,9 @@ void main() {
4546
when(() => notification.notificationId).thenReturn(0);
4647
when(() => notification.app).thenReturn('app');
4748
when(() => notification.subject).thenReturn('subject');
49+
when(() => notification.subjectRichParameters).thenReturn(BuiltMap());
4850
when(() => notification.message).thenReturn('message');
51+
when(() => notification.messageRichParameters).thenReturn(BuiltMap());
4952
when(() => notification.datetime).thenReturn(tz.TZDateTime.now(tz.UTC).toIso8601String());
5053
when(() => notification.actions).thenReturn(BuiltList([primaryAction, secondaryAction]));
5154
when(() => notification.icon).thenReturn('');
@@ -56,7 +59,7 @@ void main() {
5659
account = MockAccount();
5760
});
5861

59-
testWidgets('Notification', (tester) async {
62+
testWidgets('Plain', (tester) async {
6063
await tester.pumpWidgetWithAccessibility(
6164
TestApp(
6265
localizationsDelegates: NotificationsLocalizations.localizationsDelegates,
@@ -77,7 +80,7 @@ void main() {
7780
expect(find.text('subject'), findsOne);
7881
expect(find.text('now'), findsOne);
7982
expect(find.byType(NotificationsAction), findsExactly(2));
80-
await expectLater(find.byType(TestApp), matchesGoldenFile('goldens/notification.png'));
83+
await expectLater(find.byType(TestApp), matchesGoldenFile('goldens/notification_plain.png'));
8184

8285
await tester.tap(find.byType(NotificationsNotification));
8386
verify(() => urlLauncher.launchUrl('https://cloud.example.com:8443/link', any())).called(1);
@@ -86,4 +89,46 @@ void main() {
8689
await tester.pumpAndSettle();
8790
verify(callback.call).called(1);
8891
});
92+
93+
testWidgets('Rich', (tester) async {
94+
when(() => notification.subjectRich).thenReturn('subject {user}');
95+
when(() => notification.subjectRichParameters).thenReturn(
96+
BuiltMap({
97+
'user': BuiltMap<String, JsonObject>({
98+
'type': JsonObject('user'),
99+
'id': JsonObject('user'),
100+
'name': JsonObject('user'),
101+
}),
102+
}),
103+
);
104+
when(() => notification.messageRich).thenReturn('message {call}');
105+
when(() => notification.messageRichParameters).thenReturn(
106+
BuiltMap({
107+
'call': BuiltMap<String, JsonObject>({
108+
'type': JsonObject('call'),
109+
'id': JsonObject('call'),
110+
'name': JsonObject('call'),
111+
'icon-url': JsonObject('call'),
112+
}),
113+
}),
114+
);
115+
116+
await tester.pumpWidgetWithAccessibility(
117+
TestApp(
118+
localizationsDelegates: NotificationsLocalizations.localizationsDelegates,
119+
supportedLocales: NotificationsLocalizations.supportedLocales,
120+
providers: [
121+
Provider<BuiltSet<AppImplementation>>.value(value: BuiltSet()),
122+
Provider<Account>.value(value: account),
123+
],
124+
child: NotificationsNotification(
125+
notification: notification,
126+
onDelete: callback,
127+
),
128+
),
129+
);
130+
131+
expect(find.byType(NeonRichObjectMention), findsExactly(2));
132+
await expectLater(find.byType(TestApp), matchesGoldenFile('goldens/notification_rich.png'));
133+
});
89134
}

0 commit comments

Comments
 (0)