Skip to content

Commit caeca7e

Browse files
committed
RecentDmConversationsPage: Add
The screen's content area (so, the list of conversations, but not the app bar at the top) is built against Vlad's design: https://chat.zulip.org/#narrow/stream/243-mobile-team/topic/design.3A.20DM-conversation.20list/near/1594654 except that some features that appear in that design are left unimplemented for now, since we don't have data structures for them yet: - unread counts - user presence Fixes: #119
1 parent 08fa6f9 commit caeca7e

File tree

3 files changed

+154
-8
lines changed

3 files changed

+154
-8
lines changed

Diff for: 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(

Diff for: 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

Diff for: lib/widgets/recent_dm_conversations.dart

+131
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
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+
import 'text.dart';
12+
13+
class RecentDmConversationsPage extends StatefulWidget {
14+
const RecentDmConversationsPage({super.key});
15+
16+
static Route<void> buildRoute({required BuildContext context}) {
17+
return MaterialAccountPageRoute(context: context,
18+
builder: (context) => const RecentDmConversationsPage());
19+
}
20+
21+
@override
22+
State<RecentDmConversationsPage> createState() => _RecentDmConversationsPageState();
23+
}
24+
25+
class _RecentDmConversationsPageState extends State<RecentDmConversationsPage> with PerAccountStoreAwareStateMixin<RecentDmConversationsPage> {
26+
RecentDmConversationsView? model;
27+
28+
@override
29+
void onNewStore() {
30+
model?.removeListener(_modelChanged);
31+
model = PerAccountStoreWidget.of(context).recentDmConversationsView
32+
..addListener(_modelChanged);
33+
}
34+
35+
void _modelChanged() {
36+
setState(() {
37+
// The actual state lives in [model].
38+
// This method was called because that just changed.
39+
});
40+
}
41+
42+
Widget _buildItem(BuildContext context, DmNarrow narrow) {
43+
final allRecipientIds = narrow.allRecipientIds;
44+
final store = PerAccountStoreWidget.of(context);
45+
final selfUser = store.users[store.account.userId]!;
46+
final recipientsSansSelf = allRecipientIds
47+
.whereNot((id) => id == selfUser.userId)
48+
.map((id) => store.users[id]!)
49+
.toList();
50+
51+
final Widget title;
52+
final Widget avatar;
53+
switch (recipientsSansSelf.length) {
54+
case 0: {
55+
title = Text(selfUser.fullName);
56+
avatar = Avatar(userId: selfUser.userId);
57+
break;
58+
}
59+
case 1: {
60+
final otherUser = recipientsSansSelf.single;
61+
title = Text(otherUser.fullName);
62+
avatar = Avatar(userId: otherUser.userId);
63+
break;
64+
}
65+
default: {
66+
// TODO(i18n): List formatting, like you can do in JavaScript:
67+
// new Intl.ListFormat('ja').format(['Chris', 'Greg', 'Alya'])
68+
// // 'Chris、Greg、Alya'
69+
title = Text(recipientsSansSelf.map((r) => r.fullName).join(', '));
70+
avatar = ColoredBox(color: const Color(0x33808080),
71+
child: Center(
72+
child: Icon(ZulipIcons.group_dm, color: Colors.black.withOpacity(0.5))));
73+
break;
74+
}
75+
}
76+
77+
return InkWell(
78+
onTap: () {
79+
Navigator.push(context,
80+
MessageListPage.buildRoute(context: context, narrow: narrow));
81+
},
82+
child: ConstrainedBox(constraints: const BoxConstraints(minHeight: 48),
83+
child: Row(crossAxisAlignment: CrossAxisAlignment.center, children: [
84+
Padding(padding: const EdgeInsets.fromLTRB(12, 8, 0, 8),
85+
child: _AvatarWrapper(child: avatar)),
86+
const SizedBox(width: 8),
87+
Expanded(child: Padding(
88+
padding: const EdgeInsets.symmetric(vertical: 4),
89+
child: DefaultTextStyle(
90+
style: const TextStyle(
91+
fontFamily: 'Source Sans 3',
92+
fontSize: 17,
93+
height: (20 / 17),
94+
color: Color(0xFF222222),
95+
).merge(weightVariableTextStyle(context)),
96+
maxLines: 2,
97+
overflow: TextOverflow.ellipsis,
98+
child: title),
99+
)),
100+
const SizedBox(width: 8),
101+
// TODO: Unread count
102+
])));
103+
}
104+
105+
@override
106+
Widget build(BuildContext context) {
107+
final sorted = model!.sorted;
108+
return Scaffold(
109+
appBar: AppBar(title: const Text('Direct messages')),
110+
body: ListView.builder(
111+
itemCount: sorted.length,
112+
itemBuilder: (context, index) => _buildItem(context, sorted[index])));
113+
}
114+
}
115+
116+
/// Clips and sizes an avatar for a list item in [RecentDmConversationsPage].
117+
class _AvatarWrapper extends StatelessWidget {
118+
const _AvatarWrapper({required this.child});
119+
120+
final Widget child;
121+
122+
@override
123+
Widget build(BuildContext context) {
124+
return SizedBox.square(
125+
dimension: 32,
126+
child: ClipRRect(
127+
borderRadius: const BorderRadius.all(Radius.circular(3)),
128+
clipBehavior: Clip.antiAlias,
129+
child: child));
130+
}
131+
}

0 commit comments

Comments
 (0)