Skip to content

Commit f02d4e3

Browse files
committed
RecentDmConversationsPage: Add
Fixes: #119
1 parent c854003 commit f02d4e3

File tree

3 files changed

+162
-8
lines changed

3 files changed

+162
-8
lines changed

lib/widgets/app.dart

+6
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import '../model/narrow.dart';
44
import 'about_zulip.dart';
55
import 'login.dart';
66
import 'message_list.dart';
7+
import 'recent_dm_conversations.dart';
78
import 'store.dart';
89

910
class ZulipApp extends StatelessWidget {
@@ -152,6 +153,11 @@ class HomePage extends StatelessWidget {
152153
MessageListPage.buildRoute(context: context,
153154
narrow: const AllMessagesNarrow())),
154155
child: const Text("All messages")),
156+
const SizedBox(height: 16),
157+
ElevatedButton(
158+
onPressed: () => Navigator.push(context,
159+
RecentDmConversationsPage.buildRoute(context: context)),
160+
child: const Text("Direct messages")),
155161
if (testStreamId != null) ...[
156162
const SizedBox(height: 16),
157163
ElevatedButton(

lib/widgets/content.dart

+17-8
Original file line numberDiff line numberDiff line change
@@ -812,16 +812,20 @@ class RealmContentNetworkImage extends StatelessWidget {
812812
}
813813
}
814814

815-
/// A rounded square with size [size] showing a user's avatar.
815+
/// A square showing a user's avatar.
816+
///
817+
/// To set the size and clip the corners to be round, pass [size].
818+
/// If [size] is not passed, the caller takes responsibility
819+
/// for doing that with its own square wrapper.
816820
class Avatar extends StatelessWidget {
817821
const Avatar({
818822
super.key,
819823
required this.userId,
820-
required this.size,
824+
this.size,
821825
});
822826

823827
final int userId;
824-
final double size;
828+
final double? size;
825829

826830
@override
827831
Widget build(BuildContext context) {
@@ -832,16 +836,21 @@ class Avatar extends StatelessWidget {
832836
null => null, // TODO handle computing gravatars
833837
var avatarUrl => resolveUrl(avatarUrl, store.account),
834838
};
835-
final avatar = (resolvedUrl == null)
839+
840+
Widget current = (resolvedUrl == null)
836841
? const SizedBox.shrink()
837842
: RealmContentNetworkImage(resolvedUrl, filterQuality: FilterQuality.medium);
838843

839-
return SizedBox.square(
840-
dimension: size,
841-
child: ClipRRect(
844+
if (size != null) {
845+
current = ClipRRect(
842846
borderRadius: const BorderRadius.all(Radius.circular(4)), // TODO vary with [size]?
843847
clipBehavior: Clip.antiAlias,
844-
child: avatar));
848+
child: current);
849+
}
850+
851+
return SizedBox.square(
852+
dimension: size, // may be null
853+
child: current);
845854
}
846855
}
847856

+139
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
import 'package:collection/collection.dart';
2+
import 'package:flutter/material.dart';
3+
4+
import '../model/narrow.dart';
5+
import '../model/recent_dm_conversations.dart';
6+
import 'content.dart';
7+
import 'icons.dart';
8+
import 'message_list.dart';
9+
import 'page.dart';
10+
import 'store.dart';
11+
12+
class RecentDmConversationsPage extends StatefulWidget {
13+
const RecentDmConversationsPage({super.key});
14+
15+
static Route<void> buildRoute({required BuildContext context}) {
16+
return MaterialAccountPageRoute(context: context,
17+
builder: (context) => const RecentDmConversationsPage());
18+
}
19+
20+
@override
21+
State<RecentDmConversationsPage> createState() => _RecentDmConversationsPageState();
22+
}
23+
24+
class _RecentDmConversationsPageState extends State<RecentDmConversationsPage> with PerAccountStoreAwareStateMixin<RecentDmConversationsPage> {
25+
RecentDmConversationsView? model;
26+
27+
@override
28+
void onNewStore() {
29+
model?.removeListener(_modelChanged);
30+
model = PerAccountStoreWidget.of(context).recentDmConversationsView
31+
..addListener(_modelChanged);
32+
}
33+
34+
void _modelChanged() {
35+
setState(() {
36+
// The actual state lives in [model].
37+
// This method was called because that just changed.
38+
});
39+
}
40+
41+
Widget _buildItem(BuildContext context, DmNarrow narrow) {
42+
final colorScheme = Theme.of(context).colorScheme;
43+
44+
final allRecipientIds = narrow.allRecipientIds;
45+
final store = PerAccountStoreWidget.of(context);
46+
final selfUser = store.users[store.account.userId]!;
47+
final recipientsSansSelf = allRecipientIds
48+
.whereNot((id) => id == selfUser.userId)
49+
.map((id) => store.users[id]!)
50+
.toList();
51+
52+
// Distinguish contentful text (names) in bold,
53+
// against other parts of the title like comma separators.
54+
final baseTitleStyle = Theme.of(context).textTheme.titleSmall!;
55+
final nameStyle = baseTitleStyle.merge(const TextStyle(fontWeight: FontWeight.bold));
56+
57+
final Widget title;
58+
final Widget avatar;
59+
switch (recipientsSansSelf.length) {
60+
case 0: {
61+
title = Text.rich(
62+
TextSpan(children: [ // TODO(i18n)
63+
TextSpan(text: selfUser.fullName, style: nameStyle),
64+
const TextSpan(text: ' (you)'),
65+
]));
66+
avatar = Avatar(userId: selfUser.userId);
67+
break;
68+
}
69+
case 1: {
70+
final otherUser = recipientsSansSelf.single;
71+
title = Text(otherUser.fullName, style: nameStyle);
72+
avatar = Avatar(userId: otherUser.userId);
73+
break;
74+
}
75+
default: {
76+
title = Text.rich(
77+
TextSpan(
78+
children: Iterable.generate(recipientsSansSelf.length * 2 - 1,
79+
// TODO(i18n): List formatting, like you can do in JavaScript:
80+
// new Intl.ListFormat('ja').format(['Chris', 'Greg', 'Alya'])
81+
// // 'Chris、Greg、Alya'
82+
(index) => index % 2 == 0
83+
? TextSpan(text: recipientsSansSelf[(index / 2).floor()].fullName, style: nameStyle)
84+
: const TextSpan(text: ', ')
85+
).toList()));
86+
avatar = ColoredBox(color: colorScheme.surfaceVariant,
87+
child: Center(
88+
child: Icon(ZulipIcons.group_dm, color: colorScheme.onSurfaceVariant)));
89+
break;
90+
}
91+
}
92+
93+
return ListTile(
94+
leading: _LeadingAvatarContainer(child: avatar),
95+
title: DefaultTextStyle(
96+
style: baseTitleStyle,
97+
maxLines: 2,
98+
overflow: TextOverflow.ellipsis,
99+
child: title),
100+
onTap: () {
101+
Navigator.push(context,
102+
MessageListPage.buildRoute(context: context, narrow: narrow));
103+
});
104+
}
105+
106+
@override
107+
Widget build(BuildContext context) {
108+
final sorted = model!.sorted;
109+
return Scaffold(
110+
appBar: AppBar(title: const Text('Direct messages')),
111+
body: ListView.builder(
112+
itemCount: sorted.length,
113+
itemBuilder: (context, index) => _buildItem(context, sorted[index])));
114+
}
115+
}
116+
117+
/// Clips and sizes an avatar for a list item in [RecentDmConversationsPage].
118+
///
119+
/// See "Leading avatar container" at:
120+
/// <https://m3.material.io/components/lists/specs>
121+
class _LeadingAvatarContainer extends StatelessWidget {
122+
const _LeadingAvatarContainer({required this.child});
123+
124+
final Widget child;
125+
126+
@override
127+
Widget build(BuildContext context) {
128+
return SizedBox.square(
129+
dimension: 40, // 40dp in the spec (see link in dartdoc).
130+
// The spec actually wants circular avatars,
131+
// but we've long preferred rounded squares.
132+
child: ClipRRect(
133+
// Web uses 4px in the message list for 35px square avatars.
134+
// To roughly match that for our 40dp square, we also use 4dp.
135+
borderRadius: const BorderRadius.all(Radius.circular(4)),
136+
clipBehavior: Clip.antiAlias,
137+
child: child));
138+
}
139+
}

0 commit comments

Comments
 (0)