Skip to content

Commit 41a52ee

Browse files
Copilotjhass
andauthored
Show relative timestamps in notification center (#87)
* Initial plan * Show timestamps in notifications Agent-Logs-Url: https://github.com/jhass/insporation/sessions/a32c0ff4-62f3-47cd-ba7d-2babf83f6a50 Co-authored-by: jhass <141294+jhass@users.noreply.github.com> * Fix notification timestamp test setup Agent-Logs-Url: https://github.com/jhass/insporation/sessions/a32c0ff4-62f3-47cd-ba7d-2babf83f6a50 Co-authored-by: jhass <141294+jhass@users.noreply.github.com> * improve fallback avatar --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: jhass <141294+jhass@users.noreply.github.com> Co-authored-by: Jonne Haß <me@jhass.eu>
1 parent bc564cb commit 41a52ee

3 files changed

Lines changed: 114 additions & 13 deletions

File tree

lib/notifications_page.dart

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import 'src/client.dart';
55
import 'src/item_stream.dart';
66
import 'src/localizations.dart';
77
import 'src/navigation.dart';
8+
import 'src/timeago.dart';
89
import 'src/utils.dart';
910
import 'src/widgets.dart';
1011
import 'src/colors.dart' as colors;
@@ -74,6 +75,14 @@ class _NotificationListItemState extends State<_NotificationListItem> with State
7475
child: ListTile(
7576
leading: AvatarStack(people: widget.notification.eventCreators),
7677
title: Text(_title),
78+
subtitle: Timeago(
79+
widget.notification.createdAt,
80+
textStyle: TextStyle(
81+
fontStyle: FontStyle.italic,
82+
color: theme.hintColor,
83+
fontSize: 12,
84+
),
85+
),
7786
),
7887
),
7988
),

lib/src/widgets.dart

Lines changed: 39 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -108,17 +108,35 @@ class Avatar extends StatelessWidget {
108108

109109
@override
110110
Widget build(BuildContext context) {
111+
final hasUrl = url != null && url!.trim().isNotEmpty;
111112
return Container(
112113
width: size,
113114
height: size,
114-
child: url != null ? ClipRRect(
115+
child: hasUrl ? ClipRRect(
115116
borderRadius: BorderRadius.circular(5),
116117
child: RemoteImage(
117118
url!,
118-
fallback: Icon(Icons.person),
119+
fallback: _AvatarFallback(size: size),
119120
fit: BoxFit.cover,
120121
)
121-
) : Icon(Icons.person),
122+
) : _AvatarFallback(size: size),
123+
);
124+
}
125+
}
126+
127+
class _AvatarFallback extends StatelessWidget {
128+
const _AvatarFallback({required this.size});
129+
130+
final double size;
131+
132+
@override
133+
Widget build(BuildContext context) {
134+
return Container(
135+
width: size,
136+
height: size,
137+
color: Theme.of(context).colorScheme.surface,
138+
alignment: Alignment.center,
139+
child: Icon(Icons.person, size: size * 0.6),
122140
);
123141
}
124142
}
@@ -130,34 +148,42 @@ class AvatarStack extends StatelessWidget {
130148

131149
@override
132150
Widget build(BuildContext context) {
133-
final people = this.people.where((person) => person.avatar != null).toList(),
134-
displayCount = min(3, people.length);
151+
final displayCount = min(3, people.length);
135152

136153
return SizedBox.fromSize(
137154
size: Size.square(54),
138155
child: displayCount > 0 ? Stack(
139-
children: List.generate(min(3, people.length), (index) =>
156+
children: List.generate(displayCount, (index) =>
140157
Positioned(
141158
top: 4.0 * (displayCount - index - 1),
142159
left: 4.0 * (displayCount - index - 1),
143160
child: ClipRRect(
144161
borderRadius: BorderRadius.circular(5),
145162
child: Container(
146163
color: Theme.of(context).colorScheme.surface,
147-
child: RemoteImage(
148-
people[displayCount - index -1].avatar!,
149-
width: 46,
150-
height: 46,
151-
fallback: SizedBox.shrink(),
152-
fit: BoxFit.cover
153-
),
164+
child: _stackedAvatar(people[displayCount - index - 1], context),
154165
)
155166
)
156167
)
157168
),
158169
) : Center(child: Icon(Icons.person, size: 46))
159170
);
160171
}
172+
173+
Widget _stackedAvatar(Person person, BuildContext context) {
174+
final avatar = person.avatar;
175+
if (avatar == null || avatar.trim().isEmpty) {
176+
return const _AvatarFallback(size: 46);
177+
}
178+
179+
return RemoteImage(
180+
avatar,
181+
width: 46,
182+
height: 46,
183+
fallback: const _AvatarFallback(size: 46),
184+
fit: BoxFit.cover,
185+
);
186+
}
161187
}
162188

163189
class UnreadItemsIndicatorIcon<T extends ItemCountNotifier> extends StatefulWidget {

test/notifications_page_test.dart

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
import 'package:flutter/material.dart' hide Notification;
2+
import 'package:flutter_test/flutter_test.dart';
3+
import 'package:insporation/l10n/app_localizations.dart';
4+
import 'package:insporation/notifications_page.dart';
5+
import 'package:insporation/src/client.dart';
6+
import 'package:insporation/src/localizations.dart';
7+
import 'package:insporation/src/navigation.dart';
8+
import 'package:insporation/src/timeago.dart';
9+
import 'package:provider/provider.dart';
10+
import 'package:timeago/timeago.dart' as timeago;
11+
12+
void main() {
13+
testWidgets('notifications page shows relative timestamps', (tester) async {
14+
final createdAt = DateTime.now().subtract(const Duration(hours: 2));
15+
Timeago.loadLocale(const Locale('en'));
16+
final notification = Notification(
17+
guid: 'notification-1',
18+
type: NotificationType.liked,
19+
read: true,
20+
targetGuid: 'post-1',
21+
targetAuthor: null,
22+
eventCreators: [
23+
Person(
24+
guid: 'person-1',
25+
diasporaId: 'alice@example.org',
26+
name: 'Alice',
27+
avatar: null,
28+
),
29+
],
30+
createdAt: createdAt,
31+
);
32+
33+
await tester.pumpWidget(
34+
MultiProvider(
35+
providers: [
36+
Provider<Client>.value(value: TestClient([notification])),
37+
ChangeNotifierProvider(create: (_) => CurrentNavigationItemReselectedEvents()),
38+
ChangeNotifierProvider(create: (_) => UnreadNotificationsCount()),
39+
ChangeNotifierProvider(create: (_) => UnreadConversationsCount()),
40+
],
41+
child: MaterialApp(
42+
localizationsDelegates: AppLocalizations.localizationsDelegates,
43+
supportedLocales: supportedLocales,
44+
home: NotificationsPage(),
45+
),
46+
),
47+
);
48+
49+
await tester.pump();
50+
await tester.pumpAndSettle();
51+
52+
expect(find.textContaining('Alice'), findsOneWidget);
53+
expect(find.text(timeago.format(createdAt, locale: 'en')), findsOneWidget);
54+
expect(find.byType(Timeago), findsOneWidget);
55+
});
56+
}
57+
58+
class TestClient extends Client {
59+
TestClient(this.notifications);
60+
61+
final List<Notification> notifications;
62+
63+
@override
64+
Future<Page<Notification>> fetchNotifications({bool onlyUnread = false, String? page, int? perPage}) async =>
65+
Page(content: notifications);
66+
}

0 commit comments

Comments
 (0)